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
+18 -11
View File
@@ -436,22 +436,29 @@ impl AnalyticsEventsClient {
completed_at_ms: i64,
) {
let thread_id = item.sender_thread_id.to_string();
let completed_item = TurnItem::CollabAgentToolCall(item.clone()).into();
let completed_item = codex_app_server_protocol::ThreadItemPayload::from(
TurnItem::CollabAgentToolCall(item.clone()),
);
item.status = CollabAgentToolCallStatus::InProgress;
self.track_notification(&ServerNotification::ItemStarted(ItemStartedNotification {
item: TurnItem::CollabAgentToolCall(item).into(),
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
started_at_ms,
}));
self.track_notification(&ServerNotification::ItemStarted(
ItemStartedNotification::new(
thread_id.clone(),
turn_id.clone(),
codex_app_server_protocol::ThreadItemPayload::from(TurnItem::CollabAgentToolCall(
item,
)),
started_at_ms,
),
));
self.track_notification(&ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: completed_item,
ItemCompletedNotification::new(
thread_id,
turn_id,
completed_item,
Some(started_at_ms),
completed_at_ms,
},
),
));
}
@@ -935,7 +942,7 @@ fn session_event_to_analytics_notification(
match &notification {
ServerNotification::ItemStarted(ItemStartedNotification { item, .. })
| ServerNotification::ItemCompleted(ItemCompletedNotification { item, .. }) => {
tracked_tool_item_id(item)?;
tracked_tool_item_id(&item.payload)?;
}
_ => {}
}
+16 -13
View File
@@ -22,7 +22,7 @@ use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::TurnStartedNotification;
use codex_login::AuthManager;
use codex_protocol::ThreadId;
@@ -238,11 +238,10 @@ async fn buffered_tool_events_preserve_attribution_or_drop_it_on_queue_overflow(
turn: started.turn,
}));
// Collaborator completions wait in the reducer for subsequent sampling evidence.
let completed = ItemCompletedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 2,
item: ThreadItem::CollabAgentToolCall {
let completed = ItemCompletedNotification::new(
thread_id.to_string(),
"turn-1".to_string(),
ThreadItemPayload::CollabAgentToolCall {
id: "item".to_string(),
tool: CollabAgentTool::SendMessage,
status: CollabAgentToolCallStatus::Failed,
@@ -253,13 +252,17 @@ async fn buffered_tool_events_preserve_attribution_or_drop_it_on_queue_overflow(
reasoning_effort: None,
agents_states: Default::default(),
},
};
client.track_notification(&ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 1,
item: completed.item.clone(),
}));
/*started_at_ms*/ None,
/*completed_at_ms*/ 2,
);
client.track_notification(&ServerNotification::ItemStarted(
ItemStartedNotification::new(
thread_id.to_string(),
"turn-1".to_string(),
completed.item.payload.clone(),
/*started_at_ms*/ 1,
),
));
client.track_notification(&ServerNotification::ItemCompleted(completed));
let measurement = PluginMeasurementsInput {
thread_id: thread_id.to_string(),
+98 -86
View File
@@ -154,7 +154,7 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ServerResponse;
use codex_app_server_protocol::SubAgentActivityKind;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput;
use codex_app_server_protocol::WebSearchAction;
@@ -535,36 +535,36 @@ struct TurnToolCounts {
}
impl TurnToolCounts {
fn record(&mut self, item: &ThreadItem) {
fn record(&mut self, item: &ThreadItemPayload) {
match item {
ThreadItem::CommandExecution { .. } => self.shell_command += 1,
ThreadItem::FileChange { .. } => self.file_change += 1,
ThreadItem::McpToolCall { .. } => self.mcp_tool_call += 1,
ThreadItem::DynamicToolCall { .. } => self.dynamic_tool_call += 1,
ThreadItem::SubAgentActivity {
ThreadItemPayload::CommandExecution { .. } => self.shell_command += 1,
ThreadItemPayload::FileChange { .. } => self.file_change += 1,
ThreadItemPayload::McpToolCall { .. } => self.mcp_tool_call += 1,
ThreadItemPayload::DynamicToolCall { .. } => self.dynamic_tool_call += 1,
ThreadItemPayload::SubAgentActivity {
kind: SubAgentActivityKind::Completed,
..
} => return,
ThreadItem::CollabAgentToolCall { id, .. }
| ThreadItem::SubAgentActivity { id, .. } => {
ThreadItemPayload::CollabAgentToolCall { id, .. }
| ThreadItemPayload::SubAgentActivity { id, .. } => {
if !self.subagent_tool_call_ids.insert(id.clone()) {
return;
}
self.subagent_tool_call += 1;
}
ThreadItem::WebSearch(_) => self.web_search += 1,
ThreadItem::ImageGeneration(_) => self.image_generation += 1,
ThreadItem::UserMessage { .. }
| ThreadItem::HookPrompt { .. }
| ThreadItem::AgentMessage { .. }
| ThreadItem::FunctionCallOutput { .. }
| ThreadItem::Plan { .. }
| ThreadItem::Reasoning { .. }
| ThreadItem::ImageView { .. }
| ThreadItem::Sleep(_)
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. }
| ThreadItem::ContextCompaction { .. } => return,
ThreadItemPayload::WebSearch(_) => self.web_search += 1,
ThreadItemPayload::ImageGeneration(_) => self.image_generation += 1,
ThreadItemPayload::UserMessage { .. }
| ThreadItemPayload::HookPrompt { .. }
| ThreadItemPayload::AgentMessage { .. }
| ThreadItemPayload::FunctionCallOutput { .. }
| ThreadItemPayload::Plan { .. }
| ThreadItemPayload::Reasoning { .. }
| ThreadItemPayload::ImageView { .. }
| ThreadItemPayload::Sleep(_)
| ThreadItemPayload::EnteredReviewMode { .. }
| ThreadItemPayload::ExitedReviewMode { .. }
| ThreadItemPayload::ContextCompaction { .. } => return,
}
self.total += 1;
}
@@ -2060,15 +2060,17 @@ impl AnalyticsReducer {
}));
}
ServerNotification::ItemStarted(notification) => {
let Some(item_id) = tracked_tool_item_id(&notification.item) else {
let Some(item_id) = tracked_tool_item_id(&notification.item.payload) else {
return;
};
let Some(started_at_ms) = option_i64_to_u64(Some(notification.started_at_ms))
else {
return;
};
let model_context = match &notification.item {
ThreadItem::CommandExecution { model_context, .. } => model_context.clone(),
let model_context = match &notification.item.payload {
ThreadItemPayload::CommandExecution { model_context, .. } => {
model_context.clone()
}
_ => None,
};
self.turn_state(notification.turn_id.clone())
@@ -2079,7 +2081,7 @@ impl AnalyticsReducer {
turn_id: notification.turn_id.clone(),
item_id: item_id.to_string(),
};
let cua_identity = cua_mcp_tool_identity(&notification.item);
let cua_identity = cua_mcp_tool_identity(&notification.item.payload);
if cua_identity.is_some()
&& self
.turns
@@ -2148,7 +2150,10 @@ impl AnalyticsReducer {
));
}
ServerNotification::ItemCompleted(notification) => {
if matches!(notification.item, ThreadItem::SubAgentActivity { .. }) {
if matches!(
notification.item.payload,
ThreadItemPayload::SubAgentActivity { .. }
) {
let Some(turn_state) = self.turns.get_mut(&notification.turn_id) else {
tracing::warn!(
thread_id = %notification.thread_id,
@@ -2157,10 +2162,10 @@ impl AnalyticsReducer {
);
return;
};
turn_state.tool_counts.record(&notification.item);
turn_state.tool_counts.record(&notification.item.payload);
return;
}
let Some(item_id) = tracked_tool_item_id(&notification.item) else {
let Some(item_id) = tracked_tool_item_id(&notification.item.payload) else {
return;
};
let key = ToolItemKey {
@@ -2168,7 +2173,7 @@ impl AnalyticsReducer {
turn_id: notification.turn_id.clone(),
item_id: item_id.to_string(),
};
let is_cua = cua_mcp_tool_identity(&notification.item).is_some();
let is_cua = cua_mcp_tool_identity(&notification.item.payload).is_some();
if is_cua
&& self
.turns
@@ -2177,18 +2182,20 @@ impl AnalyticsReducer {
{
return;
}
let elicitation_type =
if matches!(notification.item, ThreadItem::McpToolCall { .. }) {
// Producers enqueue known classifications before completion on
// the same analytics queue, so completed keys need no history.
self.pending_mcp_tool_elicitations
.iter()
.position(|(pending, _)| pending == &key)
.and_then(|index| self.pending_mcp_tool_elicitations.remove(index))
.map(|(_, elicitation_type)| elicitation_type)
} else {
None
};
let elicitation_type = if matches!(
notification.item.payload,
ThreadItemPayload::McpToolCall { .. }
) {
// Producers enqueue known classifications before completion on
// the same analytics queue, so completed keys need no history.
self.pending_mcp_tool_elicitations
.iter()
.position(|(pending, _)| pending == &key)
.and_then(|index| self.pending_mcp_tool_elicitations.remove(index))
.map(|(_, elicitation_type)| elicitation_type)
} else {
None
};
let Some(turn_state) = self.turns.get_mut(&notification.turn_id) else {
tracing::warn!(
thread_id = %notification.thread_id,
@@ -2198,7 +2205,7 @@ impl AnalyticsReducer {
);
return;
};
turn_state.tool_counts.record(&notification.item);
turn_state.tool_counts.record(&notification.item.payload);
let Some((started_at_ms, model_context)) =
self.tool_items_started_at_ms.remove(&key)
else {
@@ -2222,7 +2229,7 @@ impl AnalyticsReducer {
if let Some(mut event) = tool_item_event(ToolItemEventInput {
thread_id: &notification.thread_id,
turn_id: &notification.turn_id,
item: &notification.item,
item: &notification.item.payload,
model_context: model_context.as_ref(),
started_at_ms,
completed_at_ms,
@@ -2245,12 +2252,14 @@ impl AnalyticsReducer {
.and_then(|config| config.turn_metadata.root_turn_id());
// Fast collaborator tools can complete before their sampling response.
// Keep the event until that response can supply its originating ID.
let emission =
if matches!(&notification.item, ThreadItem::CollabAgentToolCall { .. }) {
ToolEventEmission::AwaitResponse
} else {
ToolEventEmission::ImmediateUnlessCorrelated
};
let emission = if matches!(
&notification.item.payload,
ThreadItemPayload::CollabAgentToolCall { .. }
) {
ToolEventEmission::AwaitResponse
} else {
ToolEventEmission::ImmediateUnlessCorrelated
};
self.record_tool_event(
&notification.thread_id,
&notification.turn_id,
@@ -2903,32 +2912,34 @@ fn warn_missing_analytics_context(
);
}
pub(crate) fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> {
pub(crate) fn tracked_tool_item_id(item: &ThreadItemPayload) -> Option<&str> {
match item {
ThreadItem::CommandExecution { id, .. }
| ThreadItem::FileChange { id, .. }
| ThreadItem::McpToolCall { id, .. }
| ThreadItem::DynamicToolCall { id, .. }
| ThreadItem::CollabAgentToolCall { id, .. } => Some(id),
ThreadItem::WebSearch(item) => Some(&item.id),
ThreadItem::ImageGeneration(item) => Some(&item.id),
ThreadItem::UserMessage { .. }
| ThreadItem::HookPrompt { .. }
| ThreadItem::AgentMessage { .. }
| ThreadItem::FunctionCallOutput { .. }
| ThreadItem::Plan { .. }
| ThreadItem::Reasoning { .. }
| ThreadItem::SubAgentActivity { .. }
| ThreadItem::ImageView { .. }
| ThreadItem::Sleep(_)
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. }
| ThreadItem::ContextCompaction { .. } => None,
ThreadItemPayload::CommandExecution { id, .. }
| ThreadItemPayload::FileChange { id, .. }
| ThreadItemPayload::McpToolCall { id, .. }
| ThreadItemPayload::DynamicToolCall { id, .. }
| ThreadItemPayload::CollabAgentToolCall { id, .. } => Some(id),
ThreadItemPayload::WebSearch(item) => Some(&item.id),
ThreadItemPayload::ImageGeneration(item) => Some(&item.id),
ThreadItemPayload::UserMessage { .. }
| ThreadItemPayload::HookPrompt { .. }
| ThreadItemPayload::AgentMessage { .. }
| ThreadItemPayload::FunctionCallOutput { .. }
| ThreadItemPayload::Plan { .. }
| ThreadItemPayload::Reasoning { .. }
| ThreadItemPayload::SubAgentActivity { .. }
| ThreadItemPayload::ImageView { .. }
| ThreadItemPayload::Sleep(_)
| ThreadItemPayload::EnteredReviewMode { .. }
| ThreadItemPayload::ExitedReviewMode { .. }
| ThreadItemPayload::ContextCompaction { .. } => None,
}
}
fn cua_mcp_tool_identity(item: &ThreadItem) -> Option<(&str, &str, Option<&str>, Option<&str>)> {
let ThreadItem::McpToolCall {
fn cua_mcp_tool_identity(
item: &ThreadItemPayload,
) -> Option<(&str, &str, Option<&str>, Option<&str>)> {
let ThreadItemPayload::McpToolCall {
server,
tool,
plugin_id,
@@ -3037,7 +3048,7 @@ fn item_review_summary_key(pending_review: &PendingReviewState) -> Option<ToolIt
struct ToolItemEventInput<'a> {
thread_id: &'a str,
turn_id: &'a str,
item: &'a ThreadItem,
item: &'a ThreadItemPayload,
model_context: Option<&'a ModelInvocationContext>,
started_at_ms: u64,
completed_at_ms: u64,
@@ -3063,7 +3074,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
elicitation_type,
} = input;
match item {
ThreadItem::CommandExecution {
ThreadItemPayload::CommandExecution {
id,
sandbox_type,
plugin_id,
@@ -3122,7 +3133,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
},
))
}
ThreadItem::FileChange {
ThreadItemPayload::FileChange {
id,
changes,
status,
@@ -3160,7 +3171,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
},
}))
}
ThreadItem::McpToolCall {
ThreadItemPayload::McpToolCall {
id,
server,
tool,
@@ -3209,7 +3220,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
},
))
}
ThreadItem::DynamicToolCall {
ThreadItemPayload::DynamicToolCall {
id,
tool,
status,
@@ -3256,7 +3267,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
},
))
}
ThreadItem::CollabAgentToolCall {
ThreadItemPayload::CollabAgentToolCall {
id,
tool,
status,
@@ -3323,7 +3334,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
},
))
}
ThreadItem::WebSearch(item) => {
ThreadItemPayload::WebSearch(item) => {
let base = tool_item_base(
thread_id,
turn_id,
@@ -3353,7 +3364,7 @@ fn tool_item_event(input: ToolItemEventInput<'_>) -> Option<TrackEventRequest> {
},
}))
}
ThreadItem::ImageGeneration(item) => {
ThreadItemPayload::ImageGeneration(item) => {
let (terminal_status, failure_kind) = image_generation_outcome(item.status.as_str());
let base = tool_item_base(
thread_id,
@@ -4235,11 +4246,10 @@ mod tests {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: keys[2].thread_id.clone(),
turn_id: keys[2].turn_id.clone(),
completed_at_ms: 1_000,
item: ThreadItem::McpToolCall {
ItemCompletedNotification::new(
keys[2].thread_id.clone(),
keys[2].turn_id.clone(),
ThreadItemPayload::McpToolCall {
id: keys[2].item_id.clone(),
server: "server".to_string(),
tool: "tool".to_string(),
@@ -4254,7 +4264,9 @@ mod tests {
error: None,
duration_ms: None,
},
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1_000,
),
))),
&mut events,
)
@@ -308,16 +308,16 @@ async fn completed_background_tool_item_emits_after_turn_event() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
started_at_ms: 998,
item: sample_command_execution_item(
ItemStartedNotification::new(
"thread-2".to_string(),
"turn-2".to_string(),
sample_command_execution_item(
CommandExecutionStatus::InProgress,
/*exit_code*/ None,
/*duration_ms*/ None,
),
},
/*started_at_ms*/ 998,
),
))),
&mut out,
)
@@ -343,16 +343,17 @@ async fn completed_background_tool_item_emits_after_turn_event() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
completed_at_ms: 1_000,
item: sample_command_execution_item(
ItemCompletedNotification::new(
"thread-2".to_string(),
"turn-2".to_string(),
sample_command_execution_item(
CommandExecutionStatus::Completed,
Some(0),
Some(1),
),
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1_000,
),
))),
&mut out,
)
@@ -430,16 +431,17 @@ async fn item_completed_without_turn_state_does_not_create_turn_state() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
completed_at_ms: 1_000,
item: sample_command_execution_item(
ItemCompletedNotification::new(
"thread-2".to_string(),
"turn-2".to_string(),
sample_command_execution_item(
CommandExecutionStatus::Completed,
Some(0),
Some(1),
),
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1_000,
),
))),
&mut out,
)
@@ -830,16 +830,16 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-subagent".to_string(),
turn_id: "turn-subagent".to_string(),
started_at_ms: 1_000,
item: sample_command_execution_item(
ItemStartedNotification::new(
"thread-subagent".to_string(),
"turn-subagent".to_string(),
sample_command_execution_item(
CommandExecutionStatus::InProgress,
/*exit_code*/ None,
/*duration_ms*/ None,
),
},
/*started_at_ms*/ 1_000,
),
))),
&mut events,
)
@@ -847,16 +847,17 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: "thread-subagent".to_string(),
turn_id: "turn-subagent".to_string(),
completed_at_ms: 1_042,
item: sample_command_execution_item(
ItemCompletedNotification::new(
"thread-subagent".to_string(),
"turn-subagent".to_string(),
sample_command_execution_item(
CommandExecutionStatus::Completed,
Some(0),
Some(42),
),
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1_042,
),
))),
&mut events,
)
@@ -47,7 +47,7 @@ use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::McpToolCallAppContext;
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::sandbox::SandboxType;
@@ -64,9 +64,9 @@ fn sample_command_execution_item_with_actions(
plugin_id: Option<&str>,
script_path: Option<&str>,
sandbox_type: Option<SandboxType>,
) -> ThreadItem {
) -> ThreadItemPayload {
let mut item = sample_command_execution_item(status, exit_code, duration_ms);
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
command_actions: item_command_actions,
plugin_id: item_plugin_id,
script_path: item_script_path,
@@ -233,7 +233,7 @@ async fn cua_tool_call_lifecycle_requires_a_start_and_deduplicates_replay() {
.await;
events.clear();
let item = |status| ThreadItem::McpToolCall {
let item = |status| ThreadItemPayload::McpToolCall {
id: "cua-call".into(),
server: "codex_apps".into(),
tool: "js".into(),
@@ -254,18 +254,19 @@ async fn cua_tool_call_lifecycle_requires_a_start_and_deduplicates_replay() {
error: None,
duration_ms: None,
};
let started = ServerNotification::ItemStarted(ItemStartedNotification {
thread_id: "thread-2".into(),
turn_id: "turn-2".into(),
started_at_ms: 1_000,
item: item(McpToolCallStatus::InProgress),
});
let completed = ServerNotification::ItemCompleted(ItemCompletedNotification {
thread_id: "thread-2".into(),
turn_id: "turn-2".into(),
completed_at_ms: 1_100,
item: item(McpToolCallStatus::Completed),
});
let started = ServerNotification::ItemStarted(ItemStartedNotification::new(
"thread-2".into(),
"turn-2".into(),
item(McpToolCallStatus::InProgress),
/*started_at_ms*/ 1_000,
));
let completed = ServerNotification::ItemCompleted(ItemCompletedNotification::new(
"thread-2".into(),
"turn-2".into(),
item(McpToolCallStatus::Completed),
Some(1_000),
/*completed_at_ms*/ 1_100,
));
for notification in [
completed.clone(), // Missing start must not poison later deduplication.
started.clone(),
@@ -330,17 +331,18 @@ async fn item_lifecycle_notifications_publish_command_execution_event() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 1_000,
item: {
ItemStartedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
{
let mut item = sample_command_execution_item(
CommandExecutionStatus::InProgress,
/*exit_code*/ None,
/*duration_ms*/ None,
);
if let ThreadItem::CommandExecution { model_context, .. } = &mut item {
if let ThreadItemPayload::CommandExecution { model_context, .. } =
&mut item
{
*model_context =
Some(codex_protocol::items::ModelInvocationContext {
model_slug: model.to_string(),
@@ -349,7 +351,8 @@ async fn item_lifecycle_notifications_publish_command_execution_event() {
}
item
},
},
/*started_at_ms*/ 1_000,
),
))),
&mut events,
)
@@ -363,11 +366,10 @@ async fn item_lifecycle_notifications_publish_command_execution_event() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 1_045,
item: sample_command_execution_item_with_actions(
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
sample_command_execution_item_with_actions(
CommandExecutionStatus::Completed,
Some(0),
Some(42),
@@ -394,7 +396,9 @@ async fn item_lifecycle_notifications_publish_command_execution_event() {
Some("scripts/run.py"),
Some(SandboxType::WindowsMxc),
),
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1_045,
),
))),
&mut events,
)
@@ -466,7 +470,7 @@ async fn collaborator_tool_events_keep_response_ids_when_completion_races_sampli
&mut events,
)
.await;
let item = ThreadItem::CollabAgentToolCall {
let item = ThreadItemPayload::CollabAgentToolCall {
id: "call-1".into(),
tool: CollabAgentTool::SendMessage,
status: CollabAgentToolCallStatus::Failed,
@@ -480,12 +484,12 @@ async fn collaborator_tool_events_keep_response_ids_when_completion_races_sampli
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-1".into(),
turn_id: "turn-1".into(),
started_at_ms: 1_000,
item: item.clone(),
},
ItemStartedNotification::new(
"thread-1".into(),
"turn-1".into(),
item.clone(),
/*started_at_ms*/ 1_000,
),
))),
&mut events,
)
@@ -494,12 +498,13 @@ async fn collaborator_tool_events_keep_response_ids_when_completion_races_sampli
sampling_response("turn-1", "response-1", &["call-1"]),
));
let completion = AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: "thread-1".into(),
turn_id: "turn-1".into(),
completed_at_ms: 1_010,
ItemCompletedNotification::new(
"thread-1".into(),
"turn-1".into(),
item,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1_010,
),
)));
let facts = if response_first {
[response, completion]
@@ -785,7 +790,7 @@ async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_c
("retry", "calendar", None),
("denied", "drive", Some(ElicitationType::Approval)),
] {
let item = ThreadItem::McpToolCall {
let item = ThreadItemPayload::McpToolCall {
id: item_id.to_string(),
server: "server".to_string(),
tool: "search".to_string(),
@@ -809,12 +814,12 @@ async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_c
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
started_at_ms: 998,
item: item.clone(),
},
ItemStartedNotification::new(
"thread-2".to_string(),
"turn-2".to_string(),
item.clone(),
/*started_at_ms*/ 998,
),
))),
&mut events,
)
@@ -851,12 +856,13 @@ async fn mcp_elicitation_classification_survives_turn_completion_and_preserves_c
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
completed_at_ms: 1_000,
ItemCompletedNotification::new(
"thread-2".to_string(),
"turn-2".to_string(),
item,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1_000,
),
))),
&mut events,
)
@@ -966,7 +972,7 @@ async fn image_generation_events_preserve_transparent_background_metadata() {
)
.await;
let item = ThreadItem::ImageGeneration(ImageGenerationItem {
let item = ThreadItemPayload::ImageGeneration(ImageGenerationItem {
id: "image-1".to_string(),
status: status.to_string(),
revised_prompt: None,
@@ -984,12 +990,12 @@ async fn image_generation_events_preserve_transparent_background_metadata() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
started_at_ms: 998,
item: item.clone(),
},
ItemStartedNotification::new(
"thread-2".to_string(),
"turn-2".to_string(),
item.clone(),
/*started_at_ms*/ 998,
),
))),
&mut out,
)
@@ -997,12 +1003,13 @@ async fn image_generation_events_preserve_transparent_background_metadata() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
completed_at_ms: 1_000,
ItemCompletedNotification::new(
"thread-2".to_string(),
"turn-2".to_string(),
item,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1_000,
),
))),
&mut out,
)
@@ -34,7 +34,7 @@ use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::PatchApplyStatus;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::SubAgentActivityKind;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadRealtimeStartedNotification;
use codex_app_server_protocol::TurnStatus as AppServerTurnStatus;
use codex_app_server_protocol::WebSearchItem;
@@ -474,7 +474,7 @@ async fn turn_event_counts_completed_tool_items() {
)
.await;
let mcp_tool_call_item = |status, duration_ms| ThreadItem::McpToolCall {
let mcp_tool_call_item = |status, duration_ms| ThreadItemPayload::McpToolCall {
id: "mcp-1".to_string(),
server: "server".to_string(),
tool: "search".to_string(),
@@ -497,13 +497,13 @@ async fn turn_event_counts_completed_tool_items() {
};
let completed_tool_items = vec![
sample_command_execution_item(CommandExecutionStatus::Completed, Some(0), Some(1)),
ThreadItem::FileChange {
ThreadItemPayload::FileChange {
id: "file-change-1".to_string(),
changes: Vec::new(),
status: PatchApplyStatus::Completed,
},
mcp_tool_call_item(McpToolCallStatus::Completed, Some(2)),
ThreadItem::DynamicToolCall {
ThreadItemPayload::DynamicToolCall {
id: "dynamic-1".to_string(),
// A name collision with code-mode exec must not change the event boundary.
namespace: Some("custom".to_string()),
@@ -514,7 +514,7 @@ async fn turn_event_counts_completed_tool_items() {
success: Some(true),
duration_ms: Some(3),
},
ThreadItem::CollabAgentToolCall {
ThreadItemPayload::CollabAgentToolCall {
id: "collab-1".to_string(),
tool: CollabAgentTool::SpawnAgent,
status: CollabAgentToolCallStatus::Completed,
@@ -525,7 +525,7 @@ async fn turn_event_counts_completed_tool_items() {
reasoning_effort: None,
agents_states: Default::default(),
},
ThreadItem::SubAgentActivity {
ThreadItemPayload::SubAgentActivity {
model: None,
reasoning_effort: None,
id: "sub-agent-activity-1".to_string(),
@@ -533,7 +533,7 @@ async fn turn_event_counts_completed_tool_items() {
agent_thread_id: "thread-child".to_string(),
agent_path: "/root/child".to_string(),
},
ThreadItem::SubAgentActivity {
ThreadItemPayload::SubAgentActivity {
model: None,
reasoning_effort: None,
id: "sub-agent-activity-completed".to_string(),
@@ -541,13 +541,13 @@ async fn turn_event_counts_completed_tool_items() {
agent_thread_id: "thread-child".to_string(),
agent_path: "/root/child".to_string(),
},
ThreadItem::WebSearch(WebSearchItem {
ThreadItemPayload::WebSearch(WebSearchItem {
id: "web-1".to_string(),
query: "codex".to_string(),
action: None,
results: None,
}),
ThreadItem::ImageGeneration(ImageGenerationItem {
ThreadItemPayload::ImageGeneration(ImageGenerationItem {
id: "image-1".to_string(),
status: "completed".to_string(),
revised_prompt: None,
@@ -567,12 +567,12 @@ async fn turn_event_counts_completed_tool_items() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
started_at_ms: 998,
item: item.clone(),
},
ItemStartedNotification::new(
"thread-2".to_string(),
"turn-2".to_string(),
item.clone(),
/*started_at_ms*/ 998,
),
))),
&mut out,
)
@@ -583,12 +583,13 @@ async fn turn_event_counts_completed_tool_items() {
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: "thread-2".to_string(),
turn_id: "turn-2".to_string(),
completed_at_ms: 1_000,
ItemCompletedNotification::new(
"thread-2".to_string(),
"turn-2".to_string(),
item,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1_000,
),
))),
&mut out,
)
+17 -16
View File
@@ -31,7 +31,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::ThreadItemPayload;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadSource as AppServerThreadSource;
use codex_app_server_protocol::ThreadStartResponse;
@@ -545,17 +545,17 @@ pub(super) async fn ingest_completed_command_execution_item(
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted(
ItemStartedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 1_000,
item: sample_command_execution_item_with_id(
ItemStartedNotification::new(
thread_id.to_string(),
"turn-1".to_string(),
sample_command_execution_item_with_id(
item_id,
CommandExecutionStatus::InProgress,
/*exit_code*/ None,
/*duration_ms*/ None,
),
},
/*started_at_ms*/ 1_000,
),
))),
events,
)
@@ -563,17 +563,18 @@ pub(super) async fn ingest_completed_command_execution_item(
reducer
.ingest(
AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted(
ItemCompletedNotification {
thread_id: thread_id.to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 1_042,
item: sample_command_execution_item_with_id(
ItemCompletedNotification::new(
thread_id.to_string(),
"turn-1".to_string(),
sample_command_execution_item_with_id(
item_id,
CommandExecutionStatus::Completed,
Some(0),
Some(42),
),
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 1_042,
),
))),
events,
)
@@ -640,7 +641,7 @@ pub(super) fn sample_command_execution_item(
status: CommandExecutionStatus,
exit_code: Option<i32>,
duration_ms: Option<i64>,
) -> ThreadItem {
) -> ThreadItemPayload {
sample_command_execution_item_with_id("item-1", status, exit_code, duration_ms)
}
@@ -649,8 +650,8 @@ pub(super) fn sample_command_execution_item_with_id(
status: CommandExecutionStatus,
exit_code: Option<i32>,
duration_ms: Option<i64>,
) -> ThreadItem {
ThreadItem::CommandExecution {
) -> ThreadItemPayload {
ThreadItemPayload::CommandExecution {
model_context: None,
sandbox_type: None,
id: id.to_string(),
+18 -15
View File
@@ -1134,19 +1134,22 @@ mod tests {
}
fn item_completed_notification(text: &str) -> ServerNotification {
ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification {
thread_id: "thread".to_string(),
turn_id: "turn".to_string(),
completed_at_ms: 0,
item: codex_app_server_protocol::ThreadItem::AgentMessage {
id: "item".to_string(),
text: text.to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
})
ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification::new(
"thread".to_string(),
"turn".to_string(),
codex_app_server_protocol::ThreadItemPayload::AgentMessage {
id: "item".to_string(),
text: text.to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
)
}
fn turn_completed_notification() -> ServerNotification {
@@ -1882,8 +1885,8 @@ mod tests {
}
ServerNotification::ItemCompleted(notification)
if matches!(
&notification.item,
codex_app_server_protocol::ThreadItem::AgentMessage { text, .. }
&notification.item.payload,
codex_app_server_protocol::ThreadItemPayload::AgentMessage { text, .. }
if text == "hello"
) =>
{
@@ -4643,6 +4643,7 @@
"type": "string"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -5545,7 +5546,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadNameUpdatedNotification": {
"properties": {
@@ -22316,6 +22316,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -23218,7 +23219,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadItemEntry": {
"properties": {
@@ -20030,6 +20030,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -20932,7 +20933,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadItemEntry": {
"properties": {
@@ -797,6 +797,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -1699,7 +1700,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"UserInput": {
"oneOf": [
@@ -797,6 +797,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -1699,7 +1700,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"UserInput": {
"oneOf": [
@@ -1001,6 +1001,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -1903,7 +1904,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"Turn": {
"properties": {
@@ -1615,6 +1615,7 @@
"type": "string"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -2517,7 +2518,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadSection": {
"description": "An independently persisted, user-visible thread section.",
@@ -797,6 +797,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -1699,7 +1700,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadItemEntry": {
"properties": {
@@ -1406,6 +1406,7 @@
"type": "string"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -2308,7 +2309,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadReadState": {
"description": "The first unread position in a durable local thread. A null position means read. The opaque revision guards edits against activity or marks the caller has not seen.",
@@ -1406,6 +1406,7 @@
"type": "string"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -2308,7 +2309,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadSection": {
"description": "An independently persisted, user-visible thread section.",
@@ -1406,6 +1406,7 @@
"type": "string"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -2308,7 +2309,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadReadState": {
"description": "The first unread position in a durable local thread. A null position means read. The opaque revision guards edits against activity or marks the caller has not seen.",
@@ -1667,6 +1667,7 @@
"type": "string"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -2569,7 +2570,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadSection": {
"description": "An independently persisted, user-visible thread section.",
@@ -1406,6 +1406,7 @@
"type": "string"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -2308,7 +2309,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadSection": {
"description": "An independently persisted, user-visible thread section.",
@@ -1615,6 +1615,7 @@
"type": "string"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -2517,7 +2518,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadSection": {
"description": "An independently persisted, user-visible thread section.",
@@ -1406,6 +1406,7 @@
"type": "string"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -2308,7 +2309,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadSection": {
"description": "An independently persisted, user-visible thread section.",
@@ -1001,6 +1001,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -1903,7 +1904,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"Turn": {
"properties": {
@@ -1406,6 +1406,7 @@
"type": "string"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -2308,7 +2309,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"ThreadSection": {
"description": "An independently persisted, user-visible thread section.",
@@ -1001,6 +1001,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -1903,7 +1904,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"Turn": {
"properties": {
@@ -1001,6 +1001,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -1903,7 +1904,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"Turn": {
"properties": {
@@ -1001,6 +1001,7 @@
"type": "object"
},
"ThreadItem": {
"description": "A thread item and its recorded lifecycle times. The payload stays flat on the wire.",
"oneOf": [
{
"properties": {
@@ -1903,7 +1904,28 @@
"title": "ContextCompactionThreadItem",
"type": "object"
}
]
],
"properties": {
"completedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item completed, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
},
"startedAtMs": {
"default": null,
"description": "Unix timestamp in milliseconds when the item started, if recorded.",
"format": "int64",
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"Turn": {
"properties": {
@@ -31,7 +31,18 @@ import type { PatchApplyStatus } from "./PatchApplyStatus";
import type { SubAgentActivityKind } from "./SubAgentActivityKind";
import type { UserInput } from "./UserInput";
export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, delivery: AgentMessageDelivery | null, questions: Array<AsyncUserInputQuestion> | null, } | { "type": "functionCallOutput", id: string, name: string, namespace: string | null, output: FunctionCallOutputBody, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
/**
* A thread item and its recorded lifecycle times. The payload stays flat on the wire.
*/
export type ThreadItem = {
/**
* Unix timestamp in milliseconds when the item started, if recorded.
*/
startedAtMs: number | null,
/**
* Unix timestamp in milliseconds when the item completed, if recorded.
*/
completedAtMs: number | null, } & ({ "type": "userMessage", id: string, clientId: string | null, content: Array<UserInput>, } | { "type": "hookPrompt", id: string, fragments: Array<HookPromptFragment>, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, delivery: AgentMessageDelivery | null, questions: Array<AsyncUserInputQuestion> | null, } | { "type": "functionCallOutput", id: string, name: string, namespace: string | null, output: FunctionCallOutputBody, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array<string>, content: Array<string>, } | { "type": "commandExecution", id: string,
/**
* Trusted first-party plugin id when this command resolves to one plugin script.
*/
@@ -130,4 +141,4 @@ model: string | null,
/**
* Resolved reasoning effort at sub-agent creation, when known.
*/
reasoningEffort: ReasoningEffort | null, id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep" } & SleepItem | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
reasoningEffort: ReasoningEffort | null, id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch" } & WebSearchItem | { "type": "imageView", id: string, path: LegacyAppPathString, } | { "type": "sleep" } & SleepItem | { "type": "imageGeneration" } & ImageGenerationItem | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, });
@@ -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/")]
+15 -12
View File
@@ -57,7 +57,7 @@ use codex_app_server_protocol::ThreadDecrementElicitationParams;
use codex_app_server_protocol::ThreadDecrementElicitationResponse;
use codex_app_server_protocol::ThreadIncrementElicitationParams;
use codex_app_server_protocol::ThreadIncrementElicitationResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadResumeParams;
@@ -1456,13 +1456,13 @@ struct CodexClient {
command_item_started: bool,
helper_done_seen: bool,
turn_completed_before_helper_done: bool,
unexpected_items_before_helper_done: Vec<ThreadItem>,
unexpected_items_before_helper_done: Vec<ThreadItemPayload>,
last_turn_status: Option<TurnStatus>,
last_turn_error_message: Option<String>,
}
fn item_started_before_helper_done_is_unexpected(
item: &ThreadItem,
item: &ThreadItemPayload,
command_item_started: bool,
helper_done_seen: bool,
) -> bool {
@@ -1470,7 +1470,7 @@ fn item_started_before_helper_done_is_unexpected(
return false;
}
!matches!(item, ThreadItem::UserMessage { .. })
!matches!(item, ThreadItemPayload::UserMessage { .. })
}
impl CodexClient {
@@ -1816,28 +1816,31 @@ impl CodexClient {
std::io::stdout().flush().ok();
}
ServerNotification::ItemStarted(payload) => {
if matches!(payload.item, ThreadItem::CommandExecution { .. }) {
if matches!(
payload.item.payload,
ThreadItemPayload::CommandExecution { .. }
) {
if self.command_item_started && !self.helper_done_seen {
self.unexpected_items_before_helper_done
.push(payload.item.clone());
.push(payload.item.payload.clone());
}
self.command_item_started = true;
} else if item_started_before_helper_done_is_unexpected(
&payload.item,
&payload.item.payload,
self.command_item_started,
self.helper_done_seen,
) {
self.unexpected_items_before_helper_done
.push(payload.item.clone());
.push(payload.item.payload.clone());
}
println!("\n< item started: {:?}", payload.item);
println!("\n< item started: {:?}", payload.item.payload);
}
ServerNotification::ItemCompleted(payload) => {
if let ThreadItem::CommandExecution {
if let ThreadItemPayload::CommandExecution {
status,
aggregated_output,
..
} = payload.item.clone()
} = payload.item.payload.clone()
{
self.command_execution_statuses.push(status);
if let Some(aggregated_output) = aggregated_output {
@@ -1845,7 +1848,7 @@ impl CodexClient {
self.command_execution_outputs.push(aggregated_output);
}
}
println!("< item completed: {:?}", payload.item);
println!("< item completed: {:?}", payload.item.payload);
}
ServerNotification::TurnCompleted(payload) => {
if payload.turn.id == turn_id {
+5
View File
@@ -611,6 +611,11 @@ execution and sandbox policies.
# Item history anchors
Live notifications and turn history return the same `ThreadItem`. Each item has
`startedAtMs` and `completedAtMs` beside `type` and `id`. Each timestamp is in
Unix milliseconds, or `null` when the producer has not recorded it. Clients
reading older servers may also encounter items that omit these fields.
`thread/items/list` accepts an optional nullable `cursor`: either an opaque string
from a previous response or an item anchor such as
`{"type":"item","itemId":"item-123"}`. An item anchor resumes exclusively after
+92 -126
View File
@@ -54,6 +54,7 @@ use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::StrictReviewRequiredNotification;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadRealtimeClosedNotification;
use codex_app_server_protocol::ThreadRealtimeErrorNotification;
use codex_app_server_protocol::ThreadRealtimeItemAddedNotification;
@@ -156,6 +157,7 @@ pub(crate) async fn apply_bespoke_event_handling(
id: event_turn_id,
msg,
} = event;
let approval_starts_command = msg.command_approval_start().is_some();
match msg {
EventMsg::TurnStarted(payload) => {
// While not technically necessary as it was already done on TurnComplete, be extra cautios and abort any pending server requests.
@@ -165,7 +167,8 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
let turn = {
let state = thread_state.lock().await;
let mut turn = state.active_turn_snapshot().unwrap_or_else(|| Turn {
let turn = state.active_turn_metadata_snapshot().map(Turn::from);
turn.unwrap_or_else(|| Turn {
id: payload.turn_id.clone(),
root_turn_id: payload.root_turn_id.clone(),
items: Vec::new(),
@@ -175,10 +178,7 @@ pub(crate) async fn apply_bespoke_event_handling(
started_at: payload.started_at,
completed_at: None,
duration_ms: None,
});
turn.items.clear();
turn.items_view = TurnItemsView::NotLoaded;
turn
})
};
let notification = TurnStartedNotification {
thread_id: conversation_id.to_string(),
@@ -300,7 +300,7 @@ pub(crate) async fn apply_bespoke_event_handling(
&assessment,
CommandExecutionStatus::InProgress,
) {
Some(ThreadItem::CommandExecution {
Some(ThreadItemPayload::CommandExecution {
id,
model_context,
plugin_id,
@@ -327,20 +327,15 @@ pub(crate) async fn apply_bespoke_event_handling(
} else {
assessment.turn_id.clone()
};
if assessment.status == codex_protocol::protocol::GuardianAssessmentStatus::InProgress
if approval_starts_command
&& let Some((target_item_id, completion_item)) = pending_command_execution.as_ref()
{
start_command_execution_item(
&conversation_id,
assessment_turn_id.clone(),
target_item_id.clone(),
completion_item.model_context.clone(),
completion_item.plugin_id.clone(),
completion_item.script_path.clone(),
completion_item.command.clone(),
completion_item.cwd.clone(),
completion_item.command_actions.clone(),
CommandExecutionSource::Agent,
assessment.started_at_ms,
completion_item,
&outgoing,
&thread_state,
)
@@ -393,9 +388,8 @@ pub(crate) async fn apply_bespoke_event_handling(
assessment_turn_id,
target_item_id,
completion_item,
/*process_id*/ None,
CommandExecutionSource::Agent,
completion_status,
assessment.completed_at_ms,
&outgoing,
&thread_state,
)
@@ -760,20 +754,13 @@ pub(crate) async fn apply_bespoke_event_handling(
Some(completion_item),
),
};
if approval_id.is_none()
&& let Some(completion_item) = completion_item.as_ref()
{
if approval_starts_command && let Some(completion_item) = completion_item.as_ref() {
start_command_execution_item(
&conversation_id,
event_turn_id.clone(),
call_id.clone(),
completion_item.model_context.clone(),
completion_item.plugin_id.clone(),
completion_item.script_path.clone(),
completion_item.command.clone(),
completion_item.cwd.clone(),
completion_item.command_actions.clone(),
CommandExecutionSource::Agent,
started_at_ms,
completion_item,
&outgoing,
&thread_state,
)
@@ -1088,13 +1075,12 @@ pub(crate) async fn apply_bespoke_event_handling(
EventMsg::ItemStarted(event) => {
let should_emit = match &event.item {
// Approval and guardian flows can emit the command start notification before core
// emits the canonical item. Reuse the same set to suppress that duplicate.
// emits the canonical item. Keep the first observed start and suppress that duplicate.
CoreTurnItem::CommandExecution(item) => thread_state
.lock()
.await
.turn_summary
.command_execution_started
.insert(item.id.clone()),
.record_command_start(item.id.clone(), event.started_at_ms),
_ => true,
};
let dynamic_tool_call_params = match &event.item {
@@ -1129,12 +1115,12 @@ pub(crate) async fn apply_bespoke_event_handling(
});
}
}
EventMsg::ItemCompleted(event) => {
EventMsg::ItemCompleted(mut event) => {
apply_canonical_item_completed_side_effects(
&thread_manager,
&thread_watch_manager,
&thread_state,
&event.item,
&mut event,
)
.await;
let mut notification = item_event_to_server_notification(
@@ -1408,16 +1394,18 @@ async fn apply_canonical_item_completed_side_effects(
thread_manager: &Arc<ThreadManager>,
thread_watch_manager: &ThreadWatchManager,
thread_state: &Arc<Mutex<ThreadState>>,
item: &CoreTurnItem,
event: &mut codex_protocol::protocol::ItemCompletedEvent,
) {
match item {
match &event.item {
CoreTurnItem::CommandExecution(item) => {
thread_state
let observed_start = thread_state
.lock()
.await
.turn_summary
.command_execution_started
.command_start_times
.remove(&item.id);
// A client can already be displaying the command's approval-time start.
event.started_at_ms = observed_start.or(event.started_at_ms);
}
CoreTurnItem::SubAgentActivity(activity)
if activity.kind == SubAgentActivityKind::Interrupted =>
@@ -1450,50 +1438,42 @@ async fn remove_missing_thread_watch(
}
}
#[allow(clippy::too_many_arguments)]
async fn start_command_execution_item(
conversation_id: &ThreadId,
turn_id: String,
item_id: String,
model_context: Option<ModelInvocationContext>,
plugin_id: Option<String>,
script_path: Option<String>,
command: String,
cwd: LegacyAppPathString,
command_actions: Vec<V2ParsedCommand>,
source: CommandExecutionSource,
started_at_ms: i64,
details: &CommandExecutionCompletionItem,
outgoing: &ThreadScopedOutgoingMessageSender,
thread_state: &Arc<Mutex<ThreadState>>,
) -> bool {
let first_start = {
let mut state = thread_state.lock().await;
state
.turn_summary
.command_execution_started
.insert(item_id.clone())
};
let first_start = thread_state
.lock()
.await
.turn_summary
.record_command_start(item_id.clone(), started_at_ms);
if first_start {
let notification = ItemStartedNotification {
thread_id: conversation_id.to_string(),
let notification = ItemStartedNotification::new(
conversation_id.to_string(),
turn_id,
started_at_ms: now_unix_timestamp_ms(),
item: ThreadItem::CommandExecution {
ThreadItemPayload::CommandExecution {
id: item_id,
model_context,
model_context: details.model_context.clone(),
sandbox_type: None,
plugin_id,
script_path,
command,
cwd,
plugin_id: details.plugin_id.clone(),
script_path: details.script_path.clone(),
command: details.command.clone(),
cwd: details.cwd.clone(),
process_id: None,
source,
source: CommandExecutionSource::Agent,
status: CommandExecutionStatus::InProgress,
command_actions,
command_actions: details.command_actions.clone(),
aggregated_output: None,
exit_code: None,
duration_ms: None,
},
};
started_at_ms,
);
outgoing
.send_server_notification(ServerNotification::ItemStarted(notification))
.await;
@@ -1507,23 +1487,22 @@ async fn complete_command_execution_item(
turn_id: String,
item_id: String,
completion_item: CommandExecutionCompletionItem,
process_id: Option<String>,
source: CommandExecutionSource,
status: CommandExecutionStatus,
completed_at_ms: Option<i64>,
outgoing: &ThreadScopedOutgoingMessageSender,
thread_state: &Arc<Mutex<ThreadState>>,
) {
let should_emit = thread_state
let Some(started_at_ms) = thread_state
.lock()
.await
.turn_summary
.command_execution_started
.remove(&item_id);
if !should_emit {
.command_start_times
.remove(&item_id)
else {
return;
}
};
let item = ThreadItem::CommandExecution {
let item = ThreadItemPayload::CommandExecution {
id: item_id,
model_context: completion_item.model_context,
sandbox_type: None,
@@ -1531,20 +1510,22 @@ async fn complete_command_execution_item(
script_path: completion_item.script_path,
command: completion_item.command,
cwd: completion_item.cwd,
process_id,
source,
process_id: None,
source: CommandExecutionSource::Agent,
status,
command_actions: completion_item.command_actions,
aggregated_output: None,
exit_code: None,
duration_ms: None,
};
let notification = ItemCompletedNotification {
thread_id: conversation_id.to_string(),
let notification = ItemCompletedNotification::new(
conversation_id.to_string(),
turn_id,
completed_at_ms: now_unix_timestamp_ms(),
item,
};
Some(started_at_ms),
// Guardian supplies the decision time used on resume. Manual approvals are timed here.
completed_at_ms.unwrap_or_else(now_unix_timestamp_ms),
);
outgoing
.send_server_notification(ServerNotification::ItemCompleted(notification))
.await;
@@ -2132,8 +2113,8 @@ async fn on_command_execution_request_approval_response(
let state = thread_state.lock().await;
state
.turn_summary
.command_execution_started
.contains(&item_id)
.command_start_times
.contains_key(&item_id)
} else {
false
}
@@ -2148,9 +2129,8 @@ async fn on_command_execution_request_approval_response(
event_turn_id.clone(),
item_id.clone(),
completion_item,
/*process_id*/ None,
CommandExecutionSource::Agent,
status,
/*completed_at_ms*/ None,
&outgoing,
&thread_state,
)
@@ -2551,13 +2531,8 @@ mod tests {
&conversation_id,
"turn-1".to_string(),
"cmd-1".to_string(),
completion_item.model_context.clone(),
completion_item.plugin_id.clone(),
completion_item.script_path.clone(),
completion_item.command.clone(),
completion_item.cwd.clone(),
completion_item.command_actions.clone(),
CommandExecutionSource::Agent,
/*started_at_ms*/ 1_000,
&completion_item,
&outgoing,
&thread_state,
)
@@ -2567,11 +2542,12 @@ mod tests {
let msg = recv_broadcast_notification(&mut rx).await?;
match msg {
ServerNotification::ItemStarted(payload) => {
assert_eq!(payload.item.started_at_ms, Some(1_000));
assert_eq!(payload.thread_id, conversation_id.to_string());
assert_eq!(payload.turn_id, "turn-1");
assert_eq!(
payload.item,
ThreadItem::CommandExecution {
payload.item.payload,
ThreadItemPayload::CommandExecution {
model_context: None,
sandbox_type: None,
id: "cmd-1".to_string(),
@@ -2596,13 +2572,8 @@ mod tests {
&conversation_id,
"turn-1".to_string(),
"cmd-1".to_string(),
completion_item.model_context.clone(),
completion_item.plugin_id.clone(),
completion_item.script_path.clone(),
completion_item.command.clone(),
completion_item.cwd.clone(),
completion_item.command_actions.clone(),
CommandExecutionSource::Agent,
/*started_at_ms*/ 1_100,
&completion_item,
&outgoing,
&thread_state,
)
@@ -2633,13 +2604,8 @@ mod tests {
&conversation_id,
"turn-1".to_string(),
"cmd-1".to_string(),
completion_item.model_context.clone(),
completion_item.plugin_id.clone(),
completion_item.script_path.clone(),
completion_item.command.clone(),
completion_item.cwd.clone(),
completion_item.command_actions.clone(),
CommandExecutionSource::Agent,
/*started_at_ms*/ 1_000,
&completion_item,
&outgoing,
&thread_state,
)
@@ -2651,9 +2617,8 @@ mod tests {
"turn-1".to_string(),
"cmd-1".to_string(),
completion_item,
/*process_id*/ None,
CommandExecutionSource::Agent,
CommandExecutionStatus::Declined,
/*completed_at_ms*/ None,
&outgoing,
&thread_state,
)
@@ -2662,13 +2627,13 @@ mod tests {
let completed = recv_broadcast_notification(&mut rx).await?;
match completed {
ServerNotification::ItemCompleted(payload) => {
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
id,
plugin_id,
script_path,
status,
..
} = payload.item
} = payload.item.payload
else {
bail!("expected command execution completion");
};
@@ -2685,9 +2650,8 @@ mod tests {
"turn-1".to_string(),
"cmd-1".to_string(),
command_execution_completion_item("printf hi"),
/*process_id*/ None,
CommandExecutionSource::Agent,
CommandExecutionStatus::Declined,
/*completed_at_ms*/ None,
&outgoing,
&thread_state,
)
@@ -2750,13 +2714,13 @@ mod tests {
match first {
ServerNotification::ItemStarted(payload) => {
assert_eq!(payload.turn_id, "turn-guardian-approved");
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
id,
plugin_id,
script_path,
status,
..
} = payload.item
} = payload.item.payload
else {
bail!("expected command execution item");
};
@@ -2822,13 +2786,13 @@ mod tests {
match fourth {
ServerNotification::ItemStarted(payload) => {
assert_eq!(payload.turn_id, "turn-guardian-denied");
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
id,
plugin_id,
script_path,
status,
..
} = payload.item
} = payload.item.payload
else {
bail!("expected command execution item");
};
@@ -2878,13 +2842,14 @@ mod tests {
let seventh = recv_broadcast_notification(&mut rx).await?;
match seventh {
ServerNotification::ItemCompleted(payload) => {
let ThreadItem::CommandExecution {
assert_eq!(payload.item.completed_at_ms, Some(1_042));
let ThreadItemPayload::CommandExecution {
id,
plugin_id,
script_path,
status,
..
} = payload.item
} = payload.item.payload
else {
bail!("expected command execution completion");
};
@@ -3360,8 +3325,10 @@ mod tests {
};
assert_eq!(
payload,
ItemCompletedNotification {
item: ThreadItem::SubAgentActivity {
ItemCompletedNotification::new(
conversation_id.to_string(),
"turn-1".to_string(),
ThreadItemPayload::SubAgentActivity {
model: None,
reasoning_effort: None,
id: "activity-1".to_string(),
@@ -3369,10 +3336,9 @@ mod tests {
agent_thread_id: child_thread_id_string,
agent_path: "/root/worker".to_string(),
},
thread_id: conversation_id.to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 42,
}
Some(42),
/*completed_at_ms*/ 42
)
);
Ok(())
}
@@ -3511,8 +3477,8 @@ mod tests {
delivery: None,
questions: None,
}),
started_at_ms: Some(0),
completed_at_ms: 0,
started_at_ms: Some(42_123),
completed_at_ms: 42_200,
}),
);
state.track_current_turn_event(
@@ -3530,8 +3496,8 @@ mod tests {
delivery: None,
questions: None,
}),
started_at_ms: Some(0),
completed_at_ms: 0,
started_at_ms: Some(42_321),
completed_at_ms: 42_400,
}),
);
state.track_current_turn_event(&event_turn_id, &EventMsg::TurnComplete(event.clone()));
@@ -3554,7 +3520,7 @@ mod tests {
assert_eq!(n.turn.items_view, TurnItemsView::Summary);
assert!(matches!(
&n.turn.items[..],
[ThreadItem::AgentMessage { id, text, .. }]
[ThreadItem { payload: ThreadItemPayload::AgentMessage { id, text, .. }, started_at_ms: Some(42_123), completed_at_ms: Some(42_200) }]
if id == "msg-1" && text == "complete response"
));
assert_eq!(n.turn.error, None);
+13 -9
View File
@@ -80,7 +80,7 @@ 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::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
@@ -120,8 +120,11 @@ fn server_notification_requires_delivery(notification: &ServerNotification) -> b
| ServerNotification::ThreadAttachmentUpdated(_)
| ServerNotification::ExternalAgentConfigImportCompleted(_)
| ServerNotification::ItemCompleted(ItemCompletedNotification {
item: ThreadItem::AgentMessage {
delivery: Some(AgentMessageDelivery::Async),
item: codex_app_server_protocol::ThreadItem {
payload: ThreadItemPayload::AgentMessage {
delivery: Some(AgentMessageDelivery::Async),
..
},
..
},
..
@@ -1084,8 +1087,10 @@ mod tests {
})
));
assert!(server_notification_requires_delivery(
&ServerNotification::ItemCompleted(ItemCompletedNotification {
item: ThreadItem::AgentMessage {
&ServerNotification::ItemCompleted(ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
id: "item-1".to_string(),
text: "Still working".to_string(),
phase: None,
@@ -1093,10 +1098,9 @@ mod tests {
delivery: Some(AgentMessageDelivery::Async),
questions: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
})
/*started_at_ms*/ None,
/*completed_at_ms*/ 0
))
));
}
+26 -26
View File
@@ -1,7 +1,7 @@
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
use codex_app_server_protocol::GeneratedImageRef;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::UserInput;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
@@ -11,11 +11,11 @@ use codex_protocol::models::ResponseItem;
pub(crate) fn without_notification_media(notification: ServerNotification) -> ServerNotification {
match notification {
ServerNotification::ItemStarted(mut notification) => {
notification.item = without_thread_item_media(notification.item);
notification.item.payload = without_thread_item_media(notification.item.payload);
ServerNotification::ItemStarted(notification)
}
ServerNotification::ItemCompleted(mut notification) => {
notification.item = without_thread_item_media(notification.item);
notification.item.payload = without_thread_item_media(notification.item.payload);
ServerNotification::ItemCompleted(notification)
}
ServerNotification::RawResponseItemCompleted(mut notification) => {
@@ -142,9 +142,9 @@ pub(crate) fn without_notification_media(notification: ServerNotification) -> Se
}
}
fn without_thread_item_media(mut item: ThreadItem) -> ThreadItem {
fn without_thread_item_media(mut item: ThreadItemPayload) -> ThreadItemPayload {
match &mut item {
ThreadItem::UserMessage { content, .. } => {
ThreadItemPayload::UserMessage { content, .. } => {
content.retain(|item| match item {
UserInput::Image { .. } | UserInput::Audio { .. } => false,
UserInput::Text { .. }
@@ -154,7 +154,7 @@ fn without_thread_item_media(mut item: ThreadItem) -> ThreadItem {
| UserInput::Mention { .. } => true,
});
}
ThreadItem::DynamicToolCall {
ThreadItemPayload::DynamicToolCall {
content_items: Some(content_items),
..
} => {
@@ -164,7 +164,7 @@ fn without_thread_item_media(mut item: ThreadItem) -> ThreadItem {
DynamicToolCallOutputContentItem::InputText { .. } => true,
});
}
ThreadItem::FunctionCallOutput {
ThreadItemPayload::FunctionCallOutput {
output: FunctionCallOutputBody::ContentItems(items),
..
} => {
@@ -175,7 +175,7 @@ fn without_thread_item_media(mut item: ThreadItem) -> ThreadItem {
| FunctionCallOutputContentItem::EncryptedContent { .. } => true,
});
}
ThreadItem::McpToolCall {
ThreadItemPayload::McpToolCall {
result: Some(result),
..
} => {
@@ -191,34 +191,34 @@ fn without_thread_item_media(mut item: ThreadItem) -> ThreadItem {
.is_none()
});
}
ThreadItem::ImageGeneration(item) => {
ThreadItemPayload::ImageGeneration(item) => {
item.image = GeneratedImageRef::Inline {
result: String::new(),
};
}
ThreadItem::HookPrompt { .. }
| ThreadItem::AgentMessage { .. }
| ThreadItem::Plan { .. }
| ThreadItem::Reasoning { .. }
| ThreadItem::FunctionCallOutput {
ThreadItemPayload::HookPrompt { .. }
| ThreadItemPayload::AgentMessage { .. }
| ThreadItemPayload::Plan { .. }
| ThreadItemPayload::Reasoning { .. }
| ThreadItemPayload::FunctionCallOutput {
output: FunctionCallOutputBody::Text(_),
..
}
| ThreadItem::CommandExecution { .. }
| ThreadItem::FileChange { .. }
| ThreadItem::McpToolCall { result: None, .. }
| ThreadItem::DynamicToolCall {
| ThreadItemPayload::CommandExecution { .. }
| ThreadItemPayload::FileChange { .. }
| ThreadItemPayload::McpToolCall { result: None, .. }
| ThreadItemPayload::DynamicToolCall {
content_items: None,
..
}
| ThreadItem::CollabAgentToolCall { .. }
| ThreadItem::SubAgentActivity { .. }
| ThreadItem::WebSearch(_)
| ThreadItem::ImageView { .. }
| ThreadItem::Sleep(_)
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. }
| ThreadItem::ContextCompaction { .. } => {}
| ThreadItemPayload::CollabAgentToolCall { .. }
| ThreadItemPayload::SubAgentActivity { .. }
| ThreadItemPayload::WebSearch(_)
| ThreadItemPayload::ImageView { .. }
| ThreadItemPayload::Sleep(_)
| ThreadItemPayload::EnteredReviewMode { .. }
| ThreadItemPayload::ExitedReviewMode { .. }
| ThreadItemPayload::ContextCompaction { .. } => {}
}
item
}
@@ -229,8 +229,8 @@ use codex_app_server_protocol::ThreadIncrementElicitationParams;
use codex_app_server_protocol::ThreadIncrementElicitationResponse;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadInjectItemsResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemEntry;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadItemsListParams;
use codex_app_server_protocol::ThreadItemsListResponse;
use codex_app_server_protocol::ThreadItemsReadParams;
@@ -271,6 +271,7 @@ pub(super) async fn ensure_listener_task_running(
&conversation,
watch_registration,
thread_settings_baseline,
config_snapshot.history_mode,
);
let Some(listener_command_tx) = thread_state.listener_command_tx() else {
tracing::warn!(
@@ -19,6 +19,7 @@ use crate::thread_sample::SampleThread;
use crate::thread_sample::generate_sample;
use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_app_server_protocol::ThreadHistoryMode as ApiThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemsListAnchor;
use codex_app_server_protocol::ThreadItemsListCursor;
use codex_app_server_protocol::ThreadRevertParams;
@@ -3515,8 +3516,8 @@ impl ThreadRequestProcessor {
})
.await
.map_err(paginated_history_list_error)?;
for item in page.items {
items.push(deserialize_stored_thread_item(item)?);
for item in &page.items {
items.push(stored_thread_item_to_api_item(item)?);
}
let Some(next_cursor) = page.next_cursor else {
return Ok(items);
@@ -6366,29 +6367,31 @@ fn paginated_history_list_error(err: ThreadStoreError) -> JSONRPCErrorError {
}
}
fn deserialize_stored_thread_item(
item: codex_thread_store::StoredThreadItem,
fn stored_thread_item_to_api_item(
stored_item: &codex_thread_store::StoredThreadItem,
) -> Result<ThreadItem, JSONRPCErrorError> {
serde_json::from_slice::<ThreadItem>(&item.item_json).map_err(|err| {
let payload = serde_json::from_slice(&stored_item.item_json).map_err(|err| {
internal_error(format!(
"failed to deserialize stored thread item {}: {err}",
item.item_id
stored_item.item_id
))
})?;
Ok(ThreadItem {
payload,
started_at_ms: stored_item.started_at_ms,
completed_at_ms: stored_item.completed_at_ms,
})
}
fn stored_thread_item_to_entry(
stored_item: codex_thread_store::StoredThreadItem,
) -> Result<ThreadItemEntry, JSONRPCErrorError> {
let turn_id = stored_item.turn_id.clone();
let started_at_ms = stored_item.started_at_ms;
let completed_at_ms = stored_item.completed_at_ms;
let item = deserialize_stored_thread_item(stored_item)?;
let item = stored_thread_item_to_api_item(&stored_item)?;
Ok(ThreadItemEntry {
turn_id,
turn_id: stored_item.turn_id,
started_at_ms: item.started_at_ms,
completed_at_ms: item.completed_at_ms,
item,
started_at_ms,
completed_at_ms,
})
}
@@ -6410,8 +6413,8 @@ fn stored_turn_to_api_turn(
});
let items = turn
.items
.into_iter()
.map(deserialize_stored_thread_item)
.iter()
.map(stored_thread_item_to_api_item)
.collect::<Result<Vec<_>, _>>()?;
Ok(Turn {
id: turn.turn_id,
@@ -153,6 +153,7 @@ mod thread_processor_behavior_tests {
use chrono::Utc;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_config::CloudConfigBundleLoader;
use codex_config::LoaderOverrides;
@@ -333,14 +334,14 @@ mod thread_processor_behavior_tests {
let active_turn = Turn {
id: "live-turn".to_string(),
root_turn_id: None,
items: vec![ThreadItem::UserMessage {
items: vec![ThreadItem::untimed(ThreadItemPayload::UserMessage {
id: "live-user-message".to_string(),
client_id: None,
content: vec![V2UserInput::Text {
text: "live".to_string(),
text_elements: Vec::new(),
}],
}],
})],
items_view: TurnItemsView::Full,
error: None,
status: TurnStatus::InProgress,
@@ -112,8 +112,12 @@ pub(super) async fn publish(
return None;
}
let has_error = summary.last_error.is_some();
let final_text = match &summary.last_agent_message {
Some(ThreadItem::AgentMessage { text, .. }) => Some(text.as_str()),
let final_text = match summary
.last_agent_message
.as_ref()
.map(|message| &message.payload)
{
Some(ThreadItemPayload::AgentMessage { text, .. }) => Some(text.as_str()),
_ => None,
};
let visible = match &event.msg {
@@ -1,7 +1,7 @@
#[cfg(test)]
use codex_app_server_protocol::GeneratedImageRef;
use codex_app_server_protocol::McpToolCallResult;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::Turn;
use serde_json::Value as JsonValue;
@@ -18,8 +18,8 @@ pub(super) fn should_redact_thread_resume_payloads(client_name: Option<&str>) ->
pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) {
for turn in turns {
turn.items.retain_mut(|item| match item {
ThreadItem::McpToolCall {
turn.items.retain_mut(|entry| match &mut entry.payload {
ThreadItemPayload::McpToolCall {
arguments,
result,
error,
@@ -34,7 +34,7 @@ pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) {
}
true
}
ThreadItem::ImageGeneration(_) => false,
ThreadItemPayload::ImageGeneration(_) => false,
_ => true,
});
}
@@ -60,6 +60,7 @@ mod tests {
use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::TurnItemsView;
use codex_app_server_protocol::TurnStatus;
@@ -70,7 +71,7 @@ mod tests {
#[test]
fn redacts_mcp_success_result_and_removes_image_generation() {
let mut thread = test_thread(vec![
ThreadItem::AgentMessage {
ThreadItemPayload::AgentMessage {
id: "agent-1".to_string(),
text: "kept".to_string(),
phase: None,
@@ -78,7 +79,7 @@ mod tests {
delivery: None,
questions: None,
},
ThreadItem::McpToolCall {
ThreadItemPayload::McpToolCall {
id: "mcp-1".to_string(),
server: "docs".to_string(),
tool: "lookup".to_string(),
@@ -106,7 +107,7 @@ mod tests {
error: None,
duration_ms: Some(8),
},
ThreadItem::ImageGeneration(ImageGenerationItem {
ThreadItemPayload::ImageGeneration(ImageGenerationItem {
id: "ig-1".to_string(),
status: "completed".to_string(),
revised_prompt: Some("revised".to_string()),
@@ -121,13 +122,16 @@ mod tests {
generation_id: None,
}),
]);
thread.turns[0].items[1].started_at_ms = Some(123);
thread.turns[0].items[2].started_at_ms = Some(456);
redact_thread_resume_payloads(&mut thread.turns);
assert_eq!(thread.turns[0].items.len(), 2);
assert_eq!(thread.turns[0].items[1].started_at_ms, Some(123));
assert_eq!(
thread.turns[0].items[0],
ThreadItem::AgentMessage {
thread.turns[0].items[0].payload,
ThreadItemPayload::AgentMessage {
id: "agent-1".to_string(),
text: "kept".to_string(),
phase: None,
@@ -137,8 +141,8 @@ mod tests {
}
);
assert_eq!(
thread.turns[0].items[1],
ThreadItem::McpToolCall {
thread.turns[0].items[1].payload,
ThreadItemPayload::McpToolCall {
id: "mcp-1".to_string(),
server: "docs".to_string(),
tool: "lookup".to_string(),
@@ -164,7 +168,7 @@ mod tests {
#[test]
fn redacts_mcp_error_message() {
let mut thread = test_thread(vec![ThreadItem::McpToolCall {
let mut thread = test_thread(vec![ThreadItemPayload::McpToolCall {
id: "mcp-1".to_string(),
server: "docs".to_string(),
tool: "lookup".to_string(),
@@ -185,8 +189,8 @@ mod tests {
redact_thread_resume_payloads(&mut thread.turns);
assert_eq!(
thread.turns[0].items[0],
ThreadItem::McpToolCall {
thread.turns[0].items[0].payload,
ThreadItemPayload::McpToolCall {
id: "mcp-1".to_string(),
server: "docs".to_string(),
tool: "lookup".to_string(),
@@ -206,7 +210,7 @@ mod tests {
);
}
fn test_thread(items: Vec<ThreadItem>) -> Thread {
fn test_thread(items: Vec<ThreadItemPayload>) -> Thread {
Thread {
originator: None,
environments: None,
@@ -242,7 +246,7 @@ mod tests {
turns: vec![Turn {
id: "turn-1".to_string(),
root_turn_id: None,
items,
items: items.into_iter().map(ThreadItem::untimed).collect(),
items_view: TurnItemsView::Full,
status: TurnStatus::Completed,
error: None,
@@ -4,6 +4,7 @@ use codex_agent_extension::AgentInvocation;
use codex_agent_extension::AgentRun;
use codex_agent_extension::AgentRunner;
use codex_app_server_protocol::ImageReference as V2ImageReference;
use codex_app_server_protocol::ThreadItem;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
@@ -1412,7 +1413,7 @@ impl TurnRequestProcessor {
let items = if display_text.is_empty() {
Vec::new()
} else {
vec![ThreadItem::UserMessage {
vec![ThreadItem::untimed(ThreadItemPayload::UserMessage {
id: turn_id.clone(),
client_id: None,
content: vec![V2UserInput::Text {
@@ -1420,7 +1421,7 @@ impl TurnRequestProcessor {
// Review prompt display text is synthesized; no UI element ranges to preserve.
text_elements: Vec::new(),
}],
}]
})]
};
Turn {
+21 -4
View File
@@ -19,6 +19,7 @@ use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent;
use codex_protocol::items::TurnItem as CoreTurnItem;
use codex_protocol::models::MessagePhase;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_rollout::RolloutItem;
use codex_rollout::state_db::StateDbHandle;
use codex_utils_path_uri::LegacyAppPathString;
@@ -95,11 +96,24 @@ pub(crate) enum ThreadListenerCommand {
#[derive(Default, Clone)]
pub(crate) struct TurnSummary {
pub(crate) started_at: Option<i64>,
pub(crate) command_execution_started: HashSet<String>,
pub(crate) command_start_times: HashMap<String, i64>,
pub(crate) last_error: Option<TurnError>,
pub(crate) last_agent_message: Option<ThreadItem>,
}
impl TurnSummary {
pub(crate) fn record_command_start(&mut self, item_id: String, started_at_ms: i64) -> bool {
if let std::collections::hash_map::Entry::Vacant(entry) =
self.command_start_times.entry(item_id)
{
entry.insert(started_at_ms);
true
} else {
false
}
}
}
#[derive(Default)]
pub(crate) struct ThreadState {
goal_resume_lock: Arc<Mutex<()>>,
@@ -115,6 +129,7 @@ pub(crate) struct ThreadState {
last_thread_settings: Option<ThreadSettings>,
listener_command_tx: Option<mpsc::UnboundedSender<ThreadListenerCommand>>,
current_turn_history: ThreadHistoryBuilder,
history_mode: ThreadHistoryMode,
listener_thread: Option<Weak<CodexThread>>,
watch_registration: WatchRegistration,
}
@@ -133,12 +148,14 @@ impl ThreadState {
conversation: &Arc<CodexThread>,
watch_registration: WatchRegistration,
thread_settings_baseline: ThreadSettings,
history_mode: ThreadHistoryMode,
) -> (mpsc::UnboundedReceiver<ThreadListenerCommand>, u64) {
if let Some(previous) = self.cancel_tx.replace(cancel_tx) {
let _ = previous.send(());
}
self.listener_generation = self.listener_generation.wrapping_add(1);
self.last_thread_settings = Some(thread_settings_baseline);
self.history_mode = history_mode;
let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel();
self.listener_command_tx = Some(listener_command_tx);
self.listener_thread = Some(Arc::downgrade(conversation));
@@ -209,10 +226,10 @@ impl ThreadState {
matches!(content, CoreAgentMessageContent::Text { text } if !text.trim().is_empty())
})
{
self.turn_summary.last_agent_message =
Some(ThreadItem::from(CoreTurnItem::AgentMessage(item.clone())));
self.turn_summary.last_agent_message = Some(ThreadItem::from(payload));
}
self.current_turn_history.handle_event(event);
self.current_turn_history
.handle_event_for_history_mode(event, self.history_mode);
if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) {
self.last_terminal_turn_id = Some(event_turn_id.to_string());
if !self.current_turn_history.has_active_turn() {
@@ -15,7 +15,7 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SandboxPolicy;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadStartParams;
@@ -439,8 +439,8 @@ async fn multi_agent_v2_tools_emit_collaborator_analytics() -> Result<()> {
.items;
let child_thread_id = items
.iter()
.find_map(|item| match item {
ThreadItem::SubAgentActivity {
.find_map(|item| match &item.payload {
ThreadItemPayload::SubAgentActivity {
id,
agent_thread_id,
..
@@ -451,12 +451,14 @@ async fn multi_agent_v2_tools_emit_collaborator_analytics() -> Result<()> {
assert!(
!items
.iter()
.any(|item| matches!(item, ThreadItem::CollabAgentToolCall { .. }))
.any(|item| matches!(&item.payload, ThreadItemPayload::CollabAgentToolCall { .. }))
);
let activity_ids = items
.iter()
.filter_map(|item| match item {
ThreadItem::SubAgentActivity { id, .. } if id.starts_with("call-") => Some(id.as_str()),
.filter_map(|item| match &item.payload {
ThreadItemPayload::SubAgentActivity { id, .. } if id.starts_with("call-") => {
Some(id.as_str())
}
_ => None,
})
.collect::<Vec<_>>();
@@ -22,7 +22,7 @@ use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
@@ -427,10 +427,10 @@ async fn auto_compaction_emits_started_and_completed_items(route: CompactionRout
let started = wait_for_context_compaction_started(&mut mcp).await?;
let completed = wait_for_context_compaction_completed(&mut mcp).await?;
let ThreadItem::ContextCompaction { id: started_id } = started.item else {
let ThreadItemPayload::ContextCompaction { id: started_id } = started.item.payload else {
unreachable!("started item should be context compaction");
};
let ThreadItem::ContextCompaction { id: completed_id } = completed.item else {
let ThreadItemPayload::ContextCompaction { id: completed_id } = completed.item.payload else {
unreachable!("completed item should be context compaction");
};
@@ -548,10 +548,10 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() -
let completed = wait_for_context_compaction_completed(&mut mcp).await?;
wait_for_turn_completed(&mut mcp, &started.turn_id).await?;
let ThreadItem::ContextCompaction { id: started_id } = started.item else {
let ThreadItemPayload::ContextCompaction { id: started_id } = started.item.payload else {
unreachable!("started item should be context compaction");
};
let ThreadItem::ContextCompaction { id: completed_id } = completed.item else {
let ThreadItemPayload::ContextCompaction { id: completed_id } = completed.item.payload else {
unreachable!("completed item should be context compaction");
};
@@ -723,7 +723,7 @@ async fn wait_for_context_compaction_started(
loop {
let started: ItemStartedNotification =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("item/started")).await??;
if let ThreadItem::ContextCompaction { .. } = started.item {
if let ThreadItemPayload::ContextCompaction { .. } = started.item.payload {
return Ok(started);
}
}
@@ -738,7 +738,7 @@ async fn wait_for_context_compaction_completed(
mcp.read_notification("item/completed"),
)
.await??;
if let ThreadItem::ContextCompaction { .. } = completed.item {
if let ThreadItemPayload::ContextCompaction { .. } = completed.item.payload {
return Ok(completed);
}
}
@@ -9,7 +9,7 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartParams;
@@ -125,12 +125,12 @@ decision = "prompt"
loop {
let completed: ItemCompletedNotification =
app.read_notification("item/completed").await?;
if let ThreadItem::CommandExecution {
if let ThreadItemPayload::CommandExecution {
id,
status,
exit_code,
..
} = completed.item
} = completed.item.payload
&& id == call_id
{
assert_eq!(
@@ -9,7 +9,7 @@ use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::McpServerToolCallParams;
use codex_app_server_protocol::McpServerToolCallResponse;
use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
@@ -185,9 +185,9 @@ async fn daybreak_access_respects_plugin_provenance(case: AccessCall) -> Result<
.await?;
loop {
let event: ItemCompletedNotification = app.read_notification("item/completed").await?;
if let ThreadItem::McpToolCall {
if let ThreadItemPayload::McpToolCall {
result, plugin_id, ..
} = event.item
} = event.item.payload
{
assert_eq!(plugin_id.as_deref(), Some("selected-daybreak"));
results.push((
@@ -20,7 +20,7 @@ use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
@@ -448,7 +448,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
let started = wait_for_dynamic_tool_started(&mut mcp, call_id).await?;
assert_eq!(started.thread_id, thread_id);
assert_eq!(started.turn_id, turn_id.clone());
let ThreadItem::DynamicToolCall {
let ThreadItemPayload::DynamicToolCall {
id,
namespace,
tool,
@@ -457,7 +457,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
content_items,
success,
duration_ms,
} = started.item
} = started.item.payload
else {
panic!("expected dynamic tool call item");
};
@@ -504,7 +504,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?;
assert_eq!(completed.thread_id, thread_id);
assert_eq!(completed.turn_id, turn_id);
let ThreadItem::DynamicToolCall {
let ThreadItemPayload::DynamicToolCall {
id,
namespace,
tool,
@@ -513,7 +513,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res
content_items,
success,
duration_ms,
} = completed.item
} = completed.item.payload
else {
panic!("expected dynamic tool call item");
};
@@ -735,12 +735,12 @@ async fn dynamic_tool_call_round_trip_handles_content_items() -> Result<()> {
let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?;
assert_eq!(completed.thread_id, params.thread_id);
assert_eq!(completed.turn_id, params.turn_id);
let ThreadItem::DynamicToolCall {
let ThreadItemPayload::DynamicToolCall {
status,
content_items: completed_content_items,
success,
..
} = completed.item
} = completed.item.payload
else {
panic!("expected dynamic tool call item");
};
@@ -830,12 +830,12 @@ async fn dynamic_tool_remote_image_response_becomes_model_visible_error() -> Res
let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?;
assert_eq!(completed.thread_id, params.thread_id);
assert_eq!(completed.turn_id, params.turn_id);
let ThreadItem::DynamicToolCall {
let ThreadItemPayload::DynamicToolCall {
status,
content_items,
success,
..
} = completed.item
} = completed.item.payload
else {
panic!("expected dynamic tool call item");
};
@@ -886,12 +886,12 @@ async fn dynamic_tool_remote_audio_response_becomes_model_visible_error() -> Res
let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?;
assert_eq!(completed.thread_id, params.thread_id);
assert_eq!(completed.turn_id, params.turn_id);
let ThreadItem::DynamicToolCall {
let ThreadItemPayload::DynamicToolCall {
status,
content_items,
success,
..
} = completed.item
} = completed.item.payload
else {
panic!("expected dynamic tool call item");
};
@@ -978,7 +978,8 @@ async fn wait_for_dynamic_tool_started(
continue;
};
let started: ItemStartedNotification = serde_json::from_value(params)?;
if matches!(&started.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) {
if matches!(&started.item.payload, ThreadItemPayload::DynamicToolCall { id, .. } if id == call_id)
{
return Ok(started);
}
}
@@ -998,7 +999,8 @@ async fn wait_for_dynamic_tool_completed(
continue;
};
let completed: ItemCompletedNotification = serde_json::from_value(params)?;
if matches!(&completed.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) {
if matches!(&completed.item.payload, ThreadItemPayload::DynamicToolCall { id, .. } if id == call_id)
{
return Ok(completed);
}
}
@@ -34,7 +34,7 @@ use codex_app_server_protocol::MergeStrategy;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadReadParams;
@@ -645,10 +645,10 @@ source = {:?}
let response: ThreadReadResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(response.thread.turns.len(), 1);
let imported_items = &response.thread.turns[0].items;
let mut imported_items = response.thread.turns[0].item_payloads();
assert_eq!(imported_items.len(), 3);
match &imported_items[0] {
ThreadItem::UserMessage { content, .. } => assert_eq!(
match imported_items.next() {
Some(ThreadItemPayload::UserMessage { content, .. }) => assert_eq!(
content,
&vec![UserInput::Text {
text: "first request".to_string(),
@@ -657,8 +657,8 @@ source = {:?}
),
other => panic!("expected user message item, got {other:?}"),
}
match &imported_items[1] {
ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "first answer"),
match imported_items.next() {
Some(ThreadItemPayload::AgentMessage { text, .. }) => assert_eq!(text, "first answer"),
other => panic!("expected agent message item, got {other:?}"),
}
@@ -2108,10 +2108,10 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
let response: ThreadReadResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(response.thread.turns.len(), 2);
let control_items = &response.thread.turns[0].items;
let mut control_items = response.thread.turns[0].item_payloads();
assert_eq!(control_items.len(), 1);
match &control_items[0] {
ThreadItem::UserMessage { content, .. } => {
match control_items.next() {
Some(ThreadItemPayload::UserMessage { content, .. }) => {
assert_eq!(
content,
&vec![UserInput::Text {
@@ -2122,10 +2122,10 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
}
other => panic!("expected user message item, got {other:?}"),
}
let imported_items = &response.thread.turns[1].items;
let mut imported_items = response.thread.turns[1].item_payloads();
assert_eq!(imported_items.len(), 3);
match &imported_items[0] {
ThreadItem::UserMessage { content, .. } => {
match imported_items.next() {
Some(ThreadItemPayload::UserMessage { content, .. }) => {
assert_eq!(
content,
&vec![UserInput::Text {
@@ -2137,8 +2137,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
other => panic!("expected user message item, got {other:?}"),
}
assert_eq!(
imported_items.last(),
Some(&ThreadItem::AgentMessage {
imported_items.next_back(),
Some(&ThreadItemPayload::AgentMessage {
id: "item-4".into(),
text: "<EXTERNAL SESSION IMPORTED>".into(),
phase: None,
@@ -2183,8 +2183,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
let response: ThreadReadResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
assert_eq!(response.thread.turns.len(), 3);
match &response.thread.turns[2].items[1] {
ThreadItem::AgentMessage { text, .. } => assert_eq!(text, "follow-up answer"),
match response.thread.turns[2].item_payloads().nth(/*n*/ 1) {
Some(ThreadItemPayload::AgentMessage { text, .. }) => assert_eq!(text, "follow-up answer"),
other => panic!("expected agent message item, got {other:?}"),
}
@@ -14,9 +14,10 @@ use codex_app_server_protocol::ExternalAgentConfigDetectResponse;
use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::ExternalAgentConfigMigrationItemType;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput;
use pretty_assertions::assert_eq;
@@ -229,16 +230,18 @@ async fn start_app_server(codex_home: &Path) -> Result<TestAppServer> {
fn assert_history(response: &ThreadReadResponse, expected: &[(&str, &str)]) {
let mut actual = Vec::new();
let mut markers = 0;
for item in response.thread.turns.iter().flat_map(|turn| &turn.items) {
for item in response.thread.turns.iter().flat_map(Turn::item_payloads) {
match item {
ThreadItem::UserMessage { content, .. } => {
ThreadItemPayload::UserMessage { content, .. } => {
actual.extend(content.iter().filter_map(|input| match input {
UserInput::Text { text, .. } => Some(("user", text.as_str())),
_ => None,
}));
}
ThreadItem::AgentMessage { text, .. } if text == IMPORT_MARKER => markers += 1,
ThreadItem::AgentMessage { text, .. } => actual.push(("assistant", text.as_str())),
ThreadItemPayload::AgentMessage { text, .. } if text == IMPORT_MARKER => markers += 1,
ThreadItemPayload::AgentMessage { text, .. } => {
actual.push(("assistant", text.as_str()))
}
_ => {}
}
}
@@ -37,7 +37,7 @@ use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
@@ -2650,12 +2650,12 @@ async fn user_approval_skips_async_guardian_without_changing_other_modes() -> Re
while tool_results.len() < 3 {
let completed: ItemCompletedNotification =
timeout(TIMEOUT, app_server.read_notification("item/completed")).await??;
if let ThreadItem::McpToolCall {
if let ThreadItemPayload::McpToolCall {
status,
result,
error,
..
} = completed.item
} = completed.item.payload
{
tool_results.push((status, result.map(|result| result.content), error));
}
@@ -13,13 +13,14 @@ use codex_app_server_protocol::ImageGenerationInputImage;
use codex_app_server_protocol::ImageGenerationItem;
use codex_app_server_protocol::ImageReference as V2ImageReference;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
@@ -161,14 +162,14 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model(
)
.await??;
let ThreadItem::ImageGeneration(ImageGenerationItem {
let ThreadItemPayload::ImageGeneration(ImageGenerationItem {
status,
revised_prompt,
image,
transparent_background,
saved_path: Some(saved_path),
..
}) = completed.item
}) = completed.item.payload
else {
panic!("expected completed image generation item with saved path");
};
@@ -353,13 +354,13 @@ async fn transparent_image_preserves_output_metadata_and_persisted_history(
)
.await??;
let thread_id = completed.thread_id.clone();
let ThreadItem::ImageGeneration(ImageGenerationItem {
let ThreadItemPayload::ImageGeneration(ImageGenerationItem {
status,
image,
transparent_background,
input_images,
..
}) = completed.item
}) = completed.item.payload
else {
panic!("expected completed image-generation item");
};
@@ -390,9 +391,9 @@ async fn transparent_image_preserves_output_metadata_and_persisted_history(
let persisted_image = thread
.turns
.iter()
.flat_map(|turn| turn.items.iter())
.flat_map(Turn::item_payloads)
.find_map(|item| match item {
ThreadItem::ImageGeneration(item) => Some(item),
ThreadItemPayload::ImageGeneration(item) => Some(item),
_ => None,
})
.context("persisted legacy history should contain the generated image")?;
@@ -410,9 +411,9 @@ async fn transparent_image_preserves_output_metadata_and_persisted_history(
let resumed_image = thread
.turns
.iter()
.flat_map(|turn| turn.items.iter())
.flat_map(Turn::item_payloads)
.find_map(|item| match item {
ThreadItem::ImageGeneration(item) => Some(item),
ThreadItemPayload::ImageGeneration(item) => Some(item),
_ => None,
})
.context("resumed legacy history should contain the generated image")?;
@@ -471,7 +472,7 @@ async fn automatic_image_background_preserves_unknown_transparency() -> Result<(
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let ThreadItem::ImageGeneration(image) = completed.item else {
let ThreadItemPayload::ImageGeneration(image) = completed.item.payload else {
panic!("expected completed image-generation item");
};
assert_eq!(image.transparent_background, None);
@@ -630,8 +631,8 @@ async fn standalone_image_generation_failure_emits_terminal_item(
)
.await??;
assert_eq!(
completed.item,
ThreadItem::ImageGeneration(ImageGenerationItem {
completed.item.payload,
ThreadItemPayload::ImageGeneration(ImageGenerationItem {
id: call_id.to_string(),
status: "failed".to_string(),
revised_prompt: Some("paint a blue whale".to_string()),
@@ -744,7 +745,7 @@ async fn image_generation_usage_limit_preserves_correlated_failure_metadata() ->
)
.await??;
let thread_id = completed.thread_id.clone();
let ThreadItem::ImageGeneration(image) = completed.item else {
let ThreadItemPayload::ImageGeneration(image) = completed.item.payload else {
panic!("expected failed image-generation item");
};
assert_eq!(image.status, "failed");
@@ -772,9 +773,9 @@ async fn image_generation_usage_limit_preserves_correlated_failure_metadata() ->
let persisted_failure = thread
.turns
.iter()
.flat_map(|turn| turn.items.iter())
.flat_map(Turn::item_payloads)
.find_map(|item| match item {
ThreadItem::ImageGeneration(item) => item.failure.as_ref(),
ThreadItemPayload::ImageGeneration(item) => item.failure.as_ref(),
_ => None,
});
assert_eq!(
@@ -796,9 +797,9 @@ async fn image_generation_usage_limit_preserves_correlated_failure_metadata() ->
let resumed_failure = thread
.turns
.iter()
.flat_map(|turn| turn.items.iter())
.flat_map(Turn::item_payloads)
.find_map(|item| match item {
ThreadItem::ImageGeneration(item) => item.failure.as_ref(),
ThreadItemPayload::ImageGeneration(item) => item.failure.as_ref(),
_ => None,
});
assert_eq!(
@@ -1157,7 +1158,7 @@ async fn run_image_edit_test(
wait_for_image_generation_completed(&mut mcp),
)
.await??;
let ThreadItem::ImageGeneration(completed_image) = completed.item else {
let ThreadItemPayload::ImageGeneration(completed_image) = completed.item.payload else {
panic!("expected completed image-generation item");
};
timeout(
@@ -1240,7 +1241,10 @@ async fn wait_for_image_generation_completed(
) -> Result<ItemCompletedNotification> {
loop {
let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?;
if matches!(&completed.item, ThreadItem::ImageGeneration(_)) {
if matches!(
&completed.item.payload,
ThreadItemPayload::ImageGeneration(_)
) {
return Ok(completed);
}
}
+17 -15
View File
@@ -29,13 +29,14 @@ use codex_app_server_protocol::McpToolCallStatus;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SelectedCapabilityRoot;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnEnvironmentParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
@@ -388,10 +389,10 @@ async fn model_mcp_tool_call_uses_session_client_extensions() -> Result<()> {
.await?;
let completed = wait_for_mcp_tool_call_completed(&mut mcp, call_id).await?;
let ThreadItem::McpToolCall {
let ThreadItemPayload::McpToolCall {
result: Some(result),
..
} = completed.item
} = completed.item.payload
else {
panic!("expected completed MCP tool call item");
};
@@ -1053,7 +1054,7 @@ async fn mcp_tool_call_completion_notification_contains_truncated_large_result()
let completed = wait_for_mcp_tool_call_completed(&mut mcp, call_id).await?;
assert_eq!(completed.turn_id, turn.id);
let ThreadItem::McpToolCall {
let ThreadItemPayload::McpToolCall {
id,
server,
tool,
@@ -1061,7 +1062,7 @@ async fn mcp_tool_call_completion_notification_contains_truncated_large_result()
result: Some(result),
error,
..
} = completed.item
} = completed.item.payload
else {
panic!("expected completed MCP tool call item");
};
@@ -1081,7 +1082,7 @@ async fn mcp_tool_call_completion_notification_contains_truncated_large_result()
assert!(text.contains("truncated"));
assert!(text.len() < DEFAULT_OUTPUT_BYTES_CAP + 1024);
let serialized_item = serde_json::to_string(&ThreadItem::McpToolCall {
let serialized_item = serde_json::to_string(&ThreadItemPayload::McpToolCall {
id,
server,
tool,
@@ -1193,7 +1194,7 @@ async fn mcp_tool_call_hint_survives_mid_call_thread_read_and_resume() -> Result
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
let expected_item = ThreadItem::McpToolCall {
let expected_item = ThreadItemPayload::McpToolCall {
id: call_id.to_string(),
server: TEST_SERVER_NAME.to_string(),
tool: TEST_TOOL_NAME.to_string(),
@@ -1211,8 +1212,8 @@ async fn mcp_tool_call_hint_survives_mid_call_thread_read_and_resume() -> Result
let resumed_item = resumed_thread
.turns
.iter()
.flat_map(|turn| &turn.items)
.find(|item| matches!(item, ThreadItem::McpToolCall { id, .. } if id == call_id))
.flat_map(Turn::item_payloads)
.find(|item| matches!(item, ThreadItemPayload::McpToolCall { id, .. } if id == call_id))
.expect("resumed thread should include the in-progress MCP tool call");
assert_eq!(resumed_item, &expected_item);
@@ -1228,11 +1229,11 @@ async fn mcp_tool_call_hint_survives_mid_call_thread_read_and_resume() -> Result
let completed = wait_for_mcp_tool_call_completed(&mut mcp, call_id).await?;
assert_eq!(completed.turn_id, turn.id);
let ThreadItem::McpToolCall {
let ThreadItemPayload::McpToolCall {
status,
read_only_hint,
..
} = &completed.item
} = &completed.item.payload
else {
panic!("expected the completed MCP tool call item");
};
@@ -1258,10 +1259,10 @@ async fn mcp_tool_call_hint_survives_mid_call_thread_read_and_resume() -> Result
let persisted_item = completed_read
.turns
.iter()
.flat_map(|turn| &turn.items)
.find(|item| matches!(item, ThreadItem::McpToolCall { id, .. } if id == call_id))
.flat_map(Turn::item_payloads)
.find(|item| matches!(item, ThreadItemPayload::McpToolCall { id, .. } if id == call_id))
.expect("completed thread history should include the persisted MCP tool call");
assert_eq!(persisted_item, &completed.item);
assert_eq!(persisted_item, &completed.item.payload);
mcp_server_handle.abort();
let _ = mcp_server_handle.await;
@@ -1608,7 +1609,8 @@ async fn wait_for_mcp_tool_call_completed(
mcp.read_notification("item/completed"),
)
.await??;
if matches!(&completed.item, ThreadItem::McpToolCall { id, .. } if id == call_id) {
if matches!(&completed.item.payload, ThreadItemPayload::McpToolCall { id, .. } if id == call_id)
{
return Ok(completed);
}
}
@@ -9,7 +9,7 @@ use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::PlanDeltaNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartParams;
@@ -63,7 +63,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> {
assert_eq!(turn_completed.turn.id, turn.id);
assert_eq!(turn_completed.turn.status, TurnStatus::Completed);
let expected_plan = ThreadItem::Plan {
let expected_plan = ThreadItemPayload::Plan {
id: format!("{}-plan", turn.id),
text: "# Final plan\n- first\n- second\n".to_string(),
};
@@ -81,7 +81,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> {
let plan_items = completed_items
.iter()
.filter_map(|item| match item {
ThreadItem::Plan { .. } => Some(item.clone()),
ThreadItemPayload::Plan { .. } => Some(item.clone()),
_ => None,
})
.collect::<Vec<_>>();
@@ -89,7 +89,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> {
assert!(
completed_items
.iter()
.any(|item| matches!(item, ThreadItem::AgentMessage { .. })),
.any(|item| matches!(item, ThreadItemPayload::AgentMessage { .. })),
"agent message items should still be emitted alongside the plan item"
);
@@ -123,7 +123,7 @@ async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()>
let has_plan_item = completed_items
.iter()
.any(|item| matches!(item, ThreadItem::Plan { .. }));
.any(|item| matches!(item, ThreadItemPayload::Plan { .. }));
assert!(!has_plan_item);
assert!(plan_deltas.is_empty());
@@ -168,8 +168,8 @@ async fn start_plan_mode_turn(mcp: &mut TestAppServer) -> Result<codex_app_serve
async fn collect_turn_notifications(
mcp: &mut TestAppServer,
) -> Result<(
Vec<ThreadItem>,
Vec<ThreadItem>,
Vec<ThreadItemPayload>,
Vec<ThreadItemPayload>,
Vec<PlanDeltaNotification>,
TurnCompletedNotification,
)> {
@@ -188,14 +188,14 @@ async fn collect_turn_notifications(
.params
.ok_or_else(|| anyhow!("item/started notifications must include params"))?;
let payload: ItemStartedNotification = serde_json::from_value(params)?;
started_items.push(payload.item);
started_items.push(payload.item.payload);
}
"item/completed" => {
let params = notification
.params
.ok_or_else(|| anyhow!("item/completed notifications must include params"))?;
let payload: ItemCompletedNotification = serde_json::from_value(params)?;
completed_items.push(payload.item);
completed_items.push(payload.item.payload);
}
"item/plan/delta" => {
let params = notification
@@ -14,7 +14,7 @@ use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadItemsListParams;
use codex_app_server_protocol::ThreadItemsListResponse;
use codex_app_server_protocol::ThreadReadParams;
@@ -56,6 +56,7 @@ use codex_app_server_protocol::ThreadTimelineListParams;
use codex_app_server_protocol::ThreadTimelineListResponse;
use codex_app_server_protocol::ThreadTurnsListParams;
use codex_app_server_protocol::ThreadTurnsListResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
@@ -1285,8 +1286,8 @@ async fn realtime_timeline_splits_accepted_steering_and_persists_promoted_artifa
.read_notification::<ItemCompletedNotification>("item/completed")
.await?;
assert!(matches!(
initial_message.item,
ThreadItem::UserMessage { .. }
initial_message.item.payload,
ThreadItemPayload::UserMessage { .. }
));
harness
@@ -1397,8 +1398,8 @@ async fn realtime_timeline_splits_accepted_steering_and_persists_promoted_artifa
item,
..
} if matches!(
item.as_ref(),
ThreadItem::UserMessage { client_id, .. }
&item.payload,
ThreadItemPayload::UserMessage { client_id, .. }
if client_id.as_deref() == Some("accepted-steer")
)
)
@@ -1411,7 +1412,7 @@ async fn realtime_timeline_splits_accepted_steering_and_persists_promoted_artifa
.iter()
.filter_map(|entry| match entry {
ThreadTimelineEntry::Item { item, .. }
if matches!(item.as_ref(), ThreadItem::AgentMessage { id, .. } if id == "promoted-message") => Some("artifact"),
if matches!(&item.payload, ThreadItemPayload::AgentMessage { id, .. } if id == "promoted-message") => Some("artifact"),
ThreadTimelineEntry::Realtime {
item: ThreadRealtimeItem {
content: ThreadRealtimeItemContent::BemItemPromoted { item_id, .. },
@@ -1883,7 +1884,7 @@ async fn realtime_conversation_stop_emits_closed_notification(
let items: ThreadItemsListResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request)).await??;
let tails = items.data.iter().filter(|entry| matches!(
&entry.item, ThreadItem::UserMessage { content, .. }
&entry.item.payload, ThreadItemPayload::UserMessage { content, .. }
if content.iter().any(|part| matches!(part, V2UserInput::Text { text, .. }
if text.contains("<source>transcript_tail_flush</source>") && text.contains("user: final words")))
)).collect::<Vec<_>>();
@@ -1913,8 +1914,8 @@ async fn realtime_conversation_stop_emits_closed_notification(
.await?;
let history: ThreadReadResponse =
timeout(DEFAULT_TIMEOUT, mcp.read_response(request)).await??;
assert_eq!(history.thread.turns.iter().flat_map(|turn| &turn.items).filter(|item| matches!(
item, ThreadItem::UserMessage { content, .. }
assert_eq!(history.thread.turns.iter().flat_map(Turn::item_payloads).filter(|item| matches!(
item, ThreadItemPayload::UserMessage { content, .. }
if content.iter().any(|part| matches!(part, V2UserInput::Text { text, .. }
if text.contains("<source>transcript_tail_flush</source>") && text.contains("user: final words")))
)).count(), 1);
@@ -3963,7 +3964,8 @@ async fn websocket_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Resul
// Phase 2: observe the delegated background agent turn executing the requested shell command.
let started_command = wait_for_started_command_execution(&mut harness.mcp).await?;
let ThreadItem::CommandExecution { id, status, .. } = started_command.item else {
let ThreadItemPayload::CommandExecution { id, status, .. } = started_command.item.payload
else {
unreachable!("helper returns command execution items");
};
assert_eq!(
@@ -3972,12 +3974,12 @@ async fn websocket_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Resul
);
let completed_command = wait_for_completed_command_execution(&mut harness.mcp).await?;
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
id,
status,
aggregated_output,
..
} = completed_command.item
} = completed_command.item.payload
else {
unreachable!("helper returns command execution items");
};
@@ -4298,7 +4300,7 @@ async fn wait_for_started_command_execution(
) -> Result<ItemStartedNotification> {
loop {
let started = read_notification::<ItemStartedNotification>(mcp, "item/started").await?;
if let ThreadItem::CommandExecution { .. } = &started.item {
if let ThreadItemPayload::CommandExecution { .. } = &started.item.payload {
return Ok(started);
}
}
@@ -4310,7 +4312,7 @@ async fn wait_for_completed_command_execution(
loop {
let completed =
read_notification::<ItemCompletedNotification>(mcp, "item/completed").await?;
if let ThreadItem::CommandExecution { .. } = &completed.item {
if let ThreadItemPayload::CommandExecution { .. } = &completed.item.payload {
return Ok(completed);
}
}
+12 -11
View File
@@ -19,6 +19,7 @@ use codex_app_server_protocol::ReviewTarget;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStartedNotification;
@@ -148,14 +149,14 @@ async fn review_start_runs_review_turn_and_emits_code_review_item(
assert_eq!(turn.items_view, TurnItemsView::NotLoaded);
assert_eq!(
turn.items,
vec![ThreadItem::UserMessage {
[ThreadItem::untimed(ThreadItemPayload::UserMessage {
id: turn_id.clone(),
client_id: None,
content: vec![V2UserInput::Text {
text: "commit 1234567: Tidy UI colors".to_string(),
text_elements: Vec::new(),
}],
}]
})]
);
// Confirm we see the EnteredReviewMode marker on the main thread.
@@ -163,8 +164,8 @@ async fn review_start_runs_review_turn_and_emits_code_review_item(
for _ in 0..10 {
let started: ItemStartedNotification =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("item/started")).await??;
match started.item {
ThreadItem::EnteredReviewMode { review, .. } => {
match started.item.payload {
ThreadItemPayload::EnteredReviewMode { review, .. } => {
assert_eq!(started.turn_id, turn_id);
assert_eq!(review, "commit 1234567: Tidy UI colors");
saw_entered_review_mode = true;
@@ -187,8 +188,8 @@ async fn review_start_runs_review_turn_and_emits_code_review_item(
mcp.read_notification("item/completed"),
)
.await??;
match completed.item {
ThreadItem::ExitedReviewMode { review, .. } => {
match completed.item.payload {
ThreadItemPayload::ExitedReviewMode { review, .. } => {
assert_eq!(completed.turn_id, turn_id);
review_body = Some(review);
break;
@@ -257,14 +258,14 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() ->
assert_eq!(turn.items_view, TurnItemsView::NotLoaded);
assert_eq!(
turn.items,
vec![ThreadItem::UserMessage {
[ThreadItem::untimed(ThreadItemPayload::UserMessage {
id: turn_id.clone(),
client_id: None,
content: vec![V2UserInput::Text {
text: "commit 1234567: Check review approvals".to_string(),
text_elements: Vec::new(),
}],
}]
})]
);
let server_req = timeout(
@@ -282,7 +283,7 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() ->
for _ in 0..10 {
let started: ItemStartedNotification =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("item/started")).await??;
if let ThreadItem::CommandExecution { id, .. } = started.item {
if let ThreadItemPayload::CommandExecution { id, .. } = started.item.payload {
command_item_id = Some(id);
break;
}
@@ -437,14 +438,14 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<(
assert_eq!(turn.items_view, TurnItemsView::NotLoaded);
assert_eq!(
turn.items,
vec![ThreadItem::UserMessage {
[ThreadItem::untimed(ThreadItemPayload::UserMessage {
id: turn.id.clone(),
client_id: None,
content: vec![V2UserInput::Text {
text: expected_prompt.clone(),
text_elements: Vec::new(),
}],
}]
})]
);
assert_ne!(
review_thread_id, thread_id,
@@ -11,7 +11,7 @@ use codex_app_server_protocol::ModelRerouteReason;
use codex_app_server_protocol::ModelReroutedNotification;
use codex_app_server_protocol::ModelVerification;
use codex_app_server_protocol::ModelVerificationNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnModerationMetadataNotification;
@@ -365,14 +365,14 @@ async fn collect_turn_notifications_and_validate_no_warning_item(
anyhow::anyhow!("item/started notifications must include params")
})?;
let payload: ItemStartedNotification = serde_json::from_value(params)?;
assert!(!is_warning_user_message_item(&payload.item));
assert!(!is_warning_user_message_item(&payload.item.payload));
}
"item/completed" => {
let params = notification.params.ok_or_else(|| {
anyhow::anyhow!("item/completed notifications must include params")
})?;
let payload: ItemCompletedNotification = serde_json::from_value(params)?;
assert!(!is_warning_user_message_item(&payload.item));
assert!(!is_warning_user_message_item(&payload.item.payload));
}
"turn/completed" => {
return rerouted.ok_or_else(|| {
@@ -413,14 +413,14 @@ async fn collect_model_verification_notifications_and_validate_no_warning_item(
anyhow::anyhow!("item/started notifications must include params")
})?;
let payload: ItemStartedNotification = serde_json::from_value(params)?;
assert!(!is_warning_user_message_item(&payload.item));
assert!(!is_warning_user_message_item(&payload.item.payload));
}
"item/completed" => {
let params = notification.params.ok_or_else(|| {
anyhow::anyhow!("item/completed notifications must include params")
})?;
let payload: ItemCompletedNotification = serde_json::from_value(params)?;
assert!(!is_warning_user_message_item(&payload.item));
assert!(!is_warning_user_message_item(&payload.item.payload));
}
"turn/completed" => {
let verification = verification.ok_or_else(|| {
@@ -468,8 +468,8 @@ async fn collect_cyber_policy_error_and_validate_no_reroute(
}
}
fn warning_text_from_item(item: &ThreadItem) -> Option<&str> {
let ThreadItem::UserMessage { content, .. } = item else {
fn warning_text_from_item(item: &ThreadItemPayload) -> Option<&str> {
let ThreadItemPayload::UserMessage { content, .. } = item else {
return None;
};
@@ -479,7 +479,7 @@ fn warning_text_from_item(item: &ThreadItem) -> Option<&str> {
})
}
fn is_warning_user_message_item(item: &ThreadItem) -> bool {
fn is_warning_user_message_item(item: &ThreadItemPayload) -> bool {
warning_text_from_item(item).is_some()
}
+17 -16
View File
@@ -8,7 +8,7 @@ use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SleepItem;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnCompletedNotification;
@@ -286,28 +286,29 @@ clock_source = "external"
);
}
let expected_item = ThreadItem::Sleep(SleepItem {
let expected_item = ThreadItemPayload::Sleep(SleepItem {
id: CALL_ID.to_string(),
duration_ms: DURATION_MS,
});
assert!(completed.completed_at_ms >= started.started_at_ms);
assert_eq!(
started,
ItemStartedNotification {
item: expected_item.clone(),
thread_id: thread.id.clone(),
turn_id: turn.id.clone(),
started_at_ms: started.started_at_ms,
}
ItemStartedNotification::new(
thread.id.clone(),
turn.id.clone(),
expected_item.clone(),
started.started_at_ms
)
);
assert_eq!(
completed,
ItemCompletedNotification {
item: expected_item,
thread_id: thread.id,
turn_id: turn.id,
completed_at_ms: completed.completed_at_ms,
}
ItemCompletedNotification::new(
thread.id,
turn.id,
expected_item,
Some(started.started_at_ms),
completed.completed_at_ms
)
);
Ok(())
@@ -320,7 +321,7 @@ async fn wait_for_sleep_started(
loop {
let started: ItemStartedNotification =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("item/started")).await??;
if matches!(&started.item, ThreadItem::Sleep(item) if item.id == call_id) {
if matches!(&started.item.payload, ThreadItemPayload::Sleep(item) if item.id == call_id) {
return Ok(started);
}
}
@@ -336,7 +337,7 @@ async fn wait_for_sleep_completed(
mcp.read_notification("item/completed"),
)
.await??;
if matches!(&completed.item, ThreadItem::Sleep(item) if item.id == call_id) {
if matches!(&completed.item.payload, ThreadItemPayload::Sleep(item) if item.id == call_id) {
return Ok(completed);
}
}
@@ -37,7 +37,7 @@ use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadReadParams;
@@ -250,8 +250,8 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
let turn = &thread.turns[0];
assert_eq!(turn.status, TurnStatus::Interrupted);
assert_eq!(turn.items.len(), 1, "expected user message item");
match &turn.items[0] {
ThreadItem::UserMessage { content, .. } => {
match &turn.items[0].payload {
ThreadItemPayload::UserMessage { content, .. } => {
assert_eq!(
content,
&vec![UserInput::Text {
@@ -2102,8 +2102,8 @@ async fn assert_thread_fork_freezes_active_paginated_turn_as_interrupted(
assert_eq!(turns[0].status, TurnStatus::Interrupted);
assert_eq!(turns[0].items.len(), 1);
assert!(matches!(
&turns[0].items[0],
ThreadItem::UserMessage { id, .. } if id == "before-fork"
&turns[0].items[0].payload,
ThreadItemPayload::UserMessage { id, .. } if id == "before-fork"
));
let search: ThreadSearchOccurrencesResponse = mcp
@@ -2486,8 +2486,8 @@ async fn assert_thread_fork_ephemeral_remains_pathless_and_omits_listing(
let turn = &thread.turns[0];
assert_eq!(turn.status, TurnStatus::Completed);
assert_eq!(turn.items.len(), 1, "expected user message item");
match &turn.items[0] {
ThreadItem::UserMessage { content, .. } => {
match &turn.items[0].payload {
ThreadItemPayload::UserMessage { content, .. } => {
assert_eq!(
content,
&vec![UserInput::Text {
@@ -11,6 +11,7 @@ use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadInjectItemsResponse;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
@@ -330,10 +331,16 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history(
assert_eq!(turn.id, turn_started.turn.id);
assert_eq!(turn_started.turn.status, TurnStatus::InProgress);
assert_eq!(item_completed.turn_id, turn_started.turn.id);
assert_eq!(item_started.item, item_completed.item);
assert_eq!(
item_completed.item,
ThreadItem {
completed_at_ms: Some(item_completed.completed_at_ms),
..item_started.item
}
);
assert!(matches!(
&item_completed.item,
ThreadItem::FunctionCallOutput {
&item_completed.item.payload,
ThreadItemPayload::FunctionCallOutput {
name,
namespace,
output: FunctionCallOutputBody::Text(output),
@@ -18,7 +18,7 @@ use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::QueuedSubmission;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
@@ -386,9 +386,9 @@ async fn idle_queue_dispatch_preserves_client_id() -> Result<()> {
);
let started: ItemStartedNotification =
timeout(READ_TIMEOUT, app.read_notification("item/started")).await??;
let ThreadItem::UserMessage {
let ThreadItemPayload::UserMessage {
client_id, content, ..
} = started.item
} = started.item.payload
else {
anyhow::bail!("queued turn did not begin with its user message");
};
@@ -538,9 +538,9 @@ async fn cold_thread_resume_dispatches_a_persisted_queued_submission() -> Result
let started: ItemStartedNotification =
timeout(READ_TIMEOUT, resumed.read_notification("item/started")).await??;
let ThreadItem::UserMessage {
let ThreadItemPayload::UserMessage {
client_id, content, ..
} = started.item
} = started.item.payload
else {
anyhow::bail!("cold resume did not start the persisted queued user message");
};
@@ -747,14 +747,14 @@ async fn queue_start_while_active_returns_busy_and_preserves_the_queue() -> Resu
let started_item: ItemStartedNotification =
timeout(READ_TIMEOUT, app.read_notification("item/started")).await??;
if matches!(
&started_item.item,
ThreadItem::UserMessage { client_id, .. }
&started_item.item.payload,
ThreadItemPayload::UserMessage { client_id, .. }
if client_id.as_deref() == Some("active-queued-client-id")
) {
break started_item;
}
};
let ThreadItem::UserMessage { client_id, .. } = started_item.item else {
let ThreadItemPayload::UserMessage { client_id, .. } = started_item.item.payload else {
anyhow::bail!("queued message did not start after the active turn completed");
};
assert_eq!(client_id.as_deref(), Some("active-queued-client-id"));
@@ -823,8 +823,8 @@ async fn queue_start_without_id_starts_the_head_when_idle() -> Result<()> {
let started_item: ItemStartedNotification =
timeout(READ_TIMEOUT, app.read_notification("item/started")).await??;
if matches!(
&started_item.item,
ThreadItem::UserMessage { client_id, .. }
&started_item.item.payload,
ThreadItemPayload::UserMessage { client_id, .. }
if client_id.as_deref() == Some(first.client_user_message_id.as_str())
) {
break started_item;
@@ -25,8 +25,8 @@ use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemEntry;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadItemsListAnchor;
use codex_app_server_protocol::ThreadItemsListCursor;
use codex_app_server_protocol::ThreadItemsListParams;
@@ -222,8 +222,8 @@ async fn thread_read_can_include_turns() -> Result<()> {
assert_eq!(turn.root_turn_id, None);
assert_eq!(turn.items_view, TurnItemsView::Full);
assert_eq!(turn.items.len(), 1, "expected user message item");
match &turn.items[0] {
ThreadItem::UserMessage { content, .. } => {
match &turn.items[0].payload {
ThreadItemPayload::UserMessage { content, .. } => {
assert_eq!(
content,
&vec![UserInput::Text {
@@ -297,7 +297,8 @@ async fn thread_read_preserves_file_id_and_root_from_completed_turn(
thread: loaded_thread,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
let ThreadItem::UserMessage { content, .. } = &loaded_thread.turns[0].items[0] else {
let ThreadItemPayload::UserMessage { content, .. } = &loaded_thread.turns[0].items[0].payload
else {
panic!("expected completed turn to start with a user message");
};
assert_eq!(content, &vec![image.clone()]);
@@ -323,7 +324,9 @@ async fn thread_read_preserves_file_id_and_root_from_completed_turn(
thread: restarted_thread,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
let ThreadItem::UserMessage { content, .. } = &restarted_thread.turns[0].items[0] else {
let ThreadItemPayload::UserMessage { content, .. } =
&restarted_thread.turns[0].items[0].payload
else {
panic!("expected persisted turn to start with a user message");
};
assert_eq!(content, &vec![image]);
@@ -500,6 +503,7 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> {
data,
next_cursor,
backwards_cursor,
..
} = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
assert_eq!(turn_user_texts(&data), vec!["third", "second"]);
assert!(
@@ -1062,7 +1066,13 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa
let ThreadReadResponse { thread, .. } = serde_json::from_value(result)?;
assert_eq!(turn_user_texts(&thread.turns), vec!["history from store"]);
let [ThreadItem::UserMessage { content, .. }] = thread.turns[0].items.as_slice() else {
let [
codex_app_server_protocol::ThreadItem {
payload: ThreadItemPayload::UserMessage { content, .. },
..
},
] = thread.turns[0].items.as_slice()
else {
panic!("expected one user message item");
};
assert_eq!(
@@ -1834,17 +1844,17 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
id: "turn-1".to_string(),
root_turn_id: None,
items: vec![
ThreadItem::UserMessage {
ThreadItemPayload::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: Vec::new(),
},
ThreadItem::UserMessage {
ThreadItemPayload::UserMessage {
id: "steer-1".to_string(),
client_id: Some("updated-steer".to_string()),
content: Vec::new(),
},
ThreadItem::AgentMessage {
ThreadItemPayload::AgentMessage {
id: "agent-1".to_string(),
text: "first".to_string(),
phase: None,
@@ -1852,7 +1862,14 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
delivery: None,
questions: None,
},
],
]
.into_iter()
.map(|payload| codex_app_server_protocol::ThreadItem {
payload,
started_at_ms: Some(0),
completed_at_ms: Some(1),
})
.collect(),
items_view: TurnItemsView::Full,
status: TurnStatus::Completed,
error: None,
@@ -1863,11 +1880,18 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
let expected_turn_2_full = Turn {
id: "turn-2".to_string(),
root_turn_id: None,
items: vec![ThreadItem::UserMessage {
items: vec![ThreadItemPayload::UserMessage {
id: "user-2".to_string(),
client_id: None,
content: Vec::new(),
}],
}]
.into_iter()
.map(|payload| codex_app_server_protocol::ThreadItem {
payload,
started_at_ms: Some(0),
completed_at_ms: Some(1),
})
.collect(),
items_view: TurnItemsView::Full,
status: TurnStatus::Interrupted,
error: None,
@@ -2029,12 +2053,12 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
id: "turn-1".to_string(),
root_turn_id: None,
items: vec![
ThreadItem::UserMessage {
ThreadItemPayload::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: Vec::new(),
},
ThreadItem::AgentMessage {
ThreadItemPayload::AgentMessage {
id: "agent-1".to_string(),
text: "first".to_string(),
phase: None,
@@ -2042,7 +2066,14 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
delivery: None,
questions: None,
},
],
]
.into_iter()
.map(|payload| codex_app_server_protocol::ThreadItem {
payload,
started_at_ms: Some(0),
completed_at_ms: Some(1)
})
.collect(),
items_view: TurnItemsView::Summary,
status: TurnStatus::Completed,
error: None,
@@ -2051,6 +2082,24 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
duration_ms: Some(10_000),
}]
);
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread_id.to_string(),
exclude_turns: true,
initial_turns_page: Some(ThreadResumeInitialTurnsPageParams {
limit: Some(1),
sort_direction: Some(SortDirection::Asc),
items_view: Some(TurnItemsView::Summary),
}),
..Default::default()
})
.await?;
let resumed: ThreadResumeResponse =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
assert_eq!(
resumed.initial_turns_page.expect("summary page").data,
first_page.data
);
let next_cursor = first_page.next_cursor.expect("next turn cursor");
let second_page = read_turns_page(
&mut mcp,
@@ -2100,10 +2149,14 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
first_items_page.data,
vec![ThreadItemEntry {
turn_id: "turn-1".to_string(),
item: ThreadItem::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: Vec::new(),
item: codex_app_server_protocol::ThreadItem {
payload: ThreadItemPayload::UserMessage {
id: "user-1".to_string(),
client_id: None,
content: Vec::new(),
},
started_at_ms: Some(0),
completed_at_ms: Some(1)
},
started_at_ms: Some(0),
completed_at_ms: Some(1),
@@ -2232,23 +2285,31 @@ async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items(
vec![
ThreadItemEntry {
turn_id: "turn-1".to_string(),
item: ThreadItem::UserMessage {
id: "steer-1".to_string(),
client_id: Some("updated-steer".to_string()),
content: Vec::new(),
item: codex_app_server_protocol::ThreadItem {
payload: ThreadItemPayload::UserMessage {
id: "steer-1".to_string(),
client_id: Some("updated-steer".to_string()),
content: Vec::new(),
},
started_at_ms: Some(0),
completed_at_ms: Some(1)
},
started_at_ms: Some(0),
completed_at_ms: Some(1),
},
ThreadItemEntry {
turn_id: "turn-1".to_string(),
item: ThreadItem::AgentMessage {
id: "agent-1".to_string(),
text: "first".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
item: codex_app_server_protocol::ThreadItem {
payload: ThreadItemPayload::AgentMessage {
id: "agent-1".to_string(),
text: "first".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
started_at_ms: Some(0),
completed_at_ms: Some(1)
},
started_at_ms: Some(0),
completed_at_ms: Some(1),
@@ -2607,8 +2668,8 @@ fn paginated_completed_item(
fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> {
turns
.iter()
.filter_map(|turn| match turn.items.first()? {
ThreadItem::UserMessage { content, .. } => match content.first()? {
.filter_map(|turn| match &turn.items.first()?.payload {
ThreadItemPayload::UserMessage { content, .. } => match content.first()? {
UserInput::Text { text, .. } => Some(text.as_str()),
UserInput::Image { .. }
| UserInput::LocalImage { .. }
@@ -2625,9 +2686,9 @@ fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> {
fn turn_agent_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> {
turns
.iter()
.flat_map(|turn| &turn.items)
.flat_map(Turn::item_payloads)
.filter_map(|item| match item {
ThreadItem::AgentMessage { text, .. } => Some(text.as_str()),
ThreadItemPayload::AgentMessage { text, .. } => Some(text.as_str()),
_ => None,
})
.collect()
@@ -52,7 +52,7 @@ use codex_app_server_protocol::ThreadGoalGetResponse;
use codex_app_server_protocol::ThreadGoalSetResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
@@ -133,6 +133,7 @@ use core_test_support::streaming_sse::StreamingSseChunk;
use core_test_support::streaming_sse::start_streaming_sse_server;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::HashMap;
use std::fs::FileTimes;
use std::io::Write;
use std::path::Path;
@@ -2736,8 +2737,8 @@ async fn thread_resume_returns_rollout_history() -> Result<()> {
let turn = &thread.turns[0];
assert_eq!(turn.status, TurnStatus::Completed);
assert_eq!(turn.items.len(), 1, "expected user message item");
match &turn.items[0] {
ThreadItem::UserMessage { content, .. } => {
match &turn.items[0].payload {
ThreadItemPayload::UserMessage { content, .. } => {
assert_eq!(
content,
&vec![UserInput::Text {
@@ -2772,16 +2773,16 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<(
let remote_mcp_item = remote_turn
.items
.iter()
.find(|item| matches!(item, ThreadItem::McpToolCall { .. }))
.find(|item| matches!(&item.payload, ThreadItemPayload::McpToolCall { .. }))
.expect("remote resume should include redacted MCP item");
let ThreadItem::McpToolCall {
let ThreadItemPayload::McpToolCall {
arguments,
app_context,
read_only_hint,
result,
error,
..
} = remote_mcp_item
} = &remote_mcp_item.payload
else {
unreachable!("matched MCP item");
};
@@ -2812,7 +2813,7 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<(
!remote_turn
.items
.iter()
.any(|item| matches!(item, ThreadItem::ImageGeneration(_))),
.any(|item| matches!(&item.payload, ThreadItemPayload::ImageGeneration(_))),
"remote resume should drop image generation items for {client_name}"
);
}
@@ -2827,15 +2828,15 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<(
let normal_mcp_item = normal_turn
.items
.iter()
.find(|item| matches!(item, ThreadItem::McpToolCall { .. }))
.find(|item| matches!(&item.payload, ThreadItemPayload::McpToolCall { .. }))
.expect("normal resume should include MCP item");
let ThreadItem::McpToolCall {
let ThreadItemPayload::McpToolCall {
arguments,
app_context,
read_only_hint,
result,
..
} = normal_mcp_item
} = &normal_mcp_item.payload
else {
unreachable!("matched MCP item");
};
@@ -2866,8 +2867,8 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<(
assert_eq!(result.meta, Some(json!({"secret":"meta"})));
assert!(
normal_turn.items.iter().any(|item| matches!(
item,
ThreadItem::ImageGeneration(item)
&item.payload,
ThreadItemPayload::ImageGeneration(item)
if matches!(
&item.image,
GeneratedImageRef::Inline { result }
@@ -5364,6 +5365,8 @@ async fn thread_resume_rejoins_running_paginated_thread_with_initial_page() -> R
gate: None,
body: responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_message_item_added("progress", ""),
responses::ev_assistant_message("progress", "Checking"),
responses::ev_message_item_added("msg-2", ""),
]),
},
@@ -5438,17 +5441,17 @@ async fn thread_resume_rejoins_running_paginated_thread_with_initial_page() -> R
primary.read_stream_until_notification_message("turn/started"),
)
.await??;
// An assistant item starts after the user message has reached live history.
// The gated remainder of the response keeps the turn running during resume.
// Starting the gated reply means the prompt and the progress message have completed.
let mut message_starts = HashMap::new();
timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let started: ItemStartedNotification =
primary.read_notification("item/started").await?;
if started.turn_id == running_turn.id
&& matches!(started.item, ThreadItem::AgentMessage { .. })
{
assert_eq!(started.turn_id, running_turn.id);
if started.item.id() == "msg-2" {
return Ok::<(), anyhow::Error>(());
}
message_starts.insert(started.item.id().to_owned(), started.started_at_ms);
}
})
.await??;
@@ -5550,7 +5553,18 @@ async fn thread_resume_rejoins_running_paginated_thread_with_initial_page() -> R
page.data[0].items_view,
items_view.unwrap_or(TurnItemsView::Summary)
);
assert!(!page.data[0].items.is_empty());
assert!(matches!(
page.data[0].items.as_slice(),
[codex_app_server_protocol::ThreadItem { payload: ThreadItemPayload::UserMessage { .. }, .. }, codex_app_server_protocol::ThreadItem { payload: ThreadItemPayload::AgentMessage { text, .. }, .. }] if text == "Checking"
));
assert_eq!(
page.data[0]
.items
.iter()
.filter_map(|item| Some((item.id().to_owned(), item.started_at_ms?)))
.collect::<std::collections::HashMap<_, _>>(),
message_starts
);
if !exclude_turns {
let full_turn = resumed.thread.turns.last().expect("full active turn");
assert_eq!(full_turn.id, running_turn.id);
@@ -5899,14 +5913,14 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result<
.await?;
let started: ItemStartedNotification =
serde_json::from_value(notification.params.clone().expect("item/started params"))?;
if let ThreadItem::FileChange { .. } = started.item {
return Ok::<ThreadItem, anyhow::Error>(started.item);
if let ThreadItemPayload::FileChange { .. } = started.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(started.item.payload);
}
}
})
.await??;
let expected_readme_path = workspace.join("README.md");
let expected_file_change = ThreadItem::FileChange {
let expected_file_change = ThreadItemPayload::FileChange {
id: "patch-call".to_string(),
changes: vec![codex_app_server_protocol::FileUpdateChange {
path: expected_readme_path.to_string_lossy().into_owned(),
@@ -20,7 +20,7 @@ use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadShellCommandParams;
@@ -86,9 +86,9 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut
.await?;
let started = wait_for_command_execution_started(&mut mcp, /*expected_id*/ None).await?;
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
id, source, status, ..
} = &started.item
} = &started.item.payload
else {
unreachable!("helper returns command execution item");
};
@@ -103,14 +103,14 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut
);
let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?;
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
id,
source,
status,
aggregated_output,
exit_code,
..
} = &completed.item
} = &completed.item.payload
else {
unreachable!("helper returns command execution item");
};
@@ -279,9 +279,9 @@ async fn check_thread_shell_command_in_active_turn(timeout_ms: Option<i64>) -> R
let started: TurnStartedNotification = mcp.read_notification("turn/started").await?;
assert_eq!(started.turn.id, turn.id);
let agent_started = wait_for_command_execution_started(&mut mcp, Some("call-approve")).await?;
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
command, source, ..
} = &agent_started.item
} = &agent_started.item.payload
else {
unreachable!("helper returns command execution item");
};
@@ -315,19 +315,19 @@ async fn check_thread_shell_command_in_active_turn(timeout_ms: Option<i64>) -> R
wait_for_command_execution_started_by_source(&mut mcp, CommandExecutionSource::UserShell)
.await?;
assert_eq!(started.turn_id, turn.id);
let command_id = match &started.item {
ThreadItem::CommandExecution { id, .. } => id.clone(),
let command_id = match &started.item.payload {
ThreadItemPayload::CommandExecution { id, .. } => id.clone(),
_ => unreachable!("helper returns command execution item"),
};
let completed = wait_for_command_execution_completed(&mut mcp, Some(&command_id)).await?;
assert_eq!(completed.turn_id, turn.id);
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
source,
aggregated_output,
status,
exit_code,
..
} = &completed.item
} = &completed.item.payload
else {
unreachable!("helper returns command execution item");
};
@@ -443,12 +443,12 @@ async fn thread_shell_command_honors_optional_timeout() -> Result<()> {
let completed =
wait_for_command_execution_completed(&mut mcp, /*expected_id*/ None).await?;
assert_eq!(completed.turn_id, started.turn.id);
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
status,
exit_code,
aggregated_output,
..
} = completed.item
} = completed.item.payload
else {
unreachable!("helper returns command execution item");
};
@@ -544,11 +544,11 @@ fn current_shell_slow_command() -> Result<(String, String)> {
Ok((format!("{command}{suffix}"), expected_output))
}
fn assert_no_command_executions(items: &[ThreadItem], context: &str) {
fn assert_no_command_executions(items: &[codex_app_server_protocol::ThreadItem], context: &str) {
assert!(
items
.iter()
.all(|item| !matches!(item, ThreadItem::CommandExecution { .. })),
.all(|entry| !matches!(&entry.payload, ThreadItemPayload::CommandExecution { .. })),
"{context} should always exclude command executions from returned turns"
);
}
@@ -577,7 +577,7 @@ async fn wait_for_command_execution_started(
) -> Result<ItemStartedNotification> {
loop {
let started: ItemStartedNotification = mcp.read_notification("item/started").await?;
let ThreadItem::CommandExecution { id, .. } = &started.item else {
let ThreadItemPayload::CommandExecution { id, .. } = &started.item.payload else {
continue;
};
if expected_id.is_none() || expected_id == Some(id.as_str()) {
@@ -592,7 +592,7 @@ async fn wait_for_command_execution_started_by_source(
) -> Result<ItemStartedNotification> {
loop {
let started = wait_for_command_execution_started(mcp, /*expected_id*/ None).await?;
let ThreadItem::CommandExecution { source, .. } = &started.item else {
let ThreadItemPayload::CommandExecution { source, .. } = &started.item.payload else {
continue;
};
if source == &expected_source {
@@ -607,7 +607,7 @@ async fn wait_for_command_execution_completed(
) -> Result<ItemCompletedNotification> {
loop {
let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?;
let ThreadItem::CommandExecution { id, .. } = &completed.item else {
let ThreadItemPayload::CommandExecution { id, .. } = &completed.item.payload else {
continue;
};
if expected_id.is_none() || expected_id == Some(id.as_str()) {
@@ -4,7 +4,7 @@ use app_test_support::TestAppServer;
use app_test_support::create_mock_responses_server_repeating_assistant;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::SortDirection;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadItemsListParams;
use codex_app_server_protocol::ThreadItemsListResponse;
use codex_app_server_protocol::ThreadRealtimeItemContent;
@@ -164,7 +164,9 @@ async fn timeline_pages_mix_items_and_resolve_the_opening_realtime_session() ->
&page.data[0],
ThreadTimelineEntry::Item { turn_id, item, .. }
if turn_id == "turn-1"
&& matches!(item.as_ref(), ThreadItem::UserMessage { .. })
&& matches!(&item.payload, ThreadItemPayload::UserMessage { .. })
&& item.started_at_ms == Some(0)
&& item.completed_at_ms == Some(1)
));
assert!(matches!(
&page.data[1],
@@ -12,7 +12,7 @@ use codex_app_server_protocol::DynamicToolSpec;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadReadParams;
@@ -611,7 +611,8 @@ async fn wait_for_dynamic_tool_started(
continue;
};
let started: ItemStartedNotification = serde_json::from_value(params)?;
if matches!(&started.item, ThreadItem::DynamicToolCall { id, .. } if id == call_id) {
if matches!(&started.item.payload, ThreadItemPayload::DynamicToolCall { id, .. } if id == call_id)
{
return Ok(started);
}
}
@@ -52,7 +52,7 @@ use codex_app_server_protocol::ThreadDeleteResponse;
use codex_app_server_protocol::ThreadDeletedNotification;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
@@ -607,7 +607,7 @@ async fn turn_start_with_empty_input_runs_model_request() -> Result<()> {
assert_eq!(completed.turn.items_view, TurnItemsView::Summary);
assert!(matches!(
&completed.turn.items[..],
[ThreadItem::AgentMessage { text, .. }] if text == "Done"
[codex_app_server_protocol::ThreadItem { payload: ThreadItemPayload::AgentMessage { text, .. }, .. }] if text == "Done"
));
let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?;
@@ -972,15 +972,15 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> {
loop {
let item_started: ItemStartedNotification =
mcp.read_notification("item/started").await?;
if let ThreadItem::UserMessage { .. } = item_started.item {
return Ok::<ThreadItem, anyhow::Error>(item_started.item);
if let ThreadItemPayload::UserMessage { .. } = item_started.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(item_started.item.payload);
}
}
})
.await??;
match user_message_item {
ThreadItem::UserMessage {
ThreadItemPayload::UserMessage {
client_id, content, ..
} => {
assert_eq!(client_id, Some("client-message-1".to_string()));
@@ -2249,12 +2249,12 @@ allow_upstream_proxy = false
loop {
let completed: ItemCompletedNotification =
app_server.read_notification("item/completed").await?;
if let ThreadItem::CommandExecution {
if let ThreadItemPayload::CommandExecution {
id,
status,
exit_code,
..
} = completed.item
} = completed.item.payload
&& id == call_id
{
assert_eq!(
@@ -3030,6 +3030,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
params.command.as_deref(),
Some(expected_approval_command.as_str())
);
let approval_started_at_ms = params.started_at_ms;
let resolved_request_id = request_id.clone();
// Approve and wait for task completion
@@ -3051,14 +3052,15 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
"item/completed" => {
let completed: ItemCompletedNotification =
serde_json::from_value(notification.params.expect("item/completed params"))?;
match completed.item {
ThreadItem::CommandExecution {
match completed.item.payload {
ThreadItemPayload::CommandExecution {
id,
command,
exit_code,
aggregated_output,
..
} if id == "call1" => {
assert_eq!(completed.item.started_at_ms, Some(approval_started_at_ms));
assert_eq!(command, expected_display_command);
assert_eq!(exit_code, Some(0));
assert!(aggregated_output.is_some_and(|output| output.contains("True")));
@@ -3236,13 +3238,13 @@ async fn run_turn_start_exec_approval_rejection_v2(
let started_command_execution = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let started: ItemStartedNotification = mcp.read_notification("item/started").await?;
if let ThreadItem::CommandExecution { .. } = started.item {
return Ok::<ThreadItem, anyhow::Error>(started.item);
if let ThreadItemPayload::CommandExecution { .. } = started.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(started.item.payload);
}
}
})
.await??;
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
id,
status,
command,
@@ -3283,13 +3285,13 @@ async fn run_turn_start_exec_approval_rejection_v2(
loop {
let completed: ItemCompletedNotification =
mcp.read_notification("item/completed").await?;
if let ThreadItem::CommandExecution { .. } = completed.item {
return Ok::<ThreadItem, anyhow::Error>(completed.item);
if let ThreadItemPayload::CommandExecution { .. } = completed.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(completed.item.payload);
}
}
})
.await??;
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
id,
status,
command,
@@ -3494,13 +3496,16 @@ async fn turn_start_explicit_local_environment_updates_legacy_cwd_between_turns(
loop {
let item_started: ItemStartedNotification =
mcp.read_notification("item/started").await?;
if matches!(item_started.item, ThreadItem::CommandExecution { .. }) {
return Ok::<ThreadItem, anyhow::Error>(item_started.item);
if matches!(
item_started.item.payload,
ThreadItemPayload::CommandExecution { .. }
) {
return Ok::<ThreadItemPayload, anyhow::Error>(item_started.item.payload);
}
}
})
.await??;
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
cwd,
command,
status,
@@ -3868,13 +3873,13 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
let started_file_change = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let started: ItemStartedNotification = mcp.read_notification("item/started").await?;
if let ThreadItem::FileChange { .. } = started.item {
return Ok::<ThreadItem, anyhow::Error>(started.item);
if let ThreadItemPayload::FileChange { .. } = started.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(started.item.payload);
}
}
})
.await??;
let ThreadItem::FileChange {
let ThreadItemPayload::FileChange {
ref id,
status,
ref changes,
@@ -3917,7 +3922,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
)
.await?;
let mut saw_resolved = false;
let mut completed_file_change: Option<ThreadItem> = None;
let mut completed_file_change: Option<ThreadItemPayload> = None;
while completed_file_change.is_none() {
let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??;
let JSONRPCMessage::Notification(notification) = message else {
@@ -3939,9 +3944,9 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
let completed: ItemCompletedNotification = serde_json::from_value(
notification.params.clone().expect("item/completed params"),
)?;
if let ThreadItem::FileChange { .. } = completed.item {
if let ThreadItemPayload::FileChange { .. } = completed.item.payload {
assert!(saw_resolved, "serverRequest/resolved should arrive first");
completed_file_change = Some(completed.item);
completed_file_change = Some(completed.item.payload);
}
}
_ => {}
@@ -3949,7 +3954,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
}
let completed_file_change =
completed_file_change.expect("file change completion should be observed");
let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else {
let ThreadItemPayload::FileChange { ref id, status, .. } = completed_file_change else {
unreachable!("loop ensures we break on file change items");
};
assert_eq!(id, "patch-call");
@@ -4333,17 +4338,17 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<()
let spawn_started = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let started: ItemStartedNotification = mcp.read_notification("item/started").await?;
if let ThreadItem::CollabAgentToolCall { id, .. } = &started.item
if let ThreadItemPayload::CollabAgentToolCall { id, .. } = &started.item.payload
&& id == SPAWN_CALL_ID
{
return Ok::<ThreadItem, anyhow::Error>(started.item);
return Ok::<ThreadItemPayload, anyhow::Error>(started.item.payload);
}
}
})
.await??;
assert_eq!(
spawn_started,
ThreadItem::CollabAgentToolCall {
ThreadItemPayload::CollabAgentToolCall {
id: SPAWN_CALL_ID.to_string(),
tool: CollabAgentTool::SpawnAgent,
status: CollabAgentToolCallStatus::InProgress,
@@ -4360,15 +4365,15 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<()
loop {
let completed: ItemCompletedNotification =
mcp.read_notification("item/completed").await?;
if let ThreadItem::CollabAgentToolCall { id, .. } = &completed.item
if let ThreadItemPayload::CollabAgentToolCall { id, .. } = &completed.item.payload
&& id == SPAWN_CALL_ID
{
return Ok::<ThreadItem, anyhow::Error>(completed.item);
return Ok::<ThreadItemPayload, anyhow::Error>(completed.item.payload);
}
}
})
.await??;
let ThreadItem::CollabAgentToolCall {
let ThreadItemPayload::CollabAgentToolCall {
id,
tool,
status,
@@ -4577,15 +4582,15 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected(
let completed: ItemCompletedNotification =
mcp.read_notification("item/completed").await?;
assert!(!matches!(
&completed.item,
ThreadItem::CollabAgentToolCall { .. }
&completed.item.payload,
ThreadItemPayload::CollabAgentToolCall { .. }
));
if let ThreadItem::SubAgentActivity {
if let ThreadItemPayload::SubAgentActivity {
id,
kind: SubAgentActivityKind::Started,
agent_thread_id,
..
} = completed.item
} = completed.item.payload
&& id == SPAWN_CALL_ID
{
return Ok::<String, anyhow::Error>(agent_thread_id);
@@ -4993,15 +4998,15 @@ config_file = "./custom-role.toml"
loop {
let completed: ItemCompletedNotification =
mcp.read_notification("item/completed").await?;
if let ThreadItem::CollabAgentToolCall { id, .. } = &completed.item
if let ThreadItemPayload::CollabAgentToolCall { id, .. } = &completed.item.payload
&& id == SPAWN_CALL_ID
{
return Ok::<ThreadItem, anyhow::Error>(completed.item);
return Ok::<ThreadItemPayload, anyhow::Error>(completed.item.payload);
}
}
})
.await??;
let ThreadItem::CollabAgentToolCall {
let ThreadItemPayload::CollabAgentToolCall {
id,
tool,
status,
@@ -5128,13 +5133,13 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
let started_file_change_1 = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let started: ItemStartedNotification = mcp.read_notification("item/started").await?;
if let ThreadItem::FileChange { .. } = started.item {
return Ok::<ThreadItem, anyhow::Error>(started.item);
if let ThreadItemPayload::FileChange { .. } = started.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(started.item.payload);
}
}
})
.await??;
let ThreadItem::FileChange { id, status, .. } = started_file_change_1 else {
let ThreadItemPayload::FileChange { id, status, .. } = started_file_change_1 else {
unreachable!("loop ensures we break on file change items");
};
assert_eq!(id, "patch-call-1");
@@ -5181,7 +5186,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
"item/completed" => {
let completed: ItemCompletedNotification =
serde_json::from_value(notification.params.expect("item/completed params"))?;
if matches!(completed.item, ThreadItem::FileChange { ref id, .. } if id == "patch-call-1")
if matches!(completed.item.payload, ThreadItemPayload::FileChange { ref id, .. } if id == "patch-call-1")
{
assert_eq!(completed.thread_id, thread.id);
assert_eq!(completed.turn_id, turn_1.id);
@@ -5220,13 +5225,13 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
let started_file_change_2 = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let started: ItemStartedNotification = mcp.read_notification("item/started").await?;
if let ThreadItem::FileChange { .. } = started.item {
return Ok::<ThreadItem, anyhow::Error>(started.item);
if let ThreadItemPayload::FileChange { .. } = started.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(started.item.payload);
}
}
})
.await??;
let ThreadItem::FileChange { id, status, .. } = started_file_change_2 else {
let ThreadItemPayload::FileChange { id, status, .. } = started_file_change_2 else {
unreachable!("loop ensures we break on file change items");
};
assert_eq!(id, "patch-call-2");
@@ -5244,7 +5249,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
let completed: ItemCompletedNotification = serde_json::from_value(
notification.params.expect("item/completed params"),
)?;
if matches!(completed.item, ThreadItem::FileChange { ref id, .. } if id == "patch-call-2")
if matches!(completed.item.payload, ThreadItemPayload::FileChange { ref id, .. } if id == "patch-call-2")
{
return Ok::<ItemCompletedNotification, anyhow::Error>(completed);
}
@@ -5356,13 +5361,13 @@ async fn run_turn_start_file_change_approval_rejection_v2(
let started_file_change = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let started: ItemStartedNotification = mcp.read_notification("item/started").await?;
if let ThreadItem::FileChange { .. } = started.item {
return Ok::<ThreadItem, anyhow::Error>(started.item);
if let ThreadItemPayload::FileChange { .. } = started.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(started.item.payload);
}
}
})
.await??;
let ThreadItem::FileChange {
let ThreadItemPayload::FileChange {
ref id,
status,
ref changes,
@@ -5402,13 +5407,13 @@ async fn run_turn_start_file_change_approval_rejection_v2(
loop {
let completed: ItemCompletedNotification =
mcp.read_notification("item/completed").await?;
if let ThreadItem::FileChange { .. } = completed.item {
return Ok::<ThreadItem, anyhow::Error>(completed.item);
if let ThreadItemPayload::FileChange { .. } = completed.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(completed.item.payload);
}
}
})
.await??;
let ThreadItem::FileChange { ref id, status, .. } = completed_file_change else {
let ThreadItemPayload::FileChange { ref id, status, .. } = completed_file_change else {
unreachable!("loop ensures we break on file change items");
};
assert_eq!(id, "patch-call");
@@ -5506,13 +5511,13 @@ async fn command_execution_notifications_include_process_id(
let started_command = timeout(DEFAULT_READ_TIMEOUT, async {
loop {
let started: ItemStartedNotification = mcp.read_notification("item/started").await?;
if let ThreadItem::CommandExecution { .. } = started.item {
return Ok::<ThreadItem, anyhow::Error>(started.item);
if let ThreadItemPayload::CommandExecution { .. } = started.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(started.item.payload);
}
}
})
.await??;
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
id,
process_id: started_process_id,
status,
@@ -5529,13 +5534,13 @@ async fn command_execution_notifications_include_process_id(
loop {
let completed: ItemCompletedNotification =
mcp.read_notification("item/completed").await?;
if let ThreadItem::CommandExecution { .. } = completed.item {
return Ok::<ThreadItem, anyhow::Error>(completed.item);
if let ThreadItemPayload::CommandExecution { .. } = completed.item.payload {
return Ok::<ThreadItemPayload, anyhow::Error>(completed.item.payload);
}
}
})
.await??;
let ThreadItem::CommandExecution {
let ThreadItemPayload::CommandExecution {
id: completed_id,
process_id: completed_process_id,
status: completed_status,
@@ -5691,8 +5696,8 @@ async fn command_execution_notifications_include_trusted_plugin_id(
let notification = mcp.read_stream_until_notification_message(method).await?;
let params = notification.params.expect("item notification params");
let item_json = params.get("item").expect("item notification item").clone();
let item = serde_json::from_value::<ThreadItem>(item_json.clone())?;
if let ThreadItem::CommandExecution { status, .. } = item {
let item = serde_json::from_value::<ThreadItemPayload>(item_json.clone())?;
if let ThreadItemPayload::CommandExecution { status, .. } = item {
let emitted_script_path = item_json
.get("scriptPath")
.and_then(serde_json::Value::as_str)
@@ -16,7 +16,7 @@ use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
@@ -318,9 +318,9 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> {
let params = notification.params.expect("item/started params");
let item_started: ItemStartedNotification =
serde_json::from_value(params).expect("deserialize item/started notification");
let ThreadItem::UserMessage {
let ThreadItemPayload::UserMessage {
client_id, content, ..
} = item_started.item
} = item_started.item.payload
else {
continue;
};
@@ -8,7 +8,7 @@ use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::write_models_cache;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
@@ -143,7 +143,8 @@ async fn fresh_context_subagent_inherits_disabled_view_image_and_mcp_tools() ->
mcp.read_notification("item/completed"),
)
.await??;
if matches!(&completed.item, ThreadItem::McpToolCall { id, .. } if id == MCP_CALL_ID) {
if matches!(&completed.item.payload, ThreadItemPayload::McpToolCall { id, .. } if id == MCP_CALL_ID)
{
assert_ne!(completed.thread_id, thread.id);
break completed.thread_id;
}
@@ -9,11 +9,12 @@ use app_test_support::TestAppServer;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadReadResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
@@ -304,15 +305,15 @@ async fn assert_standalone_web_search_round_trips_output(
})
);
assert_eq!(
started.item,
ThreadItem::WebSearch(WebSearchItem {
started.item.payload,
ThreadItemPayload::WebSearch(WebSearchItem {
id: call_id.to_string(),
query: String::new(),
action: None,
results: None,
})
);
let expected_completed_item = ThreadItem::WebSearch(WebSearchItem {
let expected_completed_item = ThreadItemPayload::WebSearch(WebSearchItem {
id: call_id.to_string(),
query: "standalone web search".to_string(),
action: Some(WebSearchAction::Search {
@@ -328,7 +329,7 @@ async fn assert_standalone_web_search_round_trips_output(
"future_field": {"preserved": true},
})]),
});
assert_eq!(completed.item, expected_completed_item);
assert_eq!(completed.item.payload, expected_completed_item);
drop(mcp);
let mut reloaded_mcp = TestAppServer::builder()
@@ -344,11 +345,11 @@ async fn assert_standalone_web_search_round_trips_output(
.await?;
let ThreadReadResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, reloaded_mcp.read_response(read_req)).await??;
let persisted_web_searches: Vec<&ThreadItem> = thread
let persisted_web_searches: Vec<&ThreadItemPayload> = thread
.turns
.iter()
.flat_map(|turn| &turn.items)
.filter(|item| matches!(item, ThreadItem::WebSearch(_)))
.flat_map(Turn::item_payloads)
.filter(|item| matches!(item, ThreadItemPayload::WebSearch(_)))
.collect();
assert_eq!(persisted_web_searches, vec![&expected_completed_item]);
@@ -358,7 +359,7 @@ async fn assert_standalone_web_search_round_trips_output(
async fn wait_for_web_search_started(mcp: &mut TestAppServer) -> Result<ItemStartedNotification> {
loop {
let started: ItemStartedNotification = mcp.read_notification("item/started").await?;
if matches!(&started.item, ThreadItem::WebSearch(_)) {
if matches!(&started.item.payload, ThreadItemPayload::WebSearch(_)) {
return Ok(started);
}
}
@@ -369,7 +370,7 @@ async fn wait_for_web_search_completed(
) -> Result<ItemCompletedNotification> {
loop {
let completed: ItemCompletedNotification = mcp.read_notification("item/completed").await?;
if matches!(&completed.item, ThreadItem::WebSearch(_)) {
if matches!(&completed.item.payload, ThreadItemPayload::WebSearch(_)) {
return Ok(completed);
}
}
+8
View File
@@ -2500,6 +2500,14 @@ impl Session {
/// Persist the event to rollout and send it to clients.
pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) {
if let Some((item_id, started_at_ms)) = msg.command_approval_start() {
// The client can see the command before execution. Keep its first start
// for the canonical lifecycle events that will be saved in history.
turn_context
.turn_timing_state
.record_item_started(item_id.to_owned(), started_at_ms)
.await;
}
let legacy_source = msg.clone();
if let EventMsg::Error(error) = &legacy_source
&& error
+10 -2
View File
@@ -308,9 +308,13 @@ async fn guardian_retry_executes_only_after_a_completed_approval() -> Result<()>
.await?;
let mut reviews = Vec::new();
let mut warnings = Vec::new();
let mut command_started_at_ms = None;
loop {
match test.codex.next_event().await?.msg {
EventMsg::GuardianAssessment(review) => reviews.push(review.status),
EventMsg::GuardianAssessment(review) => reviews.push(review),
EventMsg::ItemCompleted(event) if event.item.id() == "write-marker" => {
command_started_at_ms = event.started_at_ms;
}
EventMsg::GuardianWarning(warning) => warnings.push(warning.message),
EventMsg::TurnComplete(_) => break,
_ => {}
@@ -338,12 +342,16 @@ async fn guardian_retry_executes_only_after_a_completed_approval() -> Result<()>
assert_eq!(request.inputs_of_type("agent_message"), expected_messages);
}
assert_eq!(
reviews,
reviews
.iter()
.map(|review| review.status)
.collect::<Vec<_>>(),
vec![
GuardianAssessmentStatus::InProgress,
GuardianAssessmentStatus::Approved,
]
);
assert_eq!(command_started_at_ms, Some(reviews[0].started_at_ms));
assert_eq!(
warnings.len(),
1,
@@ -3937,7 +3937,7 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime(ephemeral: bool) ->
ThreadTimelineEntry::Realtime { item, .. }
if matches!(&item.content, ThreadRealtimeItemContent::TranscriptSegment { text, .. } if text == "spoken before typed input") => Some("speech"),
ThreadTimelineEntry::Item { item, .. }
if matches!(item.as_ref(), codex_app_server_protocol::ThreadItem::UserMessage { .. }) => Some("typed input"),
if matches!(&item.payload, codex_app_server_protocol::ThreadItemPayload::UserMessage { .. }) => Some("typed input"),
_ => None,
}).collect::<Vec<_>>(),
vec!["speech", "typed input"]
@@ -6,7 +6,7 @@ use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::TurnStatus;
use codex_config::Constrained;
use codex_config::test_support::CloudConfigBundleFixture;
@@ -203,14 +203,15 @@ async fn interrupted_one_shot_launch_failure_completes_and_persists() -> Result<
.context("interrupted turn in persisted history")?;
assert_eq!(turn.status, TurnStatus::Interrupted);
let commands = turn
.items
.into_iter()
.item_payloads()
.filter(|item| item.id() == CALL_ID)
.collect::<Vec<_>>();
// History reconstructs persisted fields; model_context is only present on live items.
assert_eq!(
commands,
vec![ThreadItem::from(persisted_completions[0].item.clone())]
vec![&ThreadItemPayload::from(
persisted_completions[0].item.clone()
)]
);
assert!(
matches!(aborted, EventMsg::TurnAborted(event) if event.turn_id.as_deref() == Some(completed[0].turn_id.as_str()))
@@ -5,7 +5,7 @@ use codex_app_server_protocol::CommandExecutionStatus;
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_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::TurnStatus;
use codex_core::config::Config;
@@ -67,16 +67,16 @@ impl EventProcessorWithHumanOutput {
}
}
fn render_item_started(&self, item: &ThreadItem) {
fn render_item_started(&self, item: &ThreadItemPayload) {
match item {
ThreadItem::CommandExecution { command, cwd, .. } => {
ThreadItemPayload::CommandExecution { command, cwd, .. } => {
eprintln!(
"{}\n{} in {cwd}",
"exec".style(self.italic).style(self.magenta),
command.style(self.bold),
);
}
ThreadItem::McpToolCall { server, tool, .. } => {
ThreadItemPayload::McpToolCall { server, tool, .. } => {
eprintln!(
"{} {} {}",
"mcp:".style(self.bold),
@@ -84,22 +84,22 @@ impl EventProcessorWithHumanOutput {
"started".style(self.dimmed)
);
}
ThreadItem::WebSearch(item) => {
ThreadItemPayload::WebSearch(item) => {
eprintln!("{} {}", "web search:".style(self.bold), item.query);
}
ThreadItem::FileChange { .. } => {
ThreadItemPayload::FileChange { .. } => {
eprintln!("{}", "apply patch".style(self.bold));
}
ThreadItem::CollabAgentToolCall { tool, .. } => {
ThreadItemPayload::CollabAgentToolCall { tool, .. } => {
eprintln!("{} {:?}", "collab:".style(self.bold), tool);
}
_ => {}
}
}
fn render_item_completed(&mut self, item: ThreadItem) {
fn render_item_completed(&mut self, item: ThreadItemPayload) {
match item {
ThreadItem::AgentMessage { text, .. } => {
ThreadItemPayload::AgentMessage { text, .. } => {
eprintln!(
"{}\n{}",
"codex".style(self.italic).style(self.magenta),
@@ -108,7 +108,7 @@ impl EventProcessorWithHumanOutput {
self.final_message = Some(text);
self.final_message_rendered = true;
}
ThreadItem::Reasoning {
ThreadItemPayload::Reasoning {
summary, content, ..
} => {
if self.show_agent_reasoning
@@ -119,7 +119,7 @@ impl EventProcessorWithHumanOutput {
eprintln!("{}", text.style(self.dimmed));
}
}
ThreadItem::CommandExecution {
ThreadItemPayload::CommandExecution {
command: _,
aggregated_output,
exit_code,
@@ -163,7 +163,7 @@ impl EventProcessorWithHumanOutput {
eprintln!("{output}");
}
}
ThreadItem::FileChange {
ThreadItemPayload::FileChange {
changes, status, ..
} => {
let status_text = match status {
@@ -177,7 +177,7 @@ impl EventProcessorWithHumanOutput {
eprintln!("{}", change.path.style(self.dimmed));
}
}
ThreadItem::McpToolCall {
ThreadItemPayload::McpToolCall {
server,
tool,
status,
@@ -199,10 +199,10 @@ impl EventProcessorWithHumanOutput {
eprintln!("{}", error.message.style(self.red));
}
}
ThreadItem::WebSearch(item) => {
ThreadItemPayload::WebSearch(item) => {
eprintln!("{} {}", "web search:".style(self.bold), item.query);
}
ThreadItem::ContextCompaction { .. } => {
ThreadItemPayload::ContextCompaction { .. } => {
eprintln!("{}", "context compacted".style(self.dimmed));
}
_ => {}
@@ -287,11 +287,11 @@ impl EventProcessor for EventProcessorWithHumanOutput {
CodexStatus::Running
}
ServerNotification::ItemStarted(notification) => {
self.render_item_started(&notification.item);
self.render_item_started(&notification.item.payload);
CodexStatus::Running
}
ServerNotification::ItemCompleted(notification) => {
self.render_item_completed(notification.item);
self.render_item_completed(notification.item.payload);
CodexStatus::Running
}
ServerNotification::ModelRerouted(notification) => {
@@ -499,17 +499,19 @@ fn reasoning_text(
}
}
fn final_message_from_turn_items(items: &[ThreadItem]) -> Option<String> {
fn final_message_from_turn_items(
items: &[impl std::borrow::Borrow<ThreadItemPayload>],
) -> Option<String> {
items
.iter()
.rev()
.find_map(|item| match item {
ThreadItem::AgentMessage { text, .. } => Some(text.clone()),
.find_map(|item| match item.borrow() {
ThreadItemPayload::AgentMessage { text, .. } => Some(text.clone()),
_ => None,
})
.or_else(|| {
items.iter().rev().find_map(|item| match item {
ThreadItem::Plan { text, .. } => Some(text.clone()),
items.iter().rev().find_map(|item| match item.borrow() {
ThreadItemPayload::Plan { text, .. } => Some(text.clone()),
_ => None,
})
})
@@ -1,5 +1,6 @@
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnStatus;
use codex_core::config::ConfigBuilder;
@@ -352,7 +353,7 @@ async fn config_summary_entries_include_runtime_workspace_roots() {
#[test]
fn final_message_from_turn_items_uses_latest_agent_message() {
let message = final_message_from_turn_items(&[
ThreadItem::AgentMessage {
ThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "first".to_string(),
phase: None,
@@ -360,11 +361,11 @@ fn final_message_from_turn_items_uses_latest_agent_message() {
delivery: None,
questions: None,
},
ThreadItem::Plan {
ThreadItemPayload::Plan {
id: "plan-1".to_string(),
text: "plan".to_string(),
},
ThreadItem::AgentMessage {
ThreadItemPayload::AgentMessage {
id: "msg-2".to_string(),
text: "second".to_string(),
phase: None,
@@ -380,16 +381,16 @@ fn final_message_from_turn_items_uses_latest_agent_message() {
#[test]
fn final_message_from_turn_items_falls_back_to_latest_plan() {
let message = final_message_from_turn_items(&[
ThreadItem::Reasoning {
ThreadItemPayload::Reasoning {
id: "reasoning-1".to_string(),
summary: vec!["inspect".to_string()],
content: Vec::new(),
},
ThreadItem::Plan {
ThreadItemPayload::Plan {
id: "plan-1".to_string(),
text: "first plan".to_string(),
},
ThreadItem::Plan {
ThreadItemPayload::Plan {
id: "plan-2".to_string(),
text: "final plan".to_string(),
},
@@ -426,14 +427,14 @@ fn turn_completed_recovers_final_message_from_turn_items() {
id: "turn-1".to_string(),
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::AgentMessage {
items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}],
})],
status: TurnStatus::Completed,
error: None,
started_at: None,
@@ -478,14 +479,14 @@ fn turn_completed_overwrites_stale_final_message_from_turn_items() {
id: "turn-1".to_string(),
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::AgentMessage {
items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}],
})],
status: TurnStatus::Completed,
error: None,
started_at: None,
@@ -10,7 +10,7 @@ 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::ThreadItemPayload;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::WebSearchAction as ApiWebSearchAction;
@@ -141,15 +141,15 @@ impl EventProcessorWithJsonOutput {
}
fn map_item_with_id(
item: ThreadItem,
item: ThreadItemPayload,
make_id: impl FnOnce() -> String,
) -> Option<ExecThreadItem> {
match item {
ThreadItem::AgentMessage { text, .. } => Some(ExecThreadItem {
ThreadItemPayload::AgentMessage { text, .. } => Some(ExecThreadItem {
id: make_id(),
details: ThreadItemDetails::AgentMessage(AgentMessageItem { text }),
}),
ThreadItem::Reasoning { summary, .. } => {
ThreadItemPayload::Reasoning { summary, .. } => {
let text = summary.join("\n");
if text.trim().is_empty() {
return None;
@@ -159,7 +159,7 @@ impl EventProcessorWithJsonOutput {
details: ThreadItemDetails::Reasoning(ReasoningItem { text }),
})
}
ThreadItem::CommandExecution {
ThreadItemPayload::CommandExecution {
command,
aggregated_output,
exit_code,
@@ -179,7 +179,7 @@ impl EventProcessorWithJsonOutput {
},
}),
}),
ThreadItem::FileChange {
ThreadItemPayload::FileChange {
changes, status, ..
} => Some(ExecThreadItem {
id: make_id(),
@@ -204,7 +204,7 @@ impl EventProcessorWithJsonOutput {
},
}),
}),
ThreadItem::McpToolCall {
ThreadItemPayload::McpToolCall {
server,
tool,
status,
@@ -233,7 +233,7 @@ impl EventProcessorWithJsonOutput {
}),
}),
}),
ThreadItem::CollabAgentToolCall {
ThreadItemPayload::CollabAgentToolCall {
tool,
sender_thread_id,
receiver_thread_ids,
@@ -300,7 +300,7 @@ impl EventProcessorWithJsonOutput {
},
}),
}),
ThreadItem::WebSearch(item) => Some(ExecThreadItem {
ThreadItemPayload::WebSearch(item) => Some(ExecThreadItem {
id: make_id(),
details: ThreadItemDetails::WebSearch(WebSearchItem {
id: item.id,
@@ -340,9 +340,9 @@ impl EventProcessorWithJsonOutput {
.unwrap_or_else(|| self.next_item_id())
}
fn map_started_item(&mut self, item: ThreadItem) -> Option<ExecThreadItem> {
fn map_started_item(&mut self, item: ThreadItemPayload) -> Option<ExecThreadItem> {
match item {
ThreadItem::AgentMessage { .. } | ThreadItem::Reasoning { .. } => None,
ThreadItemPayload::AgentMessage { .. } | ThreadItemPayload::Reasoning { .. } => None,
other => {
let raw_id = other.id().to_string();
Self::map_item_with_id(other, || self.started_item_id(&raw_id))
@@ -350,14 +350,14 @@ impl EventProcessorWithJsonOutput {
}
}
fn map_completed_item_mut(&mut self, item: ThreadItem) -> Option<ExecThreadItem> {
if let ThreadItem::Reasoning { summary, .. } = &item
fn map_completed_item_mut(&mut self, item: ThreadItemPayload) -> Option<ExecThreadItem> {
if let ThreadItemPayload::Reasoning { summary, .. } = &item
&& summary.join("\n").trim().is_empty()
{
return None;
}
match &item {
ThreadItem::AgentMessage { .. } | ThreadItem::Reasoning { .. } => {
ThreadItemPayload::AgentMessage { .. } | ThreadItemPayload::Reasoning { .. } => {
Self::map_item_with_id(item, || self.next_item_id())
}
other => {
@@ -369,7 +369,7 @@ impl EventProcessorWithJsonOutput {
fn reconcile_unfinished_started_items(
&mut self,
turn_items: &[ThreadItem],
turn_items: &[codex_app_server_protocol::ThreadItem],
) -> Vec<ThreadEvent> {
turn_items
.iter()
@@ -378,23 +378,25 @@ impl EventProcessorWithJsonOutput {
if !self.raw_to_exec_item_id.contains_key(&raw_id) {
return None;
}
self.map_completed_item_mut(item.clone())
self.map_completed_item_mut(item.payload.clone())
.map(|item| ThreadEvent::ItemCompleted(ItemCompletedEvent { item }))
})
.collect()
}
fn final_message_from_turn_items(items: &[ThreadItem]) -> Option<String> {
fn final_message_from_turn_items(
items: &[impl std::borrow::Borrow<ThreadItemPayload>],
) -> Option<String> {
items
.iter()
.rev()
.find_map(|item| match item {
ThreadItem::AgentMessage { text, .. } => Some(text.clone()),
.find_map(|item| match item.borrow() {
ThreadItemPayload::AgentMessage { text, .. } => Some(text.clone()),
_ => None,
})
.or_else(|| {
items.iter().rev().find_map(|item| match item {
ThreadItem::Plan { text, .. } => Some(text.clone()),
items.iter().rev().find_map(|item| match item.borrow() {
ThreadItemPayload::Plan { text, .. } => Some(text.clone()),
_ => None,
})
})
@@ -475,13 +477,13 @@ impl EventProcessorWithJsonOutput {
CodexStatus::Running
}
ServerNotification::ItemStarted(notification) => {
if let Some(item) = self.map_started_item(notification.item) {
if let Some(item) = self.map_started_item(notification.item.payload) {
events.push(ThreadEvent::ItemStarted(ItemStartedEvent { item }));
}
CodexStatus::Running
}
ServerNotification::ItemCompleted(notification) => {
if let Some(item) = self.map_completed_item_mut(notification.item) {
if let Some(item) = self.map_completed_item_mut(notification.item.payload) {
if let ThreadItemDetails::AgentMessage(AgentMessageItem { text }) =
&item.details
{
@@ -12,8 +12,10 @@ fn failed_turn_does_not_overwrite_output_last_message_file() {
let mut processor = EventProcessorWithJsonOutput::new(Some(output_path.clone()));
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification {
item: ThreadItem::AgentMessage {
codex_app_server_protocol::ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "partial answer".to_string(),
phase: None,
@@ -21,10 +23,9 @@ fn failed_turn_does_not_overwrite_output_last_message_file() {
delivery: None,
questions: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(collected.status, CodexStatus::Running);
@@ -95,8 +96,10 @@ fn mcp_tool_call_result_preserves_meta_in_jsonl_event() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification {
item: ThreadItem::McpToolCall {
codex_app_server_protocol::ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::McpToolCall {
id: "mcp-1".to_string(),
server: "search service".to_string(),
tool: "web_run".to_string(),
@@ -115,10 +118,9 @@ fn mcp_tool_call_result_preserves_meta_in_jsonl_event() {
error: None,
duration_ms: Some(42),
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(collected.status, CodexStatus::Running);
+1 -2
View File
@@ -39,7 +39,6 @@ use codex_app_server_protocol::Thread as AppServerThread;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadHistoryMode;
use codex_app_server_protocol::ThreadItem as AppServerThreadItem;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadListResponse;
use codex_app_server_protocol::ThreadReadParams;
@@ -1793,7 +1792,7 @@ fn should_backfill_turn_completed_items(
fn turn_items_for_thread(
thread: &AppServerThread,
turn_id: &str,
) -> Option<Vec<AppServerThreadItem>> {
) -> Option<Vec<codex_app_server_protocol::ThreadItem>> {
thread
.turns
.iter()
+24 -18
View File
@@ -1,5 +1,7 @@
use super::*;
use codex_app_server_protocol::AuthRecoveryNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload as AppServerThreadItemPayload;
use codex_otel::set_parent_from_w3c_trace_context;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::models::ActivePermissionProfile;
@@ -387,14 +389,16 @@ fn turn_items_for_thread_returns_matching_turn_items() {
id: "turn-1".to_string(),
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![AppServerThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}],
items: vec![ThreadItem::untimed(
AppServerThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
)],
status: codex_app_server_protocol::TurnStatus::Completed,
error: None,
started_at: None,
@@ -405,10 +409,10 @@ fn turn_items_for_thread_returns_matching_turn_items() {
id: "turn-2".to_string(),
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![AppServerThreadItem::Plan {
items: vec![ThreadItem::untimed(AppServerThreadItemPayload::Plan {
id: "plan-1".to_string(),
text: "ship it".to_string(),
}],
})],
status: codex_app_server_protocol::TurnStatus::Completed,
error: None,
started_at: None,
@@ -420,14 +424,16 @@ fn turn_items_for_thread_returns_matching_turn_items() {
assert_eq!(
turn_items_for_thread(&thread, "turn-1"),
Some(vec![AppServerThreadItem::AgentMessage {
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}])
Some(vec![ThreadItem::untimed(
AppServerThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}
)])
);
assert_eq!(turn_items_for_thread(&thread, "missing-turn"), None);
}
@@ -16,6 +16,7 @@ use codex_app_server_protocol::PatchApplyStatus as ApiPatchApplyStatus;
use codex_app_server_protocol::PatchChangeKind as ApiPatchChangeKind;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::ThreadTokenUsage;
use codex_app_server_protocol::TokenUsageBreakdown;
use codex_app_server_protocol::Turn;
@@ -168,7 +169,7 @@ fn turn_started_emits_turn_started_event() {
#[test]
fn command_execution_started_and_completed_translate_to_thread_events() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let command_item = ThreadItem::CommandExecution {
let command_item = ThreadItemPayload::CommandExecution {
model_context: None,
sandbox_type: None,
id: "cmd-1".to_string(),
@@ -185,13 +186,14 @@ fn command_execution_started_and_completed_translate_to_thread_events() {
duration_ms: None,
};
let started =
processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification {
item: command_item,
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
}));
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
ItemStartedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
command_item,
/*started_at_ms*/ 0,
),
));
assert_eq!(
started,
CollectedThreadEvents {
@@ -211,8 +213,10 @@ fn command_execution_started_and_completed_translate_to_thread_events() {
);
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::CommandExecution {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::CommandExecution {
model_context: None,
sandbox_type: None,
id: "cmd-1".to_string(),
@@ -228,10 +232,9 @@ fn command_execution_started_and_completed_translate_to_thread_events() {
exit_code: Some(0),
duration_ms: Some(3),
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
completed,
@@ -257,16 +260,17 @@ fn empty_reasoning_items_are_ignored() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::Reasoning {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::Reasoning {
id: "reasoning-1".to_string(),
summary: Vec::new(),
content: vec!["raw reasoning".to_string()],
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -283,15 +287,16 @@ fn unsupported_items_do_not_consume_synthetic_ids() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let ignored = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::Plan {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::Plan {
id: "plan-1".to_string(),
text: "ignored plan".to_string(),
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -303,8 +308,10 @@ fn unsupported_items_do_not_consume_synthetic_ids() {
);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::AgentMessage {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
id: "message-1".to_string(),
text: "hello".to_string(),
phase: None,
@@ -312,10 +319,9 @@ fn unsupported_items_do_not_consume_synthetic_ids() {
delivery: None,
questions: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -339,16 +345,17 @@ fn reasoning_items_emit_summary_not_raw_content() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::Reasoning {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::Reasoning {
id: "reasoning-1".to_string(),
summary: vec!["safe summary".to_string()],
content: vec!["raw reasoning".to_string()],
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -372,8 +379,10 @@ fn web_search_completion_preserves_query_and_action() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::WebSearch(ApiWebSearchItem {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::WebSearch(ApiWebSearchItem {
id: "search-1".to_string(),
query: "rust async await".to_string(),
action: Some(ApiWebSearchAction::Search {
@@ -382,10 +391,9 @@ fn web_search_completion_preserves_query_and_action() {
}),
results: None,
}),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -436,17 +444,18 @@ fn web_search_page_actions_and_results_survive_json_output() {
] {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::WebSearch(ApiWebSearchItem {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::WebSearch(ApiWebSearchItem {
id: "search-1".to_string(),
query: url.to_string(),
action: Some(action.clone()),
results: results.clone(),
}),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
let mut expected_item = json!({
@@ -470,22 +479,25 @@ fn web_search_page_actions_and_results_survive_json_output() {
fn web_search_start_and_completion_reuse_item_id() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let started =
processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification {
item: ThreadItem::WebSearch(ApiWebSearchItem {
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
ItemStartedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::WebSearch(ApiWebSearchItem {
id: "search-1".to_string(),
query: String::new(),
action: None,
results: None,
}),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
}));
/*started_at_ms*/ 0,
),
));
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::WebSearch(ApiWebSearchItem {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::WebSearch(ApiWebSearchItem {
id: "search-1".to_string(),
query: "rust async await".to_string(),
action: Some(ApiWebSearchAction::Search {
@@ -494,10 +506,9 @@ fn web_search_start_and_completion_reuse_item_id() {
}),
results: None,
}),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -543,9 +554,11 @@ fn web_search_start_and_completion_reuse_item_id() {
fn mcp_tool_call_begin_and_end_emit_item_events() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let started =
processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification {
item: ThreadItem::McpToolCall {
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
ItemStartedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::McpToolCall {
id: "mcp-1".to_string(),
server: "server_a".to_string(),
tool: "tool_x".to_string(),
@@ -560,13 +573,14 @@ fn mcp_tool_call_begin_and_end_emit_item_events() {
error: None,
duration_ms: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
}));
/*started_at_ms*/ 0,
),
));
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::McpToolCall {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::McpToolCall {
id: "mcp-1".to_string(),
server: "server_a".to_string(),
tool: "tool_x".to_string(),
@@ -585,10 +599,9 @@ fn mcp_tool_call_begin_and_end_emit_item_events() {
error: None,
duration_ms: Some(1_000),
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -640,8 +653,10 @@ fn mcp_tool_call_failure_sets_failed_status() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::McpToolCall {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::McpToolCall {
id: "mcp-2".to_string(),
server: "server_b".to_string(),
tool: "tool_y".to_string(),
@@ -658,10 +673,9 @@ fn mcp_tool_call_failure_sets_failed_status() {
}),
duration_ms: Some(5),
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -691,9 +705,11 @@ fn mcp_tool_call_failure_sets_failed_status() {
fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let started =
processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification {
item: ThreadItem::McpToolCall {
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
ItemStartedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::McpToolCall {
id: "mcp-3".to_string(),
server: "server_c".to_string(),
tool: "tool_z".to_string(),
@@ -708,13 +724,14 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
error: None,
duration_ms: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
}));
/*started_at_ms*/ 0,
),
));
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::McpToolCall {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::McpToolCall {
id: "mcp-3".to_string(),
server: "server_c".to_string(),
tool: "tool_z".to_string(),
@@ -736,10 +753,9 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
error: None,
duration_ms: Some(10),
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -793,9 +809,11 @@ fn mcp_tool_call_defaults_arguments_and_preserves_structured_content() {
fn collab_spawn_begin_and_end_emit_item_events() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let started =
processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification {
item: ThreadItem::CollabAgentToolCall {
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
ItemStartedNotification::new(
"thread-parent".to_string(),
"turn-1".to_string(),
ThreadItemPayload::CollabAgentToolCall {
id: "collab-1".to_string(),
tool: CollabAgentTool::SpawnAgent,
status: ApiCollabAgentToolCallStatus::InProgress,
@@ -806,13 +824,14 @@ fn collab_spawn_begin_and_end_emit_item_events() {
reasoning_effort: None,
agents_states: std::collections::HashMap::new(),
},
thread_id: "thread-parent".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
}));
/*started_at_ms*/ 0,
),
));
let completed = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::CollabAgentToolCall {
ItemCompletedNotification::new(
"thread-parent".to_string(),
"turn-1".to_string(),
ThreadItemPayload::CollabAgentToolCall {
id: "collab-1".to_string(),
tool: CollabAgentTool::SpawnAgent,
status: ApiCollabAgentToolCallStatus::Completed,
@@ -829,10 +848,9 @@ fn collab_spawn_begin_and_end_emit_item_events() {
},
)]),
},
thread_id: "thread-parent".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -886,8 +904,10 @@ fn file_change_completion_maps_change_kinds() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::FileChange {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::FileChange {
id: "patch-1".to_string(),
changes: vec![
ApiFileUpdateChange {
@@ -908,10 +928,9 @@ fn file_change_completion_maps_change_kinds() {
],
status: ApiPatchApplyStatus::Completed,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -949,8 +968,10 @@ fn file_change_declined_maps_to_failed_status() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::FileChange {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::FileChange {
id: "patch-2".to_string(),
changes: vec![ApiFileUpdateChange {
path: "file.txt".to_string(),
@@ -959,10 +980,9 @@ fn file_change_declined_maps_to_failed_status() {
}],
status: ApiPatchApplyStatus::Declined,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -990,8 +1010,10 @@ fn agent_message_item_updates_final_message() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::AgentMessage {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
@@ -999,10 +1021,9 @@ fn agent_message_item_updates_final_message() {
delivery: None,
questions: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -1026,9 +1047,11 @@ fn agent_message_item_updates_final_message() {
fn agent_message_item_started_is_ignored() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected =
processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification {
item: ThreadItem::AgentMessage {
let collected = processor.collect_thread_events(ServerNotification::ItemStarted(
ItemStartedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "hello".to_string(),
phase: None,
@@ -1036,10 +1059,9 @@ fn agent_message_item_started_is_ignored() {
delivery: None,
questions: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
}));
/*started_at_ms*/ 0,
),
));
assert_eq!(
collected,
@@ -1055,16 +1077,17 @@ fn reasoning_item_completed_uses_synthetic_id() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::Reasoning {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::Reasoning {
id: "rs-1".to_string(),
summary: vec!["thinking...".to_string()],
content: vec!["raw".to_string()],
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(
@@ -1384,14 +1407,14 @@ fn turn_completion_recovers_final_message_from_turn_items() {
id: "turn-1".to_string(),
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::AgentMessage {
items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}],
})],
status: TurnStatus::Completed,
error: None,
started_at: None,
@@ -1417,9 +1440,11 @@ fn turn_completion_recovers_final_message_from_turn_items() {
fn turn_completion_reconciles_started_items_from_turn_items() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let started =
processor.collect_thread_events(ServerNotification::ItemStarted(ItemStartedNotification {
item: ThreadItem::CommandExecution {
let started = processor.collect_thread_events(ServerNotification::ItemStarted(
ItemStartedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::CommandExecution {
model_context: None,
sandbox_type: None,
id: "cmd-1".to_string(),
@@ -1435,10 +1460,9 @@ fn turn_completion_reconciles_started_items_from_turn_items() {
exit_code: None,
duration_ms: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
started_at_ms: 0,
}));
/*started_at_ms*/ 0,
),
));
assert_eq!(
started,
CollectedThreadEvents {
@@ -1464,7 +1488,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() {
id: "turn-1".to_string(),
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::CommandExecution {
items: vec![ThreadItem::untimed(ThreadItemPayload::CommandExecution {
model_context: None,
sandbox_type: None,
id: "cmd-1".to_string(),
@@ -1479,7 +1503,7 @@ fn turn_completion_reconciles_started_items_from_turn_items() {
aggregated_output: Some("a.txt\n".to_string()),
exit_code: Some(0),
duration_ms: Some(3),
}],
})],
status: TurnStatus::Completed,
error: None,
started_at: None,
@@ -1517,8 +1541,10 @@ fn turn_completion_reconciles_started_items_from_turn_items() {
fn turn_completion_overwrites_stale_final_message_from_turn_items() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let _ = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::AgentMessage {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
id: "msg-stale".to_string(),
text: "stale answer".to_string(),
phase: None,
@@ -1526,10 +1552,9 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() {
delivery: None,
questions: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
let completed = processor.collect_thread_events(ServerNotification::TurnCompleted(
@@ -1539,14 +1564,14 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() {
id: "turn-1".to_string(),
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::AgentMessage {
items: vec![ThreadItem::untimed(ThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "final answer".to_string(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}],
})],
status: TurnStatus::Completed,
error: None,
started_at: None,
@@ -1572,8 +1597,10 @@ fn turn_completion_overwrites_stale_final_message_from_turn_items() {
fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let _ = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::AgentMessage {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
id: "msg-streamed".to_string(),
text: "streamed answer".to_string(),
phase: None,
@@ -1581,10 +1608,9 @@ fn turn_completion_preserves_streamed_final_message_when_turn_items_are_empty()
delivery: None,
questions: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
let completed = processor.collect_thread_events(ServerNotification::TurnCompleted(
@@ -1621,8 +1647,10 @@ fn failed_turn_clears_stale_final_message() {
let mut processor = EventProcessorWithJsonOutput::new(/*last_message_path*/ None);
let collected = processor.collect_thread_events(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: ThreadItem::AgentMessage {
ItemCompletedNotification::new(
"thread-1".to_string(),
"turn-1".to_string(),
ThreadItemPayload::AgentMessage {
id: "msg-1".to_string(),
text: "partial answer".to_string(),
phase: None,
@@ -1630,10 +1658,9 @@ fn failed_turn_clears_stale_final_message() {
delivery: None,
questions: None,
},
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 0,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
assert_eq!(collected.status, CodexStatus::Running);
@@ -1676,10 +1703,10 @@ fn turn_completion_falls_back_to_final_plan_text() {
id: "turn-1".to_string(),
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: vec![ThreadItem::Plan {
items: vec![ThreadItem::untimed(ThreadItemPayload::Plan {
id: "plan-1".to_string(),
text: "ship the typed adapter".to_string(),
}],
})],
status: TurnStatus::Completed,
error: None,
started_at: None,
@@ -227,7 +227,7 @@ fn turn_complete_item(
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::build_turns_from_rollout_items;
use serde_json::Value as JsonValue;
use std::path::Path;
@@ -258,15 +258,15 @@ mod tests {
assert_eq!(turns[0].items.len(), 2);
assert_eq!(turns[1].items.len(), 2);
assert_eq!(
turns[1].items[1],
ThreadItem::AgentMessage {
turns[1].item_payloads().next_back(),
Some(&ThreadItemPayload::AgentMessage {
id: "item-4".into(),
text: EXTERNAL_SESSION_IMPORTED_MARKER.into(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
}
})
);
}
@@ -292,8 +292,8 @@ mod tests {
assert_eq!(turns.len(), 1);
assert_eq!(
turns[0].items.last(),
Some(&ThreadItem::AgentMessage {
turns[0].item_payloads().next_back(),
Some(&ThreadItemPayload::AgentMessage {
id: "item-3".into(),
text: EXTERNAL_SESSION_IMPORTED_MARKER.into(),
phase: None,
+28
View File
@@ -1695,6 +1695,34 @@ pub enum EventMsg {
SubAgentActivity(SubAgentActivityEvent),
}
impl EventMsg {
/// Returns the ID and start time of the command this approval opens.
/// Stdin and network reviews have their own prompts for an existing operation.
pub fn command_approval_start(&self) -> Option<(&str, i64)> {
match self {
Self::ExecApprovalRequest(request)
if request.approval_id.is_none() && request.network_approval_context.is_none() =>
{
Some((&request.call_id, request.started_at_ms))
}
Self::GuardianAssessment(assessment)
if assessment.status == GuardianAssessmentStatus::InProgress
&& matches!(
assessment.action,
GuardianAssessmentAction::Command { .. }
| GuardianAssessmentAction::Execve { .. }
) =>
{
Some((
assessment.target_item_id.as_deref()?,
assessment.started_at_ms,
))
}
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, EnumIter)]
#[serde(rename_all = "snake_case")]
pub enum HookEventName {
@@ -1,5 +1,5 @@
use codex_app_server_protocol::ThreadHistoryChangeSet;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::TurnStatus;
use codex_protocol::ThreadId;
use codex_protocol::models::MessagePhase;
@@ -428,7 +428,8 @@ WHERE thread_id = ?
.map_err(thread_history_error)?;
}
for item in changes.changed_items {
for change in changes.changed_items {
let item = change.item;
let created_at_ms =
item.started_at_ms
.or(fallback_created_at_ms)
@@ -437,8 +438,8 @@ WHERE thread_id = ?
"thread history projection for {thread_id} is missing an item creation timestamp"
),
})?;
let item_id = item.item.id().to_string();
let item_json = serde_json::to_string(&item.item).map_err(thread_history_error)?;
let item_id = item.id().to_string();
let item_json = serde_json::to_string(&item.payload).map_err(thread_history_error)?;
// Completed items are immutable: local producers emit ItemCompleted exactly once per
// item. Tolerate an unexpected duplicate defensively so it cannot poison materialization,
// preserving the original creation ordinal and timestamp while updating its snapshot.
@@ -465,7 +466,7 @@ ON CONFLICT(thread_id, turn_id, item_id) DO UPDATE SET
"#,
)
.bind(thread_id)
.bind(item.turn_id.as_str())
.bind(change.turn_id.as_str())
.bind(item_id.as_str())
.bind(rollout_ordinal)
.bind(rollout_ordinal)
@@ -480,8 +481,8 @@ ON CONFLICT(thread_id, turn_id, item_id) DO UPDATE SET
// Keep summary item IDs on the turn row so reads do not need to scan every item in the
// turn.
match item.item {
ThreadItem::UserMessage { .. } => {
match item.payload {
ThreadItemPayload::UserMessage { .. } => {
sqlx::query(
r#"
UPDATE thread_turns
@@ -494,12 +495,12 @@ WHERE thread_id = ?
)
.bind(item_id.as_str())
.bind(thread_id)
.bind(item.turn_id.as_str())
.bind(change.turn_id.as_str())
.execute(&mut **transaction)
.await
.map_err(thread_history_error)?;
}
ThreadItem::AgentMessage {
ThreadItemPayload::AgentMessage {
phase: Some(MessagePhase::FinalAnswer),
..
} => {
@@ -515,32 +516,32 @@ WHERE thread_id = ?
)
.bind(item_id.as_str())
.bind(thread_id)
.bind(item.turn_id.as_str())
.bind(change.turn_id.as_str())
.execute(&mut **transaction)
.await
.map_err(thread_history_error)?;
}
ThreadItem::AgentMessage {
ThreadItemPayload::AgentMessage {
phase: Some(MessagePhase::Commentary | MessagePhase::PartialAnswer) | None,
..
}
| ThreadItem::HookPrompt { .. }
| ThreadItem::FunctionCallOutput { .. }
| ThreadItem::Plan { .. }
| ThreadItem::Reasoning { .. }
| ThreadItem::CommandExecution { .. }
| ThreadItem::FileChange { .. }
| ThreadItem::McpToolCall { .. }
| ThreadItem::DynamicToolCall { .. }
| ThreadItem::CollabAgentToolCall { .. }
| ThreadItem::SubAgentActivity { .. }
| ThreadItem::WebSearch(_)
| ThreadItem::ImageView { .. }
| ThreadItem::Sleep(_)
| ThreadItem::ImageGeneration(_)
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. }
| ThreadItem::ContextCompaction { .. } => {}
| ThreadItemPayload::HookPrompt { .. }
| ThreadItemPayload::FunctionCallOutput { .. }
| ThreadItemPayload::Plan { .. }
| ThreadItemPayload::Reasoning { .. }
| ThreadItemPayload::CommandExecution { .. }
| ThreadItemPayload::FileChange { .. }
| ThreadItemPayload::McpToolCall { .. }
| ThreadItemPayload::DynamicToolCall { .. }
| ThreadItemPayload::CollabAgentToolCall { .. }
| ThreadItemPayload::SubAgentActivity { .. }
| ThreadItemPayload::WebSearch(_)
| ThreadItemPayload::ImageView { .. }
| ThreadItemPayload::Sleep(_)
| ThreadItemPayload::ImageGeneration(_)
| ThreadItemPayload::EnteredReviewMode { .. }
| ThreadItemPayload::ExitedReviewMode { .. }
| ThreadItemPayload::ContextCompaction { .. } => {}
}
}
Ok(())
@@ -97,7 +97,18 @@ pub(in crate::local) async fn list_timeline(
.map(|value| sqlite_integer(value, "rollout ordinal"))
.transpose()?
.unwrap_or(i64::MAX);
let segment_rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, String)>(
let segment_rows = sqlx::query_as::<
_,
(
i64,
i64,
String,
Option<String>,
String,
Option<i64>,
Option<i64>,
),
>(
r#"
WITH starts AS (
SELECT rollout_ordinal, 0 AS kind, turn_id AS id, turn_id,
@@ -109,7 +120,7 @@ WHERE thread_id = ?1 AND rollout_ordinal >= ?2
AND (rollout_ordinal, 0, turn_id) < (?4, ?5, ?6)
ORDER BY rollout_ordinal DESC, turn_id DESC LIMIT ?7
), items AS (
SELECT rollout_ordinal, 1 AS kind, item_id AS id, turn_id, item_json
SELECT rollout_ordinal, 1 AS kind, item_id AS id, turn_id, item_json, started_at_ms, completed_at_ms
FROM thread_items
WHERE thread_id = ?1 AND rollout_ordinal >= ?2
AND rollout_ordinal < ?3 AND rollout_ordinal <= ?4
@@ -134,10 +145,10 @@ WHERE thread_id = ?1 AND rollout_end_ordinal >= ?2
AND (rollout_end_ordinal, 3, turn_id) < (?4, ?5, ?6)
ORDER BY rollout_end_ordinal DESC, turn_id DESC LIMIT ?7
)
SELECT * FROM starts
SELECT *, NULL, NULL FROM starts
UNION ALL SELECT * FROM items
UNION ALL SELECT * FROM realtime
UNION ALL SELECT * FROM ends
UNION ALL SELECT *, NULL, NULL FROM realtime
UNION ALL SELECT *, NULL, NULL FROM ends
ORDER BY rollout_ordinal DESC, kind DESC, id DESC
LIMIT ?7
"#,
@@ -153,7 +164,9 @@ LIMIT ?7
.await
.map_err(thread_history_error)?;
for (position, kind, _id, turn_id, item_json) in segment_rows {
for (position, kind, _id, turn_id, item_json, started_at_ms, completed_at_ms) in
segment_rows
{
let position = u64::try_from(position).map_err(thread_history_error)?;
rows.push(match kind {
0 | 3 => serde_json::from_str::<ThreadTimelineEntry>(&item_json)
@@ -161,10 +174,11 @@ LIMIT ?7
1 => ThreadTimelineEntry::Item {
position,
turn_id: turn_id.ok_or_else(|| thread_history_error("missing turn ID"))?,
item: Box::new(
serde_json::from_str::<ThreadItem>(&item_json)
.map_err(thread_history_error)?,
),
item: Box::new(ThreadItem {
payload: serde_json::from_str(&item_json).map_err(thread_history_error)?,
started_at_ms,
completed_at_ms,
}),
},
2 => ThreadTimelineEntry::Realtime {
position,
@@ -1,7 +1,7 @@
use std::borrow::Cow;
use std::collections::HashMap;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadItemPayload;
use codex_app_server_protocol::UserInput;
use codex_protocol::ThreadId;
use codex_protocol::protocol::strip_user_message_prefix;
@@ -176,12 +176,11 @@ ORDER BY rollout_ordinal ASC
let mut matching_occurrences = 0_usize;
while let Some(row) = rows.try_next().await.map_err(thread_history_error)? {
let row = candidate_row(row)?;
let item =
serde_json::from_str::<ThreadItem>(row.item_json.as_str()).map_err(|err| {
ThreadStoreError::Internal {
message: format!("failed to deserialize stored thread item: {err}"),
}
})?;
let item = serde_json::from_str::<ThreadItemPayload>(row.item_json.as_str()).map_err(
|err| ThreadStoreError::Internal {
message: format!("failed to deserialize stored thread item: {err}"),
},
)?;
let Some(text) = searchable_text(&item) else {
continue;
};
@@ -309,9 +308,9 @@ fn invalid_cursor(cursor: &str) -> ThreadStoreError {
}
}
fn searchable_text(item: &ThreadItem) -> Option<Cow<'_, str>> {
fn searchable_text(item: &ThreadItemPayload) -> Option<Cow<'_, str>> {
match item {
ThreadItem::UserMessage { content, .. } => {
ThreadItemPayload::UserMessage { content, .. } => {
let mut text_parts = content
.iter()
.filter_map(|input| match input {
@@ -335,27 +334,27 @@ fn searchable_text(item: &ThreadItem) -> Option<Cow<'_, str>> {
}
}
}
ThreadItem::AgentMessage { text, .. } => {
ThreadItemPayload::AgentMessage { text, .. } => {
let text = markdown_to_search_text(text);
(!text.is_empty()).then_some(Cow::Owned(text))
}
ThreadItem::HookPrompt { .. }
| ThreadItem::FunctionCallOutput { .. }
| ThreadItem::Plan { .. }
| ThreadItem::Reasoning { .. }
| ThreadItem::CommandExecution { .. }
| ThreadItem::FileChange { .. }
| ThreadItem::McpToolCall { .. }
| ThreadItem::DynamicToolCall { .. }
| ThreadItem::CollabAgentToolCall { .. }
| ThreadItem::SubAgentActivity { .. }
| ThreadItem::WebSearch(_)
| ThreadItem::ImageView { .. }
| ThreadItem::Sleep(_)
| ThreadItem::ImageGeneration(_)
| ThreadItem::EnteredReviewMode { .. }
| ThreadItem::ExitedReviewMode { .. }
| ThreadItem::ContextCompaction { .. } => None,
ThreadItemPayload::HookPrompt { .. }
| ThreadItemPayload::FunctionCallOutput { .. }
| ThreadItemPayload::Plan { .. }
| ThreadItemPayload::Reasoning { .. }
| ThreadItemPayload::CommandExecution { .. }
| ThreadItemPayload::FileChange { .. }
| ThreadItemPayload::McpToolCall { .. }
| ThreadItemPayload::DynamicToolCall { .. }
| ThreadItemPayload::CollabAgentToolCall { .. }
| ThreadItemPayload::SubAgentActivity { .. }
| ThreadItemPayload::WebSearch(_)
| ThreadItemPayload::ImageView { .. }
| ThreadItemPayload::Sleep(_)
| ThreadItemPayload::ImageGeneration(_)
| ThreadItemPayload::EnteredReviewMode { .. }
| ThreadItemPayload::ExitedReviewMode { .. }
| ThreadItemPayload::ContextCompaction { .. } => None,
}
}
@@ -236,7 +236,7 @@ fn read_projection_batches(
let fallback_created_at_ms = if changes
.changed_items
.iter()
.any(|item| item.started_at_ms.is_none())
.any(|change| change.item.started_at_ms.is_none())
|| (!is_inherited_subagent_history
&& matches!(&line.item, RolloutItem::RealtimeItem(_)))
{

Some files were not shown because too many files have changed in this diff Show More