Include terminal errors in turn completion events (#32280)

## What changed

- Add an optional `error` payload to `TurnCompleteEvent` and omit it when a turn completes without an error.
- Preserve the full terminal `ErrorEvent` through the turn lifecycle so the completion event includes the same error details emitted separately by `EventMsg::Error`.

## Testing

- Extend the stream-error integration test to verify that `TurnCompleteEvent` contains the emitted error and that the next turn can still proceed.

GitOrigin-RevId: 8d32942a44132763076130102d8b89ab356d59d0
This commit is contained in:
Owen Lin
2026-07-10 22:04:03 +00:00
committed by copyberry
parent 09ccae2c07
commit c4318c386d
19 changed files with 92 additions and 6 deletions
@@ -1762,6 +1762,7 @@ mod tests {
turn_id: "turn-1".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1821,6 +1822,7 @@ mod tests {
turn_id: "turn-1".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1924,6 +1926,7 @@ mod tests {
turn_id: turn_id.to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1976,6 +1979,7 @@ mod tests {
turn_id: turn_id.to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -2029,6 +2033,7 @@ mod tests {
turn_id: turn_id.to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -2093,6 +2098,7 @@ mod tests {
turn_id: turn_id.to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -2162,6 +2168,7 @@ mod tests {
turn_id: turn_id.to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -2241,6 +2248,7 @@ mod tests {
turn_id: "turn-image".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -2594,6 +2602,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3163,6 +3172,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3206,6 +3216,7 @@ mod tests {
turn_id: "turn-b".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3263,6 +3274,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3306,6 +3318,7 @@ mod tests {
turn_id: "turn-b".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3495,6 +3508,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3518,6 +3532,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3531,6 +3546,7 @@ mod tests {
turn_id: "turn-b".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3570,6 +3586,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3637,6 +3654,7 @@ mod tests {
turn_id: "turn-compact".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3900,6 +3918,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3966,6 +3985,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -4022,6 +4042,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -4104,6 +4125,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -4270,6 +4292,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: Some(20),
duration_ms: Some(123),
time_to_first_token_ms: None,
@@ -4351,6 +4374,7 @@ mod tests {
turn_id: "turn-a".into(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: Some(20),
duration_ms: Some(123),
time_to_first_token_ms: None,
@@ -2193,6 +2193,7 @@ mod tests {
turn_id: turn_id.to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: Some(TEST_TURN_COMPLETED_AT),
duration_ms: Some(TEST_TURN_DURATION_MS),
time_to_first_token_ms: None,
@@ -161,6 +161,7 @@ async fn mark_thread_completed(thread: &CodexThread) {
turn_id: turn.sub_id.clone(),
started_at: None,
last_agent_message: Some("done".to_string()),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
+3
View File
@@ -351,6 +351,7 @@ async fn on_event_updates_status_from_task_complete() {
turn_id: "turn-1".to_string(),
started_at: None,
last_agent_message: Some("done".to_string()),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -2029,6 +2030,7 @@ async fn multi_agent_v2_completion_ignores_dead_direct_parent() {
turn_id: tester_turn.sub_id.clone(),
started_at: None,
last_agent_message: Some("done".to_string()),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -2117,6 +2119,7 @@ async fn multi_agent_v2_completion_queues_message_for_direct_parent() {
turn_id: tester_turn.sub_id.clone(),
started_at: None,
last_agent_message: Some("done".to_string()),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1179,6 +1179,7 @@ mod tests {
turn_id: turn_id.to_string(),
started_at: None,
last_agent_message: last_agent_message.map(str::to_string),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms,
+1
View File
@@ -2234,6 +2234,7 @@ async fn guardian_reused_trunk_ignores_stale_prior_turn_completion() -> anyhow::
"{\"risk_level\":\"high\",\"user_authorization\":\"low\",\"outcome\":\"deny\",\"rationale\":\"stale guardian rationale\"}"
.to_string(),
),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: Some(1),
+2 -2
View File
@@ -1776,7 +1776,7 @@ impl Session {
.terminal_error
.lock()
.await
.replace(error.message.clone());
.replace(error.clone());
}
self.services
.rollout_thread_trace
@@ -1834,7 +1834,7 @@ impl Session {
let status = match turn_context.terminal_error.lock().await.take() {
Some(error) => {
let status = AgentStatus::Errored(error);
let status = AgentStatus::Errored(error.message);
self.agent_status.send_replace(status.clone());
status
}
@@ -97,6 +97,7 @@ fn completed_user_turn_rollout(
codex_protocol::protocol::TurnCompleteEvent {
turn_id,
last_agent_message: None,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
@@ -266,6 +267,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
codex_protocol::protocol::TurnCompleteEvent {
turn_id,
last_agent_message: None,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
@@ -343,6 +345,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
turn_id: first_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -378,6 +381,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
turn_id: rolled_back_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -458,6 +462,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
turn_id: first_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -556,6 +561,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
turn_id: first_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -587,6 +593,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
turn_id: second_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -607,6 +614,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
turn_id: standalone_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -685,6 +693,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
turn_id: first_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -707,6 +716,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
turn_id: assistant_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -780,6 +790,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
turn_id: only_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -834,6 +845,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
turn_id: user_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -854,6 +866,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
turn_id: standalone_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -912,6 +925,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
turn_id: previous_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1242,6 +1256,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
turn_id: current_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1323,6 +1338,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
turn_id: previous_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1436,6 +1452,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
turn_id: previous_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1560,6 +1577,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
turn_id: previous_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1599,6 +1617,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
turn_id: current_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1690,6 +1709,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
turn_id: previous_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1860,6 +1880,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
turn_id: previous_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
+10
View File
@@ -3136,6 +3136,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
codex_protocol::protocol::TurnCompleteEvent {
turn_id,
last_agent_message: None,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
@@ -3338,6 +3339,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
turn_id: first_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3368,6 +3370,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
turn_id: rolled_back_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3458,6 +3461,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
turn_id: first_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3483,6 +3487,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
turn_id: compact_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3516,6 +3521,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
turn_id: rolled_back_turn_id,
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3590,6 +3596,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
turn_id: "turn-1".to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3618,6 +3625,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
turn_id: "turn-2".to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -3646,6 +3654,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
turn_id: "turn-3".to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -9762,6 +9771,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input()
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id,
last_agent_message: None,
error: None,
time_to_first_token_ms: None,
..
}) if turn_id == tc.sub_id
+2 -1
View File
@@ -9,6 +9,7 @@ use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::protocol::TurnEnvironmentSelection;
@@ -140,7 +141,7 @@ pub struct TurnContext {
pub(crate) extension_data: Arc<codex_extension_api::ExtensionData>,
pub(crate) turn_skills: TurnSkillsContext,
pub(crate) turn_timing_state: Arc<TurnTimingState>,
pub(crate) terminal_error: Arc<Mutex<Option<String>>>,
pub(crate) terminal_error: Arc<Mutex<Option<ErrorEvent>>>,
pub(crate) server_model_warning_emitted: AtomicBool,
pub(crate) model_verification_emitted: AtomicBool,
}
+2
View File
@@ -766,11 +766,13 @@ impl Session {
.turn_timing_state
.time_to_first_token_ms()
.await;
let error = turn_context.terminal_error.lock().await.clone();
self.emit_turn_stop_lifecycle(turn_context.extension_data.as_ref())
.await;
EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: turn_context.sub_id.clone(),
last_agent_message,
error,
started_at,
completed_at,
duration_ms,
@@ -84,6 +84,7 @@ fn turn_completed(turn_id: &str) -> RolloutItem {
turn_id: turn_id.to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -1560,6 +1560,7 @@ async fn multi_agent_v2_list_agents_returns_completed_status_without_encrypted_s
turn_id: child_turn.sub_id.clone(),
started_at: None,
last_agent_message: Some("done".to_string()),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -2010,6 +2011,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn()
turn_id: first_turn.sub_id.clone(),
started_at: None,
last_agent_message: Some("first done".to_string()),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -2052,6 +2054,7 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn()
turn_id: second_turn.sub_id.clone(),
started_at: None,
last_agent_message: Some("second done".to_string()),
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
@@ -74,6 +74,7 @@ fn resume_history(
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id,
last_agent_message: None,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
@@ -107,9 +107,18 @@ async fn continue_after_stream_error() {
.unwrap();
// Expect an Error followed by TurnComplete so the session is released.
wait_for_event(&codex, |ev| matches!(ev, EventMsg::Error(_))).await;
let EventMsg::Error(error) =
wait_for_event(&codex, |ev| matches!(ev, EventMsg::Error(_))).await
else {
unreachable!("predicate guarantees an error event");
};
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
let EventMsg::TurnComplete(completed) =
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await
else {
unreachable!("predicate guarantees a turn complete event");
};
assert_eq!(completed.error, Some(error));
// 2) Second turn: now send another prompt that should succeed using the
// mock server SSE stream. If the agent failed to clear the running task on
@@ -191,6 +191,7 @@ fn turn_complete_item(
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id,
last_agent_message: None,
error: None,
started_at,
completed_at,
duration_ms: None,
+5 -1
View File
@@ -1887,7 +1887,7 @@ pub struct ExitedReviewModeEvent {
// Individual event payload types matching each `EventMsg` variant.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
pub struct ErrorEvent {
pub message: String,
#[serde(default)]
@@ -1955,6 +1955,10 @@ pub struct ContextCompactedEvent;
pub struct TurnCompleteEvent {
pub turn_id: String,
pub last_agent_message: Option<String>,
/// Terminal error details when the turn completed unsuccessfully.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub error: Option<ErrorEvent>,
/// Unix timestamp (in seconds) when the turn started.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(type = "number | null", optional)]
@@ -53,6 +53,7 @@ fn turn_complete(turn_id: &str) -> RolloutItem {
turn_id: turn_id.to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,
+1
View File
@@ -540,6 +540,7 @@ mod tests {
turn_id: "turn-1".to_string(),
started_at: None,
last_agent_message: None,
error: None,
completed_at: None,
duration_ms: None,
time_to_first_token_ms: None,