Expose lifecycle timestamps on app-server thread items (#57172)

## What changed

- Add nullable `startedAtMs` and `completedAtMs` fields to `ThreadItem` in live notifications and turn history. Values are Unix milliseconds when recorded.
- Preserve recorded item times through history reconstruction, pagination, and resume, including command start times recorded during approval.
- Separate the Rust item payload into `ThreadItemPayload` while keeping the JSON payload flat, and update consumers, schemas, and generated TypeScript and Python types.

## Testing

Extend coverage for timestamp serialization, history reconstruction, paginated reads and resume, and persisted Python SDK items.

GitOrigin-RevId: ecbcee95af52db7c807b46a6e9deb30e5fba1f80
This commit is contained in:
Charlie Marsh
2026-10-26 18:13:00 +00:00
committed by copyberry
parent cf29971d93
commit a797dd7c6f
206 changed files with 4691 additions and 3089 deletions
@@ -17,7 +17,7 @@ use crate::protocol::v2::ReasoningSummaryPartAddedNotification;
use crate::protocol::v2::ReasoningSummaryTextDeltaNotification;
use crate::protocol::v2::ReasoningTextDeltaNotification;
use crate::protocol::v2::TerminalInteractionNotification;
use crate::protocol::v2::ThreadItem;
use crate::protocol::v2::ThreadItemPayload;
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem;
use codex_protocol::protocol::EventMsg;
use std::collections::HashMap;
@@ -42,7 +42,7 @@ pub fn item_event_to_server_notification(
DynamicToolCallStatus::Failed
};
let duration_ms = i64::try_from(response.duration.as_millis()).ok();
let item = ThreadItem::DynamicToolCall {
let item = ThreadItemPayload::DynamicToolCall {
id: response.call_id,
namespace: response.namespace,
tool: response.tool,
@@ -68,15 +68,16 @@ pub fn item_event_to_server_notification(
success: Some(response.success),
duration_ms,
};
ServerNotification::ItemCompleted(ItemCompletedNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
thread_id,
turn_id: response.turn_id,
response.turn_id,
item,
completed_at_ms: response.completed_at_ms,
})
/*started_at_ms*/ None,
response.completed_at_ms,
))
}
EventMsg::CollabAgentSpawnBegin(begin_event) => {
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: begin_event.call_id,
tool: CollabAgentTool::SpawnAgent,
status: CollabAgentToolCallStatus::InProgress,
@@ -87,12 +88,12 @@ pub fn item_event_to_server_notification(
reasoning_effort: Some(begin_event.reasoning_effort),
agents_states: HashMap::new(),
};
ServerNotification::ItemStarted(ItemStartedNotification {
ServerNotification::ItemStarted(ItemStartedNotification::new(
thread_id,
turn_id,
item,
started_at_ms: begin_event.started_at_ms,
})
begin_event.started_at_ms,
))
}
EventMsg::CollabAgentSpawnEnd(end_event) => {
let has_receiver = end_event.new_thread_id.is_some();
@@ -115,7 +116,7 @@ pub fn item_event_to_server_notification(
}
None => (Vec::new(), HashMap::new()),
};
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: end_event.call_id,
tool: CollabAgentTool::SpawnAgent,
status,
@@ -126,16 +127,17 @@ pub fn item_event_to_server_notification(
reasoning_effort: Some(end_event.reasoning_effort),
agents_states,
};
ServerNotification::ItemCompleted(ItemCompletedNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
thread_id,
turn_id,
item,
completed_at_ms: end_event.completed_at_ms,
})
/*started_at_ms*/ None,
end_event.completed_at_ms,
))
}
EventMsg::CollabAgentInteractionBegin(begin_event) => {
let receiver_thread_ids = vec![begin_event.receiver_thread_id.to_string()];
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: begin_event.call_id,
tool: CollabAgentTool::SendInput,
status: CollabAgentToolCallStatus::InProgress,
@@ -146,12 +148,12 @@ pub fn item_event_to_server_notification(
reasoning_effort: None,
agents_states: HashMap::new(),
};
ServerNotification::ItemStarted(ItemStartedNotification {
ServerNotification::ItemStarted(ItemStartedNotification::new(
thread_id,
turn_id,
item,
started_at_ms: begin_event.started_at_ms,
})
begin_event.started_at_ms,
))
}
EventMsg::CollabAgentInteractionEnd(end_event) => {
let status = match &end_event.status {
@@ -163,7 +165,7 @@ pub fn item_event_to_server_notification(
};
let receiver_id = end_event.receiver_thread_id.to_string();
let received_status = CollabAgentState::from(end_event.status);
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: end_event.call_id,
tool: CollabAgentTool::SendInput,
status,
@@ -174,15 +176,16 @@ pub fn item_event_to_server_notification(
reasoning_effort: None,
agents_states: [(receiver_id, received_status)].into_iter().collect(),
};
ServerNotification::ItemCompleted(ItemCompletedNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
thread_id,
turn_id,
item,
completed_at_ms: end_event.completed_at_ms,
})
/*started_at_ms*/ None,
end_event.completed_at_ms,
))
}
EventMsg::SubAgentActivity(activity) => {
let item = ThreadItem::SubAgentActivity {
let item = ThreadItemPayload::SubAgentActivity {
model: activity.model,
reasoning_effort: activity.reasoning_effort,
id: activity.event_id,
@@ -190,12 +193,13 @@ pub fn item_event_to_server_notification(
agent_thread_id: activity.agent_thread_id.to_string(),
agent_path: String::from(activity.agent_path),
};
ServerNotification::ItemCompleted(ItemCompletedNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
thread_id,
turn_id,
item,
completed_at_ms: activity.occurred_at_ms,
})
/*started_at_ms*/ None,
activity.occurred_at_ms,
))
}
EventMsg::CollabWaitingBegin(begin_event) => {
let receiver_thread_ids = begin_event
@@ -203,7 +207,7 @@ pub fn item_event_to_server_notification(
.iter()
.map(ToString::to_string)
.collect();
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: begin_event.call_id,
tool: CollabAgentTool::Wait,
status: CollabAgentToolCallStatus::InProgress,
@@ -214,12 +218,12 @@ pub fn item_event_to_server_notification(
reasoning_effort: None,
agents_states: HashMap::new(),
};
ServerNotification::ItemStarted(ItemStartedNotification {
ServerNotification::ItemStarted(ItemStartedNotification::new(
thread_id,
turn_id,
item,
started_at_ms: begin_event.started_at_ms,
})
begin_event.started_at_ms,
))
}
EventMsg::CollabWaitingEnd(end_event) => {
let status = if end_event.statuses.values().any(|status| {
@@ -239,7 +243,7 @@ pub fn item_event_to_server_notification(
.iter()
.map(|(id, status)| (id.to_string(), CollabAgentState::from(status.clone())))
.collect();
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: end_event.call_id,
tool: CollabAgentTool::Wait,
status,
@@ -250,15 +254,16 @@ pub fn item_event_to_server_notification(
reasoning_effort: None,
agents_states,
};
ServerNotification::ItemCompleted(ItemCompletedNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
thread_id,
turn_id,
item,
completed_at_ms: end_event.completed_at_ms,
})
/*started_at_ms*/ None,
end_event.completed_at_ms,
))
}
EventMsg::CollabCloseBegin(begin_event) => {
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: begin_event.call_id,
tool: CollabAgentTool::CloseAgent,
status: CollabAgentToolCallStatus::InProgress,
@@ -269,12 +274,12 @@ pub fn item_event_to_server_notification(
reasoning_effort: None,
agents_states: HashMap::new(),
};
ServerNotification::ItemStarted(ItemStartedNotification {
ServerNotification::ItemStarted(ItemStartedNotification::new(
thread_id,
turn_id,
item,
started_at_ms: begin_event.started_at_ms,
})
begin_event.started_at_ms,
))
}
EventMsg::CollabCloseEnd(end_event) => {
let status = match &end_event.status {
@@ -291,7 +296,7 @@ pub fn item_event_to_server_notification(
)]
.into_iter()
.collect();
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: end_event.call_id,
tool: CollabAgentTool::CloseAgent,
status,
@@ -302,15 +307,16 @@ pub fn item_event_to_server_notification(
reasoning_effort: None,
agents_states,
};
ServerNotification::ItemCompleted(ItemCompletedNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
thread_id,
turn_id,
item,
completed_at_ms: end_event.completed_at_ms,
})
/*started_at_ms*/ None,
end_event.completed_at_ms,
))
}
EventMsg::CollabResumeBegin(begin_event) => {
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: begin_event.call_id,
tool: CollabAgentTool::ResumeAgent,
status: CollabAgentToolCallStatus::InProgress,
@@ -321,12 +327,12 @@ pub fn item_event_to_server_notification(
reasoning_effort: None,
agents_states: HashMap::new(),
};
ServerNotification::ItemStarted(ItemStartedNotification {
ServerNotification::ItemStarted(ItemStartedNotification::new(
thread_id,
turn_id,
item,
started_at_ms: begin_event.started_at_ms,
})
begin_event.started_at_ms,
))
}
EventMsg::CollabResumeEnd(end_event) => {
let status = match &end_event.status {
@@ -343,7 +349,7 @@ pub fn item_event_to_server_notification(
)]
.into_iter()
.collect();
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: end_event.call_id,
tool: CollabAgentTool::ResumeAgent,
status,
@@ -354,12 +360,13 @@ pub fn item_event_to_server_notification(
reasoning_effort: None,
agents_states,
};
ServerNotification::ItemCompleted(ItemCompletedNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
thread_id,
turn_id,
item,
completed_at_ms: end_event.completed_at_ms,
})
/*started_at_ms*/ None,
end_event.completed_at_ms,
))
}
EventMsg::AgentMessageContentDelta(event) => {
let codex_protocol::protocol::AgentMessageContentDeltaEvent { item_id, delta, .. } =
@@ -404,20 +411,21 @@ pub fn item_event_to_server_notification(
})
}
EventMsg::ItemStarted(item_started_event) => {
ServerNotification::ItemStarted(ItemStartedNotification {
ServerNotification::ItemStarted(ItemStartedNotification::new(
thread_id,
turn_id,
item: item_started_event.item.into(),
started_at_ms: item_started_event.started_at_ms,
})
ThreadItemPayload::from(item_started_event.item),
item_started_event.started_at_ms,
))
}
EventMsg::ItemCompleted(item_completed_event) => {
ServerNotification::ItemCompleted(ItemCompletedNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
thread_id,
turn_id,
item: item_completed_event.item.into(),
completed_at_ms: item_completed_event.completed_at_ms,
})
ThreadItemPayload::from(item_completed_event.item),
item_completed_event.started_at_ms,
item_completed_event.completed_at_ms,
))
}
EventMsg::PatchApplyUpdated(event) => {
ServerNotification::FileChangePatchUpdated(FileChangePatchUpdatedNotification {
@@ -428,12 +436,12 @@ pub fn item_event_to_server_notification(
})
}
EventMsg::ExecCommandBegin(exec_command_begin_event) => {
ServerNotification::ItemStarted(ItemStartedNotification {
ServerNotification::ItemStarted(ItemStartedNotification::new(
thread_id,
turn_id,
item: build_command_execution_begin_item(&exec_command_begin_event),
started_at_ms: exec_command_begin_event.started_at_ms,
})
build_command_execution_begin_item(&exec_command_begin_event),
exec_command_begin_event.started_at_ms,
))
}
EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => {
let item_id = exec_command_output_delta_event.call_id;
@@ -457,12 +465,13 @@ pub fn item_event_to_server_notification(
})
}
EventMsg::ExecCommandEnd(exec_command_end_event) => {
ServerNotification::ItemCompleted(ItemCompletedNotification {
ServerNotification::ItemCompleted(ItemCompletedNotification::new(
thread_id,
turn_id,
item: build_command_execution_end_item(&exec_command_end_event),
completed_at_ms: exec_command_end_event.completed_at_ms,
})
build_command_execution_end_item(&exec_command_end_event),
/*started_at_ms*/ None,
exec_command_end_event.completed_at_ms,
))
}
_ => unreachable!("unsupported item event"),
}
@@ -528,11 +537,10 @@ mod tests {
);
assert_item_started_server_notification(
notification,
ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: event.started_at_ms,
item: ThreadItem::CollabAgentToolCall {
ItemStartedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::CollabAgentToolCall {
id: event.call_id,
tool: CollabAgentTool::ResumeAgent,
status: CollabAgentToolCallStatus::InProgress,
@@ -543,7 +551,8 @@ mod tests {
reasoning_effort: None,
agents_states: HashMap::new(),
},
},
event.started_at_ms,
),
);
}
@@ -567,11 +576,10 @@ mod tests {
);
assert_item_completed_server_notification(
notification,
ItemCompletedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
completed_at_ms: event.completed_at_ms,
item: ThreadItem::CollabAgentToolCall {
ItemCompletedNotification::new(
"thread-2".to_string(),
"turn-2".to_string(),
ThreadItemPayload::CollabAgentToolCall {
id: event.call_id,
tool: CollabAgentTool::ResumeAgent,
status: CollabAgentToolCallStatus::Failed,
@@ -587,7 +595,9 @@ mod tests {
.into_iter()
.collect(),
},
},
/*started_at_ms*/ None,
event.completed_at_ms,
),
);
}
@@ -21,7 +21,7 @@ use crate::protocol::v2::ItemGuardianApprovalReviewCompletedNotification;
use crate::protocol::v2::ItemGuardianApprovalReviewStartedNotification;
use crate::protocol::v2::PatchApplyStatus;
use crate::protocol::v2::PatchChangeKind;
use crate::protocol::v2::ThreadItem;
use crate::protocol::v2::ThreadItemPayload;
use codex_protocol::ThreadId;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
@@ -69,34 +69,34 @@ pub(crate) fn review_output_text(output: Option<&ReviewOutputEvent>) -> String {
pub fn build_file_change_approval_request_item(
payload: &ApplyPatchApprovalRequestEvent,
) -> ThreadItem {
ThreadItem::FileChange {
) -> ThreadItemPayload {
ThreadItemPayload::FileChange {
id: payload.call_id.clone(),
changes: convert_patch_changes(&payload.changes),
status: PatchApplyStatus::InProgress,
}
}
pub fn build_file_change_begin_item(payload: &PatchApplyBeginEvent) -> ThreadItem {
ThreadItem::FileChange {
pub fn build_file_change_begin_item(payload: &PatchApplyBeginEvent) -> ThreadItemPayload {
ThreadItemPayload::FileChange {
id: payload.call_id.clone(),
changes: convert_patch_changes(&payload.changes),
status: PatchApplyStatus::InProgress,
}
}
pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItem {
ThreadItem::FileChange {
pub fn build_file_change_end_item(payload: &PatchApplyEndEvent) -> ThreadItemPayload {
ThreadItemPayload::FileChange {
id: payload.call_id.clone(),
changes: convert_patch_changes(&payload.changes),
status: (&payload.status).into(),
}
}
pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItem {
pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> ThreadItemPayload {
let presentation =
CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd);
ThreadItem::CommandExecution {
ThreadItemPayload::CommandExecution {
model_context: None,
sandbox_type: None,
id: payload.call_id.clone(),
@@ -114,7 +114,7 @@ pub fn build_command_execution_begin_item(payload: &ExecCommandBeginEvent) -> Th
}
}
pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> ThreadItem {
pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> ThreadItemPayload {
let aggregated_output = if payload.aggregated_output.is_empty() {
None
} else {
@@ -124,7 +124,7 @@ pub fn build_command_execution_end_item(payload: &ExecCommandEndEvent) -> Thread
let presentation =
CommandExecutionPresentation::from_raw(&payload.command, &payload.parsed_cmd, &payload.cwd);
ThreadItem::CommandExecution {
ThreadItemPayload::CommandExecution {
model_context: None,
sandbox_type: None,
id: payload.call_id.clone(),
@@ -193,7 +193,7 @@ fn command_actions_for_path_uri(parsed_cmd: &[ParsedCommand], cwd: &PathUri) ->
pub fn build_item_from_guardian_event(
assessment: &GuardianAssessmentEvent,
status: CommandExecutionStatus,
) -> Option<ThreadItem> {
) -> Option<ThreadItemPayload> {
match &assessment.action {
GuardianAssessmentAction::Command { command, cwd, .. } => {
let id = assessment.target_item_id.as_ref()?;
@@ -201,7 +201,7 @@ pub fn build_item_from_guardian_event(
let command_actions = vec![CommandAction::Unknown {
command: command.clone(),
}];
Some(ThreadItem::CommandExecution {
Some(ThreadItemPayload::CommandExecution {
id: id.clone(),
model_context: assessment.model_context.clone(),
sandbox_type: None,
@@ -241,7 +241,7 @@ pub fn build_item_from_guardian_event(
.map(|parsed| CommandAction::from_core_with_cwd(parsed, cwd))
.collect()
};
Some(ThreadItem::CommandExecution {
Some(ThreadItemPayload::CommandExecution {
id: id.clone(),
model_context: assessment.model_context.clone(),
sandbox_type: None,
@@ -1,5 +1,6 @@
use super::*;
use crate::protocol::thread_history::build_turns_from_rollout_items;
use crate::protocol::v2::ThreadItem;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ExecCommandSource;
use codex_protocol::protocol::GuardianAssessmentStatus;
@@ -126,7 +127,11 @@ fn guardian_stdin_reviews_preserve_parent_command_history() {
assert_eq!(expected.len(), 2);
assert_eq!(
expected[0].items,
vec![build_command_execution_begin_item(&begin)],
[ThreadItem {
payload: build_command_execution_begin_item(&begin),
started_at_ms: Some(begin.started_at_ms),
completed_at_ms: None,
}],
);
assert!(expected[1].items.is_empty());
File diff suppressed because it is too large Load Diff
@@ -78,9 +78,7 @@ pub fn project_rollout_line(line: &RolloutLine) -> ThreadHistoryChangeSet {
RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) => ThreadHistoryChangeSet {
changed_items: vec![ThreadHistoryItemChange {
turn_id: event.turn_id.clone(),
item: ThreadItem::from(event.item.clone()),
started_at_ms: event.started_at_ms,
completed_at_ms: (event.completed_at_ms != 0).then_some(event.completed_at_ms),
item: ThreadItem::from(event),
}],
..Default::default()
},
@@ -19,7 +19,7 @@ use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
use super::*;
use crate::protocol::v2::ThreadItem;
use crate::protocol::v2::ThreadItemPayload;
use crate::protocol::v2::TurnError;
#[test]
@@ -145,18 +145,22 @@ fn projects_completed_canonical_turn_items() {
user_changes.changed_items,
vec![ThreadHistoryItemChange {
turn_id: "turn-1".to_string(),
item: ThreadItem::from(user_item),
started_at_ms: Some(100),
completed_at_ms: Some(123),
item: ThreadItem {
payload: ThreadItemPayload::from(user_item),
started_at_ms: Some(100),
completed_at_ms: Some(123)
}
}]
);
assert_eq!(
agent_changes.changed_items,
vec![ThreadHistoryItemChange {
turn_id: "turn-1".to_string(),
item: ThreadItem::from(agent_item),
started_at_ms: Some(100),
completed_at_ms: Some(123),
item: ThreadItem {
payload: ThreadItemPayload::from(agent_item),
started_at_ms: Some(100),
completed_at_ms: Some(123)
}
}]
);
}
@@ -187,9 +191,11 @@ fn projects_optional_completed_item_lifecycle_timestamps() {
ThreadHistoryChangeSet {
changed_items: vec![ThreadHistoryItemChange {
turn_id: "turn-1".to_string(),
item: ThreadItem::from(item.clone()),
started_at_ms,
completed_at_ms: expected_completed_at_ms,
item: ThreadItem {
payload: ThreadItemPayload::from(item.clone()),
started_at_ms,
completed_at_ms: expected_completed_at_ms
}
}],
..Default::default()
}
@@ -1,25 +1,25 @@
//! Projects a turn's items without copying payloads excluded by the requested view.
use super::v2::ThreadItem;
use super::v2::ThreadItemPayload;
use super::v2::TurnItemsView;
use std::borrow::Borrow;
impl TurnItemsView {
/// Copies only the items included in this view, preserving summary selection order.
pub fn project_items(self, items: &[ThreadItem]) -> Vec<ThreadItem> {
pub fn project_items<T: Borrow<ThreadItemPayload> + Clone>(self, items: &[T]) -> Vec<T> {
match self {
Self::NotLoaded => Vec::new(),
Self::Full => items.to_vec(),
Self::Summary => {
let first_user_message = items
.iter()
.find(|item| matches!(item, ThreadItem::UserMessage { .. }));
let final_agent_message = items
.iter()
.rev()
.find(|item| matches!(item, ThreadItem::AgentMessage { .. }));
.find(|item| matches!((*item).borrow(), ThreadItemPayload::UserMessage { .. }));
let final_agent_message = items.iter().rev().find(|item| {
matches!((*item).borrow(), ThreadItemPayload::AgentMessage { .. })
});
match (first_user_message, final_agent_message) {
(Some(user_message), Some(agent_message))
if user_message.id() != agent_message.id() =>
if user_message.borrow().id() != agent_message.borrow().id() =>
{
vec![user_message.clone(), agent_message.clone()]
}
@@ -231,11 +231,72 @@ impl CommandAction {
}
}
/// A thread item and its recorded lifecycle times. The payload stays flat on the wire.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadItem {
#[serde(flatten)]
pub payload: ThreadItemPayload,
/// Unix timestamp in milliseconds when the item started, if recorded.
#[serde(default)]
#[ts(type = "number | null")]
pub started_at_ms: Option<i64>,
/// Unix timestamp in milliseconds when the item completed, if recorded.
#[serde(default)]
#[ts(type = "number | null")]
pub completed_at_ms: Option<i64>,
}
impl ThreadItem {
/// Creates an item with no recorded start or completion time.
pub fn untimed(payload: ThreadItemPayload) -> Self {
Self {
payload,
started_at_ms: None,
completed_at_ms: None,
}
}
fn completed(
payload: ThreadItemPayload,
started_at_ms: Option<i64>,
completed_at_ms: i64,
) -> Self {
Self {
payload,
started_at_ms,
// Older rollout events default a missing completion time to zero.
completed_at_ms: (completed_at_ms != 0).then_some(completed_at_ms),
}
}
pub fn id(&self) -> &str {
self.payload.id()
}
}
impl From<&codex_protocol::protocol::ItemCompletedEvent> for ThreadItem {
fn from(event: &codex_protocol::protocol::ItemCompletedEvent) -> Self {
ThreadItem::completed(
ThreadItemPayload::from(event.item.clone()),
event.started_at_ms,
event.completed_at_ms,
)
}
}
impl std::borrow::Borrow<ThreadItemPayload> for ThreadItem {
fn borrow(&self) -> &ThreadItemPayload {
&self.payload
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type")]
#[ts(export_to = "v2/")]
pub enum ThreadItem {
pub enum ThreadItemPayload {
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
UserMessage {
@@ -453,28 +514,28 @@ pub struct HookPromptFragment {
pub hook_run_id: String,
}
impl ThreadItem {
impl ThreadItemPayload {
pub fn id(&self) -> &str {
match self {
ThreadItem::UserMessage { id, .. }
| ThreadItem::HookPrompt { id, .. }
| ThreadItem::AgentMessage { id, .. }
| ThreadItem::FunctionCallOutput { id, .. }
| ThreadItem::Plan { id, .. }
| ThreadItem::Reasoning { id, .. }
| ThreadItem::CommandExecution { id, .. }
| ThreadItem::FileChange { id, .. }
| ThreadItem::McpToolCall { id, .. }
| ThreadItem::DynamicToolCall { id, .. }
| ThreadItem::CollabAgentToolCall { id, .. }
| ThreadItem::SubAgentActivity { id, .. }
| ThreadItem::ImageView { id, .. }
| ThreadItem::EnteredReviewMode { id, .. }
| ThreadItem::ExitedReviewMode { id, .. }
| ThreadItem::ContextCompaction { id, .. } => id,
ThreadItem::WebSearch(item) => &item.id,
ThreadItem::Sleep(item) => &item.id,
ThreadItem::ImageGeneration(item) => &item.id,
Self::UserMessage { id, .. }
| Self::HookPrompt { id, .. }
| Self::AgentMessage { id, .. }
| Self::FunctionCallOutput { id, .. }
| Self::Plan { id, .. }
| Self::Reasoning { id, .. }
| Self::CommandExecution { id, .. }
| Self::FileChange { id, .. }
| Self::McpToolCall { id, .. }
| Self::DynamicToolCall { id, .. }
| Self::CollabAgentToolCall { id, .. }
| Self::SubAgentActivity { id, .. }
| Self::ImageView { id, .. }
| Self::EnteredReviewMode { id, .. }
| Self::ExitedReviewMode { id, .. }
| Self::ContextCompaction { id, .. } => id,
Self::WebSearch(item) => &item.id,
Self::Sleep(item) => &item.id,
Self::ImageGeneration(item) => &item.id,
}
}
}
@@ -872,15 +933,15 @@ pub(crate) fn web_search_action_from_core(
}
}
impl From<CoreTurnItem> for ThreadItem {
impl From<CoreTurnItem> for ThreadItemPayload {
fn from(value: CoreTurnItem) -> Self {
match value {
CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage {
CoreTurnItem::UserMessage(user) => Self::UserMessage {
id: user.id,
client_id: user.client_id,
content: user.content.into_iter().map(UserInput::from).collect(),
},
CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt {
CoreTurnItem::HookPrompt(hook_prompt) => Self::HookPrompt {
id: hook_prompt.id,
fragments: hook_prompt
.fragments
@@ -896,7 +957,7 @@ impl From<CoreTurnItem> for ThreadItem {
CoreAgentMessageContent::Text { text } => text,
})
.collect::<String>();
ThreadItem::AgentMessage {
Self::AgentMessage {
id: agent.id,
text,
phase: agent.phase,
@@ -905,17 +966,17 @@ impl From<CoreTurnItem> for ThreadItem {
questions: agent.questions,
}
}
CoreTurnItem::FunctionCallOutput(output) => ThreadItem::FunctionCallOutput {
CoreTurnItem::FunctionCallOutput(output) => Self::FunctionCallOutput {
id: output.id,
name: output.name,
namespace: output.namespace,
output: output.output,
},
CoreTurnItem::Plan(plan) => ThreadItem::Plan {
CoreTurnItem::Plan(plan) => Self::Plan {
id: plan.id,
text: plan.text,
},
CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning {
CoreTurnItem::Reasoning(reasoning) => Self::Reasoning {
id: reasoning.id,
summary: reasoning.summary_text,
content: reasoning.raw_content,
@@ -926,7 +987,7 @@ impl From<CoreTurnItem> for ThreadItem {
&command.parsed_cmd,
&command.cwd,
);
ThreadItem::CommandExecution {
Self::CommandExecution {
id: command.id,
model_context: command.model_context,
sandbox_type: command.sandbox_type,
@@ -947,7 +1008,7 @@ impl From<CoreTurnItem> for ThreadItem {
.and_then(|duration| i64::try_from(duration.as_millis()).ok()),
}
}
CoreTurnItem::DynamicToolCall(call) => ThreadItem::DynamicToolCall {
CoreTurnItem::DynamicToolCall(call) => Self::DynamicToolCall {
id: call.id,
namespace: call.namespace,
tool: call.tool,
@@ -964,7 +1025,7 @@ impl From<CoreTurnItem> for ThreadItem {
.duration
.and_then(|duration| i64::try_from(duration.as_millis()).ok()),
},
CoreTurnItem::CollabAgentToolCall(call) => ThreadItem::CollabAgentToolCall {
CoreTurnItem::CollabAgentToolCall(call) => Self::CollabAgentToolCall {
id: call.id,
tool: call.tool.into(),
status: call.status.into(),
@@ -983,7 +1044,7 @@ impl From<CoreTurnItem> for ThreadItem {
.map(|(thread_id, status)| (thread_id.to_string(), status.into()))
.collect(),
},
CoreTurnItem::SubAgentActivity(activity) => ThreadItem::SubAgentActivity {
CoreTurnItem::SubAgentActivity(activity) => Self::SubAgentActivity {
model: activity.model,
reasoning_effort: activity.reasoning_effort,
id: activity.id,
@@ -991,46 +1052,44 @@ impl From<CoreTurnItem> for ThreadItem {
agent_thread_id: activity.agent_thread_id.to_string(),
agent_path: String::from(activity.agent_path),
},
CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch(WebSearchItem {
CoreTurnItem::WebSearch(search) => Self::WebSearch(WebSearchItem {
id: search.id,
query: search.query,
action: Some(web_search_action_from_core(search.action)),
results: search.results,
}),
CoreTurnItem::ImageView(image) => ThreadItem::ImageView {
CoreTurnItem::ImageView(image) => Self::ImageView {
id: image.id,
path: image.path.into(),
},
CoreTurnItem::Extension(extension) => match extension {
ExtensionItem::ImageGeneration(item) => ThreadItem::ImageGeneration(item),
ExtensionItem::Sleep(item) => ThreadItem::Sleep(item),
ExtensionItem::WebSearch(item) => ThreadItem::WebSearch(item),
ExtensionItem::ImageGeneration(item) => Self::ImageGeneration(item),
ExtensionItem::Sleep(item) => Self::Sleep(item),
ExtensionItem::WebSearch(item) => Self::WebSearch(item),
},
CoreTurnItem::ImageGeneration(image) => {
ThreadItem::ImageGeneration(ImageGenerationItem {
id: image.id,
status: image.status,
revised_prompt: image.revised_prompt,
image: GeneratedImageRef::Inline {
result: image.result,
},
transparent_background: None,
failure: None,
saved_path: image.saved_path,
input_images: Vec::new(),
imagegen_request_id: None,
generation_id: None,
})
}
CoreTurnItem::EnteredReviewMode(review) => ThreadItem::EnteredReviewMode {
CoreTurnItem::ImageGeneration(image) => Self::ImageGeneration(ImageGenerationItem {
id: image.id,
status: image.status,
revised_prompt: image.revised_prompt,
image: GeneratedImageRef::Inline {
result: image.result,
},
transparent_background: None,
failure: None,
saved_path: image.saved_path,
input_images: Vec::new(),
imagegen_request_id: None,
generation_id: None,
}),
CoreTurnItem::EnteredReviewMode(review) => Self::EnteredReviewMode {
id: review.id,
review: review.user_facing_hint,
},
CoreTurnItem::ExitedReviewMode(review) => ThreadItem::ExitedReviewMode {
CoreTurnItem::ExitedReviewMode(review) => Self::ExitedReviewMode {
id: review.id,
review: review_output_text(review.review_output.as_ref()),
},
CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange {
CoreTurnItem::FileChange(file_change) => Self::FileChange {
id: file_change.id,
changes: convert_patch_changes(&file_change.changes),
status: file_change
@@ -1044,7 +1103,7 @@ impl From<CoreTurnItem> for ThreadItem {
.duration
.and_then(|duration| i64::try_from(duration.as_millis()).ok());
ThreadItem::McpToolCall {
Self::McpToolCall {
id: mcp.id,
server: mcp.server,
tool: mcp.tool,
@@ -1067,7 +1126,7 @@ impl From<CoreTurnItem> for ThreadItem {
}
}
CoreTurnItem::ContextCompaction(compaction) => {
ThreadItem::ContextCompaction { id: compaction.id }
Self::ContextCompaction { id: compaction.id }
}
}
}
@@ -1353,6 +1412,25 @@ pub struct ItemStartedNotification {
#[ts(type = "number")]
pub started_at_ms: i64,
}
impl ItemStartedNotification {
pub fn new(
thread_id: String,
turn_id: String,
payload: ThreadItemPayload,
started_at_ms: i64,
) -> Self {
Self {
thread_id,
turn_id,
started_at_ms,
item: ThreadItem {
payload,
started_at_ms: Some(started_at_ms),
completed_at_ms: None,
},
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -1431,6 +1509,22 @@ pub struct ItemCompletedNotification {
#[ts(type = "number")]
pub completed_at_ms: i64,
}
impl ItemCompletedNotification {
pub fn new(
thread_id: String,
turn_id: String,
payload: ThreadItemPayload,
started_at_ms: Option<i64>,
completed_at_ms: i64,
) -> Self {
Self {
thread_id,
turn_id,
completed_at_ms,
item: ThreadItem::completed(payload, started_at_ms, completed_at_ms),
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
@@ -461,8 +461,12 @@ fn thread_items_list_round_trips() {
let response = ThreadItemsListResponse {
data: vec![ThreadItemEntry {
turn_id: "turn_456".to_string(),
item: ThreadItem::ContextCompaction {
id: "item_1".to_string(),
item: ThreadItem {
payload: ThreadItemPayload::ContextCompaction {
id: "item_1".to_string(),
},
started_at_ms,
completed_at_ms,
},
started_at_ms,
completed_at_ms,
@@ -476,7 +480,7 @@ fn thread_items_list_round_trips() {
json!({
"data": [{
"turnId": "turn_456",
"item": {"type": "contextCompaction", "id": "item_1"},
"item": {"type": "contextCompaction", "id": "item_1", "startedAtMs": started_at_ms, "completedAtMs": completed_at_ms},
"startedAtMs": started_at_ms,
"completedAtMs": completed_at_ms,
}],
@@ -3164,8 +3168,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(user_item),
ThreadItem::UserMessage {
ThreadItemPayload::from(user_item),
ThreadItemPayload::UserMessage {
id: "user-1".to_string(),
client_id: Some("client-message-1".to_string()),
content: vec![
@@ -3218,8 +3222,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(agent_item),
ThreadItem::AgentMessage {
ThreadItemPayload::from(agent_item),
ThreadItemPayload::AgentMessage {
id: "agent-1".to_string(),
text: "Hello world".to_string(),
phase: None,
@@ -3249,8 +3253,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(agent_item_with_phase),
ThreadItem::AgentMessage {
ThreadItemPayload::from(agent_item_with_phase),
ThreadItemPayload::AgentMessage {
id: "agent-2".to_string(),
text: "final".to_string(),
phase: Some(MessagePhase::FinalAnswer),
@@ -3268,7 +3272,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
}
);
let async_item = ThreadItem::from(TurnItem::AgentMessage(AgentMessageItem {
let async_item = ThreadItemPayload::from(TurnItem::AgentMessage(AgentMessageItem {
id: "async-1".to_string(),
content: vec![AgentMessageContent::Text {
text: "Which?".to_string(),
@@ -3288,13 +3292,13 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
"memoryCitation": null, "delivery": "async", "questions": [{"title": "Which?", "options": null}]
})
);
let old_item: ThreadItem = serde_json::from_value(json!({
let old_item: ThreadItemPayload = serde_json::from_value(json!({
"type": "agentMessage", "id": "old-1", "text": "An old message"
}))
.unwrap();
assert!(matches!(
old_item,
ThreadItem::AgentMessage {
ThreadItemPayload::AgentMessage {
questions: None,
..
}
@@ -3307,8 +3311,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(reasoning_item),
ThreadItem::Reasoning {
ThreadItemPayload::from(reasoning_item),
ThreadItemPayload::Reasoning {
id: "reasoning-1".to_string(),
summary: vec!["line one".to_string(), "line two".to_string()],
content: vec![],
@@ -3345,8 +3349,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(command_item),
ThreadItem::CommandExecution {
ThreadItemPayload::from(command_item),
ThreadItemPayload::CommandExecution {
model_context: None,
sandbox_type: None,
id: "exec-1".to_string(),
@@ -3391,8 +3395,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(dynamic_tool_call_item),
ThreadItem::DynamicToolCall {
ThreadItemPayload::from(dynamic_tool_call_item),
ThreadItemPayload::DynamicToolCall {
id: "dynamic-1".to_string(),
namespace: Some("apps".to_string()),
tool: "lookup".to_string(),
@@ -3432,8 +3436,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(collab_item),
ThreadItem::CollabAgentToolCall {
ThreadItemPayload::from(collab_item),
ThreadItemPayload::CollabAgentToolCall {
id: "collab-1".to_string(),
tool: CollabAgentTool::SendInput,
status: CollabAgentToolCallStatus::Completed,
@@ -3466,8 +3470,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(sub_agent_activity_item),
ThreadItem::SubAgentActivity {
ThreadItemPayload::from(sub_agent_activity_item),
ThreadItemPayload::SubAgentActivity {
model: Some("gpt-5".into()),
reasoning_effort: Some(ReasoningEffort::High),
id: "activity-1".to_string(),
@@ -3484,7 +3488,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
"agent_path": "/root/worker",
}))
.unwrap();
let old_wire_activity: ThreadItem = serde_json::from_value(json!({
let old_wire_activity: ThreadItemPayload = serde_json::from_value(json!({
"type": "subAgentActivity",
"id": "old-activity",
"kind": "started",
@@ -3493,7 +3497,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
}))
.unwrap();
assert_eq!(
ThreadItem::from(TurnItem::SubAgentActivity(old_activity)),
ThreadItemPayload::from(TurnItem::SubAgentActivity(old_activity)),
old_wire_activity
);
@@ -3526,14 +3530,14 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
};
assert_eq!(
ThreadItem::from(search_item),
ThreadItem::WebSearch(expected_search_item.clone())
ThreadItemPayload::from(search_item),
ThreadItemPayload::WebSearch(expected_search_item.clone())
);
assert_eq!(
ThreadItem::from(TurnItem::Extension(
ThreadItemPayload::from(TurnItem::Extension(
codex_extension_items::ExtensionItem::WebSearch(expected_search_item.clone()),
)),
ThreadItem::WebSearch(expected_search_item)
ThreadItemPayload::WebSearch(expected_search_item)
);
let image_view_item = TurnItem::ImageView(ImageViewItem {
@@ -3542,8 +3546,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(image_view_item),
ThreadItem::ImageView {
ThreadItemPayload::from(image_view_item),
ThreadItemPayload::ImageView {
id: "view-image-1".to_string(),
path: LegacyAppPathString::from_abs_path(&test_path_buf("/tmp/view-image.png").abs()),
}
@@ -3566,8 +3570,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(file_change_item),
ThreadItem::FileChange {
ThreadItemPayload::from(file_change_item),
ThreadItemPayload::FileChange {
id: "patch-1".to_string(),
changes: vec![FileUpdateChange {
path: "README.md".to_string(),
@@ -3598,8 +3602,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(mcp_tool_call_item),
ThreadItem::McpToolCall {
ThreadItemPayload::from(mcp_tool_call_item),
ThreadItemPayload::McpToolCall {
id: "mcp-1".to_string(),
server: "server".to_string(),
tool: "tool".to_string(),
@@ -3647,8 +3651,8 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
});
assert_eq!(
ThreadItem::from(completed_mcp_tool_call_item),
ThreadItem::McpToolCall {
ThreadItemPayload::from(completed_mcp_tool_call_item),
ThreadItemPayload::McpToolCall {
id: "mcp-2".to_string(),
server: "server".to_string(),
tool: "tool".to_string(),
@@ -3672,7 +3676,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() {
#[test]
fn mcp_tool_call_app_context_serializes_connector_id() {
let item = ThreadItem::McpToolCall {
let item = ThreadItemPayload::McpToolCall {
id: "mcp-1".to_string(),
server: "codex_apps".to_string(),
tool: "calendar.create_event".to_string(),
@@ -1845,6 +1845,16 @@ pub struct ThreadItemEntry {
pub completed_at_ms: Option<i64>,
}
impl From<ThreadItemEntry> for ThreadItem {
fn from(entry: ThreadItemEntry) -> Self {
Self {
started_at_ms: entry.item.started_at_ms.or(entry.started_at_ms),
completed_at_ms: entry.item.completed_at_ms.or(entry.completed_at_ms),
..entry.item
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1,6 +1,7 @@
use super::CodexErrorInfo;
use super::ThreadEnvironment;
use super::ThreadItem;
use super::ThreadItemPayload;
use super::ThreadStatus;
use super::TurnStatus;
use crate::JsonSchema;
@@ -409,6 +410,15 @@ pub struct Turn {
pub duration_ms: Option<i64>,
}
impl Turn {
/// Iterates over the item payloads in this turn.
pub fn item_payloads(
&self,
) -> impl DoubleEndedIterator<Item = &ThreadItemPayload> + ExactSizeIterator {
self.items.iter().map(|item| &item.payload)
}
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]