diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5fb86e8718e..6468ac21378 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -204,6 +204,19 @@ fn maybe_push_chat_wire_api_deprecation( }); } +fn session_timestamp_from_history(initial_history: &InitialHistory) -> Option { + let items = match initial_history { + InitialHistory::New => return None, + InitialHistory::Resumed(resumed) => resumed.history.as_slice(), + InitialHistory::Forked(items) => items.as_slice(), + }; + + items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(line) => Some(line.meta.timestamp.clone()), + _ => None, + }) +} + impl Codex { /// Spawn a new [`Codex`] and initialize the session. pub async fn spawn( @@ -597,6 +610,8 @@ impl Session { anyhow::Error::from(e) })?; let rollout_path = rollout_recorder.rollout_path.clone(); + let session_timestamp = session_timestamp_from_history(&initial_history) + .or_else(|| rollout_recorder.session_timestamp()); let mut post_session_configured_events = Vec::::new(); @@ -685,6 +700,7 @@ impl Session { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + timestamp: session_timestamp, model: session_configuration.model.clone(), model_provider_id: config.model_provider_id.clone(), approval_policy: session_configuration.approval_policy.value(), diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index a39f85c823d..71e60f097a4 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -47,6 +47,7 @@ use codex_protocol::protocol::SessionSource; pub struct RolloutRecorder { tx: Sender, pub(crate) rollout_path: PathBuf, + session_timestamp: Option, } #[derive(Clone)] @@ -160,6 +161,9 @@ impl RolloutRecorder { None, ), }; + let session_timestamp = meta + .as_ref() + .map(|session_meta| session_meta.timestamp.clone()); // Clone the cwd for the spawned task to collect git info asynchronously let cwd = config.cwd.clone(); @@ -174,7 +178,15 @@ impl RolloutRecorder { // driver instead of blocking the runtime. tokio::task::spawn(rollout_writer(file, rx, meta, cwd)); - Ok(Self { tx, rollout_path }) + Ok(Self { + tx, + rollout_path, + session_timestamp, + }) + } + + pub fn session_timestamp(&self) -> Option { + self.session_timestamp.clone() } pub(crate) async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> { diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 0b2df544559..e0a8d3fac4d 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -67,6 +67,7 @@ pub struct EventProcessorWithJsonOutput { last_total_token_usage: Option, running_mcp_tool_calls: HashMap, last_critical_error: Option, + session_timestamp: Option, } #[derive(Debug, Clone)] @@ -101,6 +102,7 @@ impl EventProcessorWithJsonOutput { last_total_token_usage: None, running_mcp_tool_calls: HashMap::new(), last_critical_error: None, + session_timestamp: None, } } @@ -167,9 +169,11 @@ impl EventProcessorWithJsonOutput { ) } - fn handle_session_configured(&self, payload: &SessionConfiguredEvent) -> Vec { + fn handle_session_configured(&mut self, payload: &SessionConfiguredEvent) -> Vec { + self.session_timestamp.clone_from(&payload.timestamp); vec![ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: payload.session_id.to_string(), + timestamp: payload.timestamp.clone(), })] } @@ -524,14 +528,22 @@ impl EventProcessor for EventProcessorWithJsonOutput { fn process_event(&mut self, event: Event) -> CodexStatus { let aggregated = self.collect_thread_events(&event); for conv_event in aggregated { - match serde_json::to_string(&conv_event) { - Ok(line) => { - println!("{line}"); + match serde_json::to_value(&conv_event) { + Ok(value) => { + let value = self.with_timestamp(value); + match serde_json::to_string(&value) { + Ok(line) => { + println!("{line}"); + } + Err(e) => { + error!("Failed to serialize event: {e:?}"); + } + } } Err(e) => { error!("Failed to serialize event: {e:?}"); } - } + }; } let Event { msg, .. } = event; @@ -546,3 +558,18 @@ impl EventProcessor for EventProcessorWithJsonOutput { } } } + +impl EventProcessorWithJsonOutput { + fn with_timestamp(&self, mut value: JsonValue) -> JsonValue { + if let Some(timestamp) = self.session_timestamp.as_ref() + && let Some(obj) = value.as_object_mut() + && !obj.contains_key("timestamp") + { + obj.insert( + "timestamp".to_string(), + JsonValue::String(timestamp.clone()), + ); + } + value + } +} diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index f3726dad76d..293e6c850db 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -39,6 +39,9 @@ pub enum ThreadEvent { pub struct ThreadStartedEvent { /// The identified of the new thread. Can be used to resume the thread later. pub thread_id: String, + /// RFC3339 timestamp (UTC) when the session was created. + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Default)] 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 d288f568e8b..7bdc0635ec9 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -72,10 +72,12 @@ fn session_configured_produces_thread_started_event() { codex_protocol::ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8") .unwrap(); let rollout_path = PathBuf::from("/tmp/rollout.json"); + let timestamp = Some("2025-09-05T16:53:11.850Z".to_string()); let ev = event( "e1", EventMsg::SessionConfigured(SessionConfiguredEvent { session_id, + timestamp: timestamp.clone(), model: "codex-mini-latest".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -93,6 +95,7 @@ fn session_configured_produces_thread_started_event() { out, vec![ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + timestamp, })] ); } diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index 83ac25fdfd4..d3471631e3b 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -257,6 +257,7 @@ mod tests { id: "1".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + timestamp: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -296,6 +297,7 @@ mod tests { let rollout_file = NamedTempFile::new()?; let session_configured_event = SessionConfiguredEvent { session_id: conversation_id, + timestamp: None, model: "gpt-4o".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cff2a8ad98d..87c62fba8ba 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1774,6 +1774,9 @@ pub struct SkillsListEntry { pub struct SessionConfiguredEvent { /// Name left as session_id instead of conversation_id for backwards compatibility. pub session_id: ConversationId, + /// RFC3339 timestamp (UTC) when the session was created. + #[serde(skip_serializing_if = "Option::is_none")] + pub timestamp: Option, /// Tell the client what model is being queried. pub model: String, @@ -1938,6 +1941,7 @@ mod tests { id: "1234".to_string(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, + timestamp: None, model: "codex-mini-latest".to_string(), model_provider_id: "openai".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c91eafcbe76..bade32c53fa 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1555,6 +1555,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ConversationId::new(), + timestamp: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -1610,6 +1611,7 @@ mod tests { let conversation_id = ConversationId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, + timestamp: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d8d50e5db0e..370d2f3f77e 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -105,6 +105,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + timestamp: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index d567ce96177..1efc2331f8e 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -2309,6 +2309,7 @@ mod tests { let make_header = |is_first| { let event = SessionConfiguredEvent { session_id: ConversationId::new(), + timestamp: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, @@ -2604,6 +2605,7 @@ mod tests { let conversation_id = ConversationId::new(); let event = SessionConfiguredEvent { session_id: conversation_id, + timestamp: None, model: "gpt-test".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 577c0db2bf4..f0cb9789a4c 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -103,6 +103,7 @@ async fn resumed_initial_messages_render_history() { let rollout_file = NamedTempFile::new().unwrap(); let configured = codex_core::protocol::SessionConfiguredEvent { session_id: conversation_id, + timestamp: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), approval_policy: AskForApproval::Never, diff --git a/docs/exec.md b/docs/exec.md index 5a17155a829..a114a4d0386 100644 --- a/docs/exec.md +++ b/docs/exec.md @@ -20,9 +20,9 @@ To write the output of `codex exec` to a file, in addition to using a shell redi `codex exec` supports a `--json` mode that streams events to stdout as JSON Lines (JSONL) while the agent runs. -Supported event types: +Supported event types (all events include a `timestamp` field with the session creation time, RFC3339 UTC): -- `thread.started` - when a thread is started or resumed. +- `thread.started` - when a thread is started or resumed. Includes `thread_id`. - `turn.started` - when a turn starts. A turn encompasses all events between the user message and the assistant response. - `turn.completed` - when a turn completes; includes token usage. - `turn.failed` - when a turn fails; includes error details. @@ -44,14 +44,14 @@ Typically, an `agent_message` is added at the end of the turn. Sample output: ```jsonl -{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"} -{"type":"turn.started"} -{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Searching for README files**"}} -{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"","status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"2025-09-11\nAGENTS.md\nCHANGELOG.md\ncliff.toml\ncodex-cli\ncodex-rs\ndocs\nexamples\nflake.lock\nflake.nix\nLICENSE\nnode_modules\nNOTICE\npackage.json\npnpm-lock.yaml\npnpm-workspace.yaml\nPNPM.md\nREADME.md\nscripts\nsdk\ntmp\n","exit_code":0,"status":"completed"}} -{"type":"item.completed","item":{"id":"item_2","type":"reasoning","text":"**Checking repository root for README**"}} -{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Yep — there’s a `README.md` in the repository root."}} -{"type":"turn.completed","usage":{"input_tokens":24763,"cached_input_tokens":24448,"output_tokens":122}} +{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53","timestamp":"2025-09-11T15:20:05Z"} +{"type":"turn.started","timestamp":"2025-09-11T15:20:05Z"} +{"type":"item.completed","timestamp":"2025-09-11T15:20:05Z","item":{"id":"item_0","type":"reasoning","text":"**Searching for README files**"}} +{"type":"item.started","timestamp":"2025-09-11T15:20:05Z","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"","status":"in_progress"}} +{"type":"item.completed","timestamp":"2025-09-11T15:20:05Z","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","aggregated_output":"2025-09-11\nAGENTS.md\nCHANGELOG.md\ncliff.toml\ncodex-cli\ncodex-rs\ndocs\nexamples\nflake.lock\nflake.nix\nLICENSE\nnode_modules\nNOTICE\npackage.json\npnpm-lock.yaml\npnpm-workspace.yaml\nPNPM.md\nREADME.md\nscripts\nsdk\ntmp\n","exit_code":0,"status":"completed"}} +{"type":"item.completed","timestamp":"2025-09-11T15:20:05Z","item":{"id":"item_2","type":"reasoning","text":"**Checking repository root for README**"}} +{"type":"item.completed","timestamp":"2025-09-11T15:20:05Z","item":{"id":"item_3","type":"agent_message","text":"Yep — there’s a `README.md` in the repository root."}} +{"type":"turn.completed","timestamp":"2025-09-11T15:20:05Z","usage":{"input_tokens":24763,"cached_input_tokens":24448,"output_tokens":122}} ``` ### Structured output