diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index c0983a3dba..037e5aba6b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3935,6 +3935,7 @@ async fn turn_event_counts_completed_tool_items() { revised_prompt: None, result: "ok".to_string(), saved_path: None, + error: None, }, ]; diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 1c37ea6f33..ad4ac8af3b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -4225,6 +4225,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f8ddf93297..d307640663 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -17182,6 +17182,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e04aca08f6..7efe68b7ba 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -14990,6 +14990,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 162f3aa3d9..7ed9f7eae8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -1089,6 +1089,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index af3b1ddd1b..5f9ce63345 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -1089,6 +1089,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 8918f6cae4..5c7751ad72 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1233,6 +1233,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index fa1277b0ef..e9a86039eb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1717,6 +1717,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 1b53d4ca2e..95a11a98be 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -1532,6 +1532,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index c98fa1f2a5..963c60d1d6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -1532,6 +1532,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 345d84d794..a479cce513 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -1532,6 +1532,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 98ef79517b..27056ab1af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1717,6 +1717,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index f63dfe6ffb..b06f9f7c69 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -1532,6 +1532,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 052023361a..3f95ebef8a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1717,6 +1717,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index cdeda64b7c..946e10319a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -1532,6 +1532,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 2a7281fcbb..50e64ba431 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -1532,6 +1532,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 3cc329c9e5..f02a85ce65 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1233,6 +1233,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 7dbea8af53..e5c5c99f65 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1233,6 +1233,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 362e789ea8..c3db6ef336 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1233,6 +1233,12 @@ }, { "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, "id": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index 8d74ae8de2..10afa7863a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -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, }; 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 f0b900f08f..69d9613102 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -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()), }, ], } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index f556890a96..c6dee79792 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -363,6 +363,7 @@ pub enum ThreadItem { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] saved_path: Option, + error: Option, }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] @@ -840,6 +841,7 @@ impl From 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, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 956bbb49c1..b483cc8764 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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. diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index e970a88a9f..df213d1e29 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -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, }, ]); diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 5c18aa8a4e..da03a56b01 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -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()), } ); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index cf797ff6be..491bbad4f2 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -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() diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index e9383aef41..9c30f40207 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -206,6 +206,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { revised_prompt: revised_prompt.clone(), result: result.clone(), saved_path: None, + error: None, }, )), _ => None, diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 33625317fe..08e4980b7b 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -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!( diff --git a/codex-rs/ext/image-generation/src/backend.rs b/codex-rs/ext/image-generation/src/backend.rs index 9b837772c7..4b303c8665 100644 --- a/codex-rs/ext/image-generation/src/backend.rs +++ b/codex-rs/ext/image-generation/src/backend.rs @@ -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, + detail: Option, +} + +#[derive(Deserialize)] +struct ErrorMessage { + message: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum ErrorDetail { + Message(String), + Object { message: Option }, +} + +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 { + let payload = serde_json::from_str::(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; diff --git a/codex-rs/ext/image-generation/src/backend_tests.rs b/codex-rs/ext/image-generation/src/backend_tests.rs new file mode 100644 index 0000000000..fa45f58209 --- /dev/null +++ b/codex-rs/ext/image-generation/src/backend_tests.rs @@ -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), + }) +} diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 3712ebbb0e..674da49f4e 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -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 = diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index ee61dc3949..2dc01d3c31 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -151,6 +151,9 @@ pub struct ImageGenerationItem { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub saved_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, } #[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(), }) } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 07ebd30260..08c3010c5c 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2358,6 +2358,9 @@ pub struct ImageGenerationEndEvent { #[serde(skip_serializing_if = "Option::is_none")] #[ts(optional)] pub saved_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, } // 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, }; diff --git a/codex-rs/tui/src/chatwidget/replay.rs b/codex-rs/tui/src/chatwidget/replay.rs index ae05a7198e..5677526c48 100644 --- a/codex-rs/tui/src/chatwidget/replay.rs +++ b/codex-rs/tui/src/chatwidget/replay.rs @@ -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 { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__failed_image_generation_call_history_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__failed_image_generation_call_history_snapshot.snap index c69c51f27e..1afcdc9e8c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__failed_image_generation_call_history_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__failed_image_generation_call_history_snapshot.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index ca1c9bc681..3a043c1598 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -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); diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index d155376478..05b6308525 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -697,6 +697,7 @@ pub(super) fn handle_image_generation_end( status: impl Into, revised_prompt: Option, saved_path: Option, + error: Option, ) { 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, diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs index e37c44a4fa..e4e29c155d 100644 --- a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -32,6 +32,7 @@ impl ChatWidget { status: String, revised_prompt: Option, saved_path: Option, + error: Option, ) { 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(); } diff --git a/codex-rs/tui/src/history_cell/patches.rs b/codex-rs/tui/src/history_cell/patches.rs index 8a2dcb33f3..75524ef95f 100644 --- a/codex-rs/tui/src/history_cell/patches.rs +++ b/codex-rs/tui/src/history_cell/patches.rs @@ -76,9 +76,18 @@ pub(crate) fn new_image_generation_call( status: &str, revised_prompt: Option, saved_path: Option, + error: Option, ) -> 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() diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 4d23392128..b9d84b143b 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -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!(