From bf732600ea99af6f8111cb4db09233519e316e30 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Wed, 24 Dec 2025 10:07:38 -0800 Subject: [PATCH] [chore] add additional_details to StreamErrorEvent + wire through (#8307) ### What Builds on #8293. Add `additional_details`, which contains the upstream error message, to relevant structures used to pass along retryable `StreamError`s. Uses the new TUI status indicator's `details` field (shows under the status header) to display the `additional_details` error to the user on retryable `Reconnecting...` errors. This adds clarity for users for retryable errors. Will make corresponding change to VSCode extension to show `additional_details` as expandable from the `Reconnecting...` cell. Examples: image image --- codex-rs/app-server-protocol/src/protocol/v2.rs | 2 ++ codex-rs/app-server/README.md | 4 ++-- codex-rs/app-server/src/bespoke_event_handling.rs | 11 +++++++++++ codex-rs/core/src/codex.rs | 2 ++ .../exec/src/event_processor_with_human_output.rs | 10 +++++++++- .../exec/src/event_processor_with_jsonl_output.rs | 12 +++++++++--- .../exec/tests/event_processor_with_json_output.rs | 1 + codex-rs/protocol/src/protocol.rs | 5 +++++ codex-rs/tui/src/chatwidget.rs | 12 +++++++----- codex-rs/tui/src/chatwidget/tests.rs | 4 ++++ codex-rs/tui2/src/chatwidget.rs | 12 +++++++----- codex-rs/tui2/src/chatwidget/tests.rs | 4 ++++ 12 files changed, 63 insertions(+), 16 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 85ce73ab3..7f09216ea 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1274,6 +1274,8 @@ pub struct Turn { pub struct TurnError { pub message: String, pub codex_error_info: Option, + #[serde(default)] + pub additional_details: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f22758182..787ec398d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -302,7 +302,7 @@ Event notifications are the server-initiated event stream for thread lifecycles, The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` status). Token usage events stream separately via `thread/tokenUsage/updated`. Clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`. - `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`. -- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo? } }`. +- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. @@ -352,7 +352,7 @@ There are additional item-specific events: ### Errors -`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo? } }` payload as `turn.status: "failed"` and may precede that terminal notification. +`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo?, additionalDetails? } }` payload as `turn.status: "failed"` and may precede that terminal notification. `codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values: diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f7e4f709e..ad0455a05 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -340,6 +340,7 @@ pub(crate) async fn apply_bespoke_event_handling( let turn_error = TurnError { message: ev.message, codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from), + additional_details: None, }; handle_error(conversation_id, turn_error.clone(), &turn_summary_store).await; outgoing @@ -357,6 +358,7 @@ pub(crate) async fn apply_bespoke_event_handling( let turn_error = TurnError { message: ev.message, codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from), + additional_details: ev.additional_details, }; outgoing .send_server_notification(ServerNotification::Error(ErrorNotification { @@ -1340,6 +1342,7 @@ mod tests { TurnError { message: "boom".to_string(), codex_error_info: Some(V2CodexErrorInfo::InternalServerError), + additional_details: None, }, &turn_summary_store, ) @@ -1351,6 +1354,7 @@ mod tests { Some(TurnError { message: "boom".to_string(), codex_error_info: Some(V2CodexErrorInfo::InternalServerError), + additional_details: None, }) ); Ok(()) @@ -1398,6 +1402,7 @@ mod tests { TurnError { message: "oops".to_string(), codex_error_info: None, + additional_details: None, }, &turn_summary_store, ) @@ -1439,6 +1444,7 @@ mod tests { TurnError { message: "bad".to_string(), codex_error_info: Some(V2CodexErrorInfo::Other), + additional_details: None, }, &turn_summary_store, ) @@ -1467,6 +1473,7 @@ mod tests { Some(TurnError { message: "bad".to_string(), codex_error_info: Some(V2CodexErrorInfo::Other), + additional_details: None, }) ); } @@ -1691,6 +1698,7 @@ mod tests { TurnError { message: "a1".to_string(), codex_error_info: Some(V2CodexErrorInfo::BadRequest), + additional_details: None, }, &turn_summary_store, ) @@ -1710,6 +1718,7 @@ mod tests { TurnError { message: "b1".to_string(), codex_error_info: None, + additional_details: None, }, &turn_summary_store, ) @@ -1746,6 +1755,7 @@ mod tests { Some(TurnError { message: "a1".to_string(), codex_error_info: Some(V2CodexErrorInfo::BadRequest), + additional_details: None, }) ); } @@ -1766,6 +1776,7 @@ mod tests { Some(TurnError { message: "b1".to_string(), codex_error_info: None, + additional_details: None, }) ); } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 92ce74e3f..5fb86e871 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1422,12 +1422,14 @@ impl Session { message: impl Into, codex_error: CodexErr, ) { + let additional_details = codex_error.to_string(); let codex_error_info = CodexErrorInfo::ResponseStreamDisconnected { http_status_code: codex_error.http_status_code_value(), }; let event = EventMsg::StreamError(StreamErrorEvent { message: message.into(), codex_error_info: Some(codex_error_info), + additional_details: Some(additional_details), }); self.send_event(turn_context, event).await; } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index a43718d56..40afab7c9 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -222,7 +222,15 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { ts_msg!(self, "{}", message.style(self.dimmed)); } - EventMsg::StreamError(StreamErrorEvent { message, .. }) => { + EventMsg::StreamError(StreamErrorEvent { + message, + additional_details, + .. + }) => { + let message = match additional_details { + Some(details) if !details.trim().is_empty() => format!("{message} ({details})"), + _ => message, + }; ts_msg!(self, "{}", message.style(self.dimmed)); } EventMsg::TaskStarted(_) => { diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 03c51662b..0b2df5445 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -145,9 +145,15 @@ impl EventProcessorWithJsonOutput { }; vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } - EventMsg::StreamError(ev) => vec![ThreadEvent::Error(ThreadErrorEvent { - message: ev.message.clone(), - })], + EventMsg::StreamError(ev) => { + let message = match &ev.additional_details { + Some(details) if !details.trim().is_empty() => { + format!("{} ({})", ev.message, details) + } + _ => ev.message.clone(), + }; + vec![ThreadEvent::Error(ThreadErrorEvent { message })] + } EventMsg::PlanUpdate(ev) => self.handle_plan_update(ev), _ => Vec::new(), } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 2b3673f5a..d288f568e 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -583,6 +583,7 @@ fn stream_error_event_produces_error() { EventMsg::StreamError(codex_core::protocol::StreamErrorEvent { message: "retrying".to_string(), codex_error_info: Some(CodexErrorInfo::Other), + additional_details: None, }), )); assert_eq!( diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index f2e31c903..cff2a8ad9 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1600,6 +1600,11 @@ pub struct StreamErrorEvent { pub message: String, #[serde(default)] pub codex_error_info: Option, + /// Optional details about the underlying stream failure (often the same + /// human-readable message that is surfaced as the terminal error if retries + /// are exhausted). + #[serde(default)] + pub additional_details: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4578f5824..819ea2b95 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1099,11 +1099,11 @@ impl ChatWidget { } } - fn on_stream_error(&mut self, message: String) { + fn on_stream_error(&mut self, message: String, additional_details: Option) { if self.retry_status_header.is_none() { self.retry_status_header = Some(self.current_status_header.clone()); } - self.set_status_header(message); + self.set_status(message, additional_details); } /// Periodic tick to commit at most one queued line to history with a small delay, @@ -2102,9 +2102,11 @@ impl ChatWidget { } EventMsg::UndoStarted(ev) => self.on_undo_started(ev), EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), - EventMsg::StreamError(StreamErrorEvent { message, .. }) => { - self.on_stream_error(message) - } + EventMsg::StreamError(StreamErrorEvent { + message, + additional_details, + .. + }) => self.on_stream_error(message, additional_details), EventMsg::UserMessage(ev) => { if from_replay { self.on_user_message_event(ev); diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index ae3e7abd6..d8d50e5db 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3223,11 +3223,13 @@ async fn stream_error_updates_status_indicator() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.bottom_pane.set_task_running(true); let msg = "Reconnecting... 2/5"; + let details = "Idle timeout waiting for SSE"; chat.handle_codex_event(Event { id: "sub-1".into(), msg: EventMsg::StreamError(StreamErrorEvent { message: msg.to_string(), codex_error_info: Some(CodexErrorInfo::Other), + additional_details: Some(details.to_string()), }), }); @@ -3241,6 +3243,7 @@ async fn stream_error_updates_status_indicator() { .status_widget() .expect("status indicator should be visible"); assert_eq!(status.header(), msg); + assert_eq!(status.details(), Some(details)); } #[tokio::test] @@ -3277,6 +3280,7 @@ async fn stream_recovery_restores_previous_status_header() { msg: EventMsg::StreamError(StreamErrorEvent { message: "Reconnecting... 1/5".to_string(), codex_error_info: Some(CodexErrorInfo::Other), + additional_details: None, }), }); drain_insert_history(&mut rx); diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 723390ef8..4363c1550 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -959,11 +959,11 @@ impl ChatWidget { } } - fn on_stream_error(&mut self, message: String) { + fn on_stream_error(&mut self, message: String, additional_details: Option) { if self.retry_status_header.is_none() { self.retry_status_header = Some(self.current_status_header.clone()); } - self.set_status_header(message); + self.set_status(message, additional_details); } /// Periodic tick to commit at most one queued line to history with a small delay, @@ -1905,9 +1905,11 @@ impl ChatWidget { } EventMsg::UndoStarted(ev) => self.on_undo_started(ev), EventMsg::UndoCompleted(ev) => self.on_undo_completed(ev), - EventMsg::StreamError(StreamErrorEvent { message, .. }) => { - self.on_stream_error(message) - } + EventMsg::StreamError(StreamErrorEvent { + message, + additional_details, + .. + }) => self.on_stream_error(message, additional_details), EventMsg::UserMessage(ev) => { if from_replay { self.on_user_message_event(ev); diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index a71be3a63..577c0db2b 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -2875,11 +2875,13 @@ async fn stream_error_updates_status_indicator() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; chat.bottom_pane.set_task_running(true); let msg = "Reconnecting... 2/5"; + let details = "Idle timeout waiting for SSE"; chat.handle_codex_event(Event { id: "sub-1".into(), msg: EventMsg::StreamError(StreamErrorEvent { message: msg.to_string(), codex_error_info: Some(CodexErrorInfo::Other), + additional_details: Some(details.to_string()), }), }); @@ -2893,6 +2895,7 @@ async fn stream_error_updates_status_indicator() { .status_widget() .expect("status indicator should be visible"); assert_eq!(status.header(), msg); + assert_eq!(status.details(), Some(details)); } #[tokio::test] @@ -2929,6 +2932,7 @@ async fn stream_recovery_restores_previous_status_header() { msg: EventMsg::StreamError(StreamErrorEvent { message: "Reconnecting... 1/5".to_string(), codex_error_info: Some(CodexErrorInfo::Other), + additional_details: None, }), }); drain_insert_history(&mut rx);