imagegen: preserve backend error messages

This commit is contained in:
won
2026-06-12 18:13:13 -07:00
parent b724f5966e
commit 5d367b21e2
40 changed files with 308 additions and 15 deletions
@@ -3935,6 +3935,7 @@ async fn turn_event_counts_completed_tool_items() {
revised_prompt: None,
result: "ok".to_string(),
saved_path: None,
error: None,
},
];
@@ -4225,6 +4225,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -17182,6 +17182,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -14990,6 +14990,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1089,6 +1089,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1089,6 +1089,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1233,6 +1233,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1717,6 +1717,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1532,6 +1532,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1532,6 +1532,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1532,6 +1532,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1717,6 +1717,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1532,6 +1532,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1717,6 +1717,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1532,6 +1532,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1532,6 +1532,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1233,6 +1233,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1233,6 +1233,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -1233,6 +1233,12 @@
},
{
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"id": {
"type": "string"
},
@@ -99,4 +99,4 @@ reasoningEffort: ReasoningEffort | null,
/**
* Last known status of the target agents, when available.
*/
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: AbsolutePathBuf, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
agentsStates: { [key in string]?: CollabAgentState }, } | { "type": "subAgentActivity", id: string, kind: SubAgentActivityKind, agentThreadId: string, agentPath: string, } | { "type": "webSearch", id: string, query: string, action: WebSearchAction | null, } | { "type": "imageView", id: string, path: AbsolutePathBuf, } | { "type": "imageGeneration", id: string, status: string, revisedPrompt: string | null, result: string, savedPath?: AbsolutePathBuf, error: string | null, } | { "type": "enteredReviewMode", id: string, review: string, } | { "type": "exitedReviewMode", id: string, review: string, } | { "type": "contextCompaction", id: string, };
@@ -603,6 +603,7 @@ impl ThreadHistoryBuilder {
revised_prompt: None,
result: String::new(),
saved_path: None,
error: None,
};
self.upsert_item_in_current_turn(item);
}
@@ -614,6 +615,7 @@ impl ThreadHistoryBuilder {
revised_prompt: payload.revised_prompt.clone(),
result: payload.result.clone(),
saved_path: payload.saved_path.clone(),
error: payload.error.clone(),
};
self.upsert_item_in_current_turn(item);
}
@@ -1582,10 +1584,11 @@ mod tests {
})),
RolloutItem::EventMsg(EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {
call_id: "ig_123".into(),
status: "completed".into(),
status: "failed".into(),
revised_prompt: Some("final prompt".into()),
result: "Zm9v".into(),
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
result: String::new(),
saved_path: None,
error: Some("bad image request".into()),
})),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id: "turn-image".into(),
@@ -1619,10 +1622,11 @@ mod tests {
},
ThreadItem::ImageGeneration {
id: "ig_123".into(),
status: "completed".into(),
status: "failed".into(),
revised_prompt: Some("final prompt".into()),
result: "Zm9v".into(),
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
result: String::new(),
saved_path: None,
error: Some("bad image request".into()),
},
],
}
@@ -363,6 +363,7 @@ pub enum ThreadItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
saved_path: Option<AbsolutePathBuf>,
error: Option<String>,
},
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
@@ -840,6 +841,7 @@ impl From<CoreTurnItem> for ThreadItem {
revised_prompt: image.revised_prompt,
result: image.result,
saved_path: image.saved_path,
error: image.error,
},
CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange {
id: file_change.id,
+1
View File
@@ -1316,6 +1316,7 @@ Today both notifications carry an empty `items` array even when item events were
- `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`.
- `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion.
- `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool.
- `imageGeneration` — `{id, status, revisedPrompt, result, savedPath?, error}` for standalone image generation; failed items use `status: "failed"`, an empty `result`, and a user-facing `error` message, while successful and in-progress items set `error` to `null`.
- `enteredReviewMode` — `{id, review}` sent when the reviewer starts; `review` is a short user-facing label such as `"current changes"` or the requested target description.
- `exitedReviewMode` — `{id, review}` emitted when the reviewer finishes; `review` is the full plain-text review (usually, overall notes plus bullet point findings).
- `contextCompaction` — `{id}` emitted when codex compacts the conversation history. This can happen automatically.
@@ -97,6 +97,7 @@ mod tests {
revised_prompt: Some("revised".to_string()),
result: "base64-result".to_string(),
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
error: None,
},
]);
@@ -203,6 +203,7 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()>
revised_prompt: Some("paint a blue whale".to_string()),
result: String::new(),
saved_path: None,
error: Some("image backend failed".to_string()),
}
);
@@ -806,6 +806,7 @@ fn append_resume_redaction_history(
revised_prompt: Some("secret revised prompt".to_string()),
result: "base64-image-result".to_string(),
saved_path: Some(test_absolute_path("/tmp/ig-1.png")),
error: None,
}),
]
.into_iter()
+1
View File
@@ -206,6 +206,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option<TurnItem> {
revised_prompt: revised_prompt.clone(),
result: result.clone(),
saved_path: None,
error: None,
},
)),
_ => None,
@@ -504,6 +504,7 @@ mod tests {
revised_prompt: None,
result: String::new(),
saved_path: None,
error: None,
},
))
.await;
@@ -515,6 +516,7 @@ mod tests {
revised_prompt: Some("A tiny blue square".to_string()),
result: "cG5n".to_string(),
saved_path: Some(test_path_buf("/tmp/extension-claimed.png").abs()),
error: None,
},
))
.await;
@@ -578,6 +580,7 @@ mod tests {
revised_prompt: None,
result: String::new(),
saved_path: None,
error: None,
}
);
assert_eq!(
@@ -588,6 +591,7 @@ mod tests {
revised_prompt: Some("A tiny blue square".to_string()),
result: "cG5n".to_string(),
saved_path: Some(expected_path.clone()),
error: None,
}
);
assert_eq!(
+73 -2
View File
@@ -1,11 +1,16 @@
use codex_api::ApiError;
use codex_api::ImageEditRequest;
use codex_api::ImageGenerationRequest;
use codex_api::ImageResponse;
use codex_api::ImagesClient;
use codex_api::ReqwestTransport;
use codex_api::TransportError;
use codex_login::default_client::build_reqwest_client;
use codex_model_provider::SharedModelProvider;
use http::HeaderMap;
use serde::Deserialize;
const MAX_ERROR_MESSAGE_CHARS: usize = 4000;
#[derive(Clone)]
pub(crate) struct CodexImagesBackend {
@@ -46,7 +51,7 @@ impl CodexImagesBackend {
.await?
.generate(&request, HeaderMap::new())
.await
.map_err(|err| err.to_string())
.map_err(image_api_error_message)
}
/// Sends a standalone image edit request through the configured Images client.
@@ -55,6 +60,72 @@ impl CodexImagesBackend {
.await?
.edit(&request, HeaderMap::new())
.await
.map_err(|err| err.to_string())
.map_err(image_api_error_message)
}
}
#[derive(Deserialize)]
struct ErrorPayload {
error: Option<ErrorMessage>,
detail: Option<ErrorDetail>,
}
#[derive(Deserialize)]
struct ErrorMessage {
message: Option<String>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ErrorDetail {
Message(String),
Object { message: Option<String> },
}
fn image_api_error_message(err: ApiError) -> String {
let message = if let ApiError::Transport(TransportError::Http {
body: Some(body), ..
}) = &err
{
if let Some(message) = parse_error_message(body) {
message
} else if !body.trim().is_empty() {
body.trim().to_string()
} else {
err.to_string()
}
} else {
err.to_string()
};
truncate_error_message(&message)
}
fn parse_error_message(body: &str) -> Option<String> {
let payload = serde_json::from_str::<ErrorPayload>(body).ok()?;
payload
.error
.and_then(|error| error.message)
.or(match payload.detail {
Some(ErrorDetail::Message(message)) => Some(message),
Some(ErrorDetail::Object { message }) => message,
None => None,
})
.filter(|message| !message.trim().is_empty())
}
fn truncate_error_message(message: &str) -> String {
if message.chars().count() <= MAX_ERROR_MESSAGE_CHARS {
return message.to_string();
}
message
.chars()
.take(MAX_ERROR_MESSAGE_CHARS - 1)
.chain(std::iter::once('…'))
.collect()
}
#[cfg(test)]
#[path = "backend_tests.rs"]
mod tests;
@@ -0,0 +1,69 @@
use codex_api::ApiError;
use codex_api::TransportError;
use http::StatusCode;
use pretty_assertions::assert_eq;
use super::MAX_ERROR_MESSAGE_CHARS;
use super::image_api_error_message;
#[test]
fn image_api_error_message_extracts_codex_backend_error_envelopes() {
let cases = [
(
r#"{"error":{"message":"bad image request"}}"#,
"bad image request",
),
(r#"{"detail":"Forbidden"}"#, "Forbidden"),
(
r#"{"detail":{"message":"workspace access denied","code":"codex_workspace_access_denied"}}"#,
"workspace access denied",
),
(
r#"{"detail":{"message":"current message"},"error":{"message":"legacy message"}}"#,
"legacy message",
),
];
for (body, expected) in cases {
assert_eq!(
image_api_error_message(http_error(body.to_string())),
expected
);
}
}
#[test]
fn image_api_error_message_truncates_plain_text_body() {
let body = "x".repeat(MAX_ERROR_MESSAGE_CHARS + 1);
let message = image_api_error_message(http_error(body));
assert_eq!(message.chars().count(), MAX_ERROR_MESSAGE_CHARS);
assert!(message.ends_with('…'));
}
#[test]
fn image_api_error_message_truncates_json_message() {
let backend_message = "x".repeat(MAX_ERROR_MESSAGE_CHARS + 1);
let body = serde_json::json!({
"error": {
"message": backend_message,
},
})
.to_string();
let message = image_api_error_message(http_error(body));
assert_eq!(message.chars().count(), MAX_ERROR_MESSAGE_CHARS);
assert!(message.ends_with('…'));
assert!(!message.contains("error"));
}
fn http_error(body: String) -> ApiError {
ApiError::Transport(TransportError::Http {
status: StatusCode::BAD_REQUEST,
url: None,
headers: None,
body: Some(body),
})
}
+5 -2
View File
@@ -115,13 +115,13 @@ impl ImageGenerationTool {
revised_prompt: None,
result: String::new(),
saved_path: None,
error: None,
}))
.await;
let result = match request {
ImageRequest::Generate(request) => self.backend.generate(request).await,
ImageRequest::Edit(request) => self.backend.edit(request).await,
}
.map_err(|err| format!("image generation failed: {err}"))
.and_then(|response| {
response
.data
@@ -133,6 +133,7 @@ impl ImageGenerationTool {
let result = match result {
Ok(result) => result,
Err(message) => {
let model_message = format!("image generation failed: {message}");
call.turn_item_emitter
.emit_completed(ExtensionTurnItem::ImageGeneration(ImageGenerationItem {
id: call.call_id.clone(),
@@ -140,9 +141,10 @@ impl ImageGenerationTool {
revised_prompt: Some(args.prompt.clone()),
result: String::new(),
saved_path: None,
error: Some(message),
}))
.await;
return Err(FunctionCallError::RespondToModel(message));
return Err(FunctionCallError::RespondToModel(model_message));
}
};
call.turn_item_emitter
@@ -152,6 +154,7 @@ impl ImageGenerationTool {
revised_prompt: Some(args.prompt),
result: result.clone(),
saved_path: None,
error: None,
}))
.await;
let output_path =
+4
View File
@@ -151,6 +151,9 @@ pub struct ImageGenerationItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub saved_path: Option<AbsolutePathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub error: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
@@ -501,6 +504,7 @@ impl ImageGenerationItem {
revised_prompt: self.revised_prompt.clone(),
result: self.result.clone(),
saved_path: self.saved_path.clone(),
error: self.error.clone(),
})
}
}
+5
View File
@@ -2358,6 +2358,9 @@ pub struct ImageGenerationEndEvent {
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub saved_path: Option<AbsolutePathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub error: Option<String>,
}
// Conversation kept for backward compatibility.
@@ -4737,6 +4740,7 @@ mod tests {
revised_prompt: None,
result: String::new(),
saved_path: None,
error: None,
}),
started_at_ms: 0,
};
@@ -4833,6 +4837,7 @@ mod tests {
revised_prompt: Some("A tiny blue square".into()),
result: "Zm9v".into(),
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
error: None,
}),
completed_at_ms: 0,
};
+2 -1
View File
@@ -154,9 +154,10 @@ impl ChatWidget {
status,
revised_prompt,
saved_path,
error,
..
} => {
self.on_image_generation_end(id, status, revised_prompt, saved_path);
self.on_image_generation_end(id, status, revised_prompt, saved_path, error);
}
ThreadItem::EnteredReviewMode { review, .. } => {
if from_replay {
@@ -3,4 +3,4 @@ source: tui/src/chatwidget/tests/exec_flow.rs
expression: "lines_to_single_string(&cells[0])"
---
✗ Image generation failed
└ A tiny blue square
└ Error: image backend failed
@@ -867,6 +867,7 @@ async fn image_generation_call_adds_history_cell() {
"completed",
Some("A tiny blue square".into()),
Some(test_path_buf("/tmp/ig-1.png").abs()),
None,
);
let cells = drain_insert_history(&mut rx);
@@ -884,6 +885,7 @@ async fn image_generation_call_adds_history_cell() {
"failed",
Some("A tiny blue square".into()),
/*saved_path*/ None,
Some("image backend failed".into()),
);
let cells = drain_insert_history(&mut rx);
@@ -697,6 +697,7 @@ pub(super) fn handle_image_generation_end(
status: impl Into<String>,
revised_prompt: Option<String>,
saved_path: Option<AbsolutePathBuf>,
error: Option<String>,
) {
chat.handle_server_notification(
ServerNotification::ItemCompleted(ItemCompletedNotification {
@@ -709,6 +710,7 @@ pub(super) fn handle_image_generation_end(
revised_prompt,
result: String::new(),
saved_path,
error,
},
}),
/*replay_kind*/ None,
@@ -32,6 +32,7 @@ impl ChatWidget {
status: String,
revised_prompt: Option<String>,
saved_path: Option<AbsolutePathBuf>,
error: Option<String>,
) {
self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_image_generation_call(
@@ -39,6 +40,7 @@ impl ChatWidget {
&status,
revised_prompt,
saved_path,
error,
));
self.request_redraw();
}
+11 -2
View File
@@ -76,9 +76,18 @@ pub(crate) fn new_image_generation_call(
status: &str,
revised_prompt: Option<String>,
saved_path: Option<AbsolutePathBuf>,
error: Option<String>,
) -> PlainHistoryCell {
let detail = revised_prompt.unwrap_or(call_id);
let heading = if status == "failed" {
let failed = status == "failed";
let detail = if failed {
error
.map(|error| format!("Error: {}", truncate_text(error.trim(), 1000)))
.or(revised_prompt)
.unwrap_or(call_id)
} else {
revised_prompt.unwrap_or(call_id)
};
let heading = if failed {
vec!["✗ ".red().bold(), "Image generation failed".bold()].into()
} else {
vec!["• ".dim(), "Generated Image:".bold()].into()
+1
View File
@@ -477,6 +477,7 @@ fn image_generation_call_renders_saved_path() {
"completed",
Some("A tiny blue square".to_string()),
Some(saved_path),
None,
);
assert_eq!(