[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:
parent
38de0a1de4
commit
bf732600ea
12 changed files with 63 additions and 16 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(_) => {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue