[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:
<img width="1012" height="326" alt="image"
src="https://github.com/user-attachments/assets/f35e7e6a-8f5e-4a2f-a764-358101776996"
/>

<img width="1526" height="358" alt="image"
src="https://github.com/user-attachments/assets/0029cbc0-f062-4233-8650-cc216c7808f0"
/>
This commit is contained in:
sayan-oai 2025-12-24 10:07:38 -08:00 committed by GitHub
parent 38de0a1de4
commit bf732600ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 63 additions and 16 deletions

View file

@ -1274,6 +1274,8 @@ pub struct Turn {
pub struct TurnError {
pub message: String,
pub codex_error_info: Option<CodexErrorInfo>,
#[serde(default)]
pub additional_details: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]

View file

@ -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:

View file

@ -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,
})
);
}

View file

@ -1422,12 +1422,14 @@ impl Session {
message: impl Into<String>,
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;
}

View file

@ -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(_) => {

View file

@ -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(),
}

View file

@ -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!(

View file

@ -1600,6 +1600,11 @@ pub struct StreamErrorEvent {
pub message: String,
#[serde(default)]
pub codex_error_info: Option<CodexErrorInfo>,
/// 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<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]

View file

@ -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<String>) {
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);

View file

@ -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);

View file

@ -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<String>) {
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);

View file

@ -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);