Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions codex-rs/core/src/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,19 @@ fn maybe_push_chat_wire_api_deprecation(
});
}

fn session_timestamp_from_history(initial_history: &InitialHistory) -> Option<String> {
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(
Expand Down Expand Up @@ -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::<Event>::new();

Expand Down Expand Up @@ -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(),
Expand Down
14 changes: 13 additions & 1 deletion codex-rs/core/src/rollout/recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ use codex_protocol::protocol::SessionSource;
pub struct RolloutRecorder {
tx: Sender<RolloutCmd>,
pub(crate) rollout_path: PathBuf,
session_timestamp: Option<String>,
}

#[derive(Clone)]
Expand Down Expand Up @@ -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();
Expand All @@ -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<String> {
self.session_timestamp.clone()
}

pub(crate) async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> {
Expand Down
37 changes: 32 additions & 5 deletions codex-rs/exec/src/event_processor_with_jsonl_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ pub struct EventProcessorWithJsonOutput {
last_total_token_usage: Option<codex_core::protocol::TokenUsage>,
running_mcp_tool_calls: HashMap<String, RunningMcpToolCall>,
last_critical_error: Option<ThreadErrorEvent>,
session_timestamp: Option<String>,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -101,6 +102,7 @@ impl EventProcessorWithJsonOutput {
last_total_token_usage: None,
running_mcp_tool_calls: HashMap::new(),
last_critical_error: None,
session_timestamp: None,
}
}

Expand Down Expand Up @@ -167,9 +169,11 @@ impl EventProcessorWithJsonOutput {
)
}

fn handle_session_configured(&self, payload: &SessionConfiguredEvent) -> Vec<ThreadEvent> {
fn handle_session_configured(&mut self, payload: &SessionConfiguredEvent) -> Vec<ThreadEvent> {
self.session_timestamp.clone_from(&payload.timestamp);
vec![ThreadEvent::ThreadStarted(ThreadStartedEvent {
thread_id: payload.session_id.to_string(),
timestamp: payload.timestamp.clone(),
})]
}

Expand Down Expand Up @@ -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;
Expand All @@ -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
}
}
3 changes: 3 additions & 0 deletions codex-rs/exec/src/exec_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS, Default)]
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/exec/tests/event_processor_with_json_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
})]
);
}
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/mcp-server/src/outgoing_message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/protocol/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

/// Tell the client what model is being queried.
pub model: String,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions codex-rs/tui2/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui2/src/chatwidget/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 10 additions & 10 deletions docs/exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down