From c4318c386de365bd0dd9595a08d55a30bb142d11 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Fri, 10 Jul 2026 21:56:58 +0000 Subject: [PATCH] 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 --- .../src/protocol/thread_history.rs | 24 +++++++++++++++++++ .../app-server/src/bespoke_event_handling.rs | 1 + .../core/src/agent/control/residency_tests.rs | 1 + codex-rs/core/src/agent/control_tests.rs | 3 +++ codex-rs/core/src/guardian/review_session.rs | 1 + codex-rs/core/src/guardian/tests.rs | 1 + codex-rs/core/src/session/mod.rs | 4 ++-- .../session/rollout_reconstruction_tests.rs | 21 ++++++++++++++++ codex-rs/core/src/session/tests.rs | 10 ++++++++ codex-rs/core/src/session/turn_context.rs | 3 ++- codex-rs/core/src/tasks/mod.rs | 2 ++ .../src/thread_rollout_truncation_tests.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 3 +++ codex-rs/core/tests/suite/resume_warning.rs | 1 + .../suite/stream_error_allows_next_turn.rs | 13 ++++++++-- .../external-agent-sessions/src/export.rs | 1 + codex-rs/protocol/src/protocol.rs | 6 ++++- .../rollout/src/persistence_metrics_tests.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 1 + 19 files changed, 92 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index daa897a4b8..8e32e9c1b5 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -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, diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f7051bdb24..8fd4ad0a14 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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, diff --git a/codex-rs/core/src/agent/control/residency_tests.rs b/codex-rs/core/src/agent/control/residency_tests.rs index abc85a74f9..be3210dfec 100644 --- a/codex-rs/core/src/agent/control/residency_tests.rs +++ b/codex-rs/core/src/agent/control/residency_tests.rs @@ -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, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 8b34c37b5e..19b7c010a7 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -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, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7fb22342f0..1fa9c298f8 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -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, diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 651e822cbb..a90d14d86a 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -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), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9339413b33..516116e040 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -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 } diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 01005643a3..885f6bccdf 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -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, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index c17b06b9b3..4121baee40 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -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 diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 3bcff2d9e6..032436d410 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -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, pub(crate) turn_skills: TurnSkillsContext, pub(crate) turn_timing_state: Arc, - pub(crate) terminal_error: Arc>>, + pub(crate) terminal_error: Arc>>, pub(crate) server_model_warning_emitted: AtomicBool, pub(crate) model_verification_emitted: AtomicBool, } diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index f3bc02f5cf..3faf104364 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -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, diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index c0f8819c83..5b6c865965 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -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, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 620e854989..2dcf864e0b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -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, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index f88bda56db..6cd3e8f452 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -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, diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index 1870feab9f..2c86edc53c 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -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 diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 4352f74f75..8c46eb55f8 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -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, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0935bea46f..94d066bd38 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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, + /// Terminal error details when the turn completed unsuccessfully. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, /// Unix timestamp (in seconds) when the turn started. #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(type = "number | null", optional)] diff --git a/codex-rs/rollout/src/persistence_metrics_tests.rs b/codex-rs/rollout/src/persistence_metrics_tests.rs index d7a8e80cf6..3a352263b7 100644 --- a/codex-rs/rollout/src/persistence_metrics_tests.rs +++ b/codex-rs/rollout/src/persistence_metrics_tests.rs @@ -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, diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index dd5891633e..dc10cb9362 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -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,