Handle standalone image generation failures as terminal items (#27920)

## Why

Standalone image generation emitted a started item but no terminal item
when the backend failed. Clients could leave the operation unresolved or
render it as successful.

## What changed

- Emit a terminal image-generation item with `status: "failed"` when
generation or editing fails.
- Skip image persistence for failed terminal items.
- Render failed image generation distinctly in TUI history.
- Preserve the status when handling live and replayed terminal items.

## Looks for TUI, App-Side change needed 

<img width="867" height="89" alt="image"
src="https://github.com/user-attachments/assets/9e32342f-a982-411e-8498-456639fc468a"
/>

## Validation

- `just test -p codex-image-generation-extension`
- App-server image-generation tests
- Core stream-event tests
- TUI image-generation lifecycle and snapshot tests
- Scoped Clippy and formatting
This commit is contained in:
Won Park authored and GitHub committed 2026-06-12 11:57:22 -07:00
1 parent e26f734f91
commit b6baa77eec
10 files changed
+142 -17

No files matched your search

+2 -1
View File
@@ -151,11 +151,12 @@ impl ChatWidget {
}
ThreadItem::ImageGeneration {
id,
status,
revised_prompt,
saved_path,
..
} => {
self.on_image_generation_end(id, revised_prompt, saved_path);
self.on_image_generation_end(id, status, revised_prompt, saved_path);
}
ThreadItem::EnteredReviewMode { review, .. } => {
if from_replay {
@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests/exec_flow.rs
expression: "lines_to_single_string(&cells[0])"
---
✗ Image generation failed
└ A tiny blue square
@@ -864,6 +864,7 @@ async fn image_generation_call_adds_history_cell() {
handle_image_generation_end(
&mut chat,
"call-image-generation",
"completed",
Some("A tiny blue square".into()),
Some(test_path_buf("/tmp/ig-1.png").abs()),
);
@@ -876,6 +877,21 @@ async fn image_generation_call_adds_history_cell() {
let combined =
lines_to_single_string(&cells[0]).replace(&platform_file_url, "file:///tmp/ig-1.png");
assert_chatwidget_snapshot!("image_generation_call_history_snapshot", combined);
handle_image_generation_end(
&mut chat,
"call-image-generation-failed",
"failed",
Some("A tiny blue square".into()),
/*saved_path*/ None,
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected a single failure history cell");
assert_chatwidget_snapshot!(
"failed_image_generation_call_history_snapshot",
lines_to_single_string(&cells[0])
);
}
#[tokio::test]
+2 -1
View File
@@ -694,6 +694,7 @@ pub(super) fn handle_view_image_tool_call(
pub(super) fn handle_image_generation_end(
chat: &mut ChatWidget,
call_id: impl Into<String>,
status: impl Into<String>,
revised_prompt: Option<String>,
saved_path: Option<AbsolutePathBuf>,
) {
@@ -704,7 +705,7 @@ pub(super) fn handle_image_generation_end(
completed_at_ms: 0,
item: AppServerThreadItem::ImageGeneration {
id: call_id.into(),
status: "completed".to_string(),
status: status.into(),
revised_prompt,
result: String::new(),
saved_path,
@@ -29,12 +29,14 @@ impl ChatWidget {
pub(super) fn on_image_generation_end(
&mut self,
call_id: String,
status: String,
revised_prompt: Option<String>,
saved_path: Option<AbsolutePathBuf>,
) {
self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_image_generation_call(
call_id,
&status,
revised_prompt,
saved_path,
));
+8 -6
View File
@@ -73,15 +73,17 @@ pub(crate) fn new_view_image_tool_call(path: AbsolutePathBuf, cwd: &Path) -> Pla
pub(crate) fn new_image_generation_call(
call_id: String,
status: &str,
revised_prompt: Option<String>,
saved_path: Option<AbsolutePathBuf>,
) -> PlainHistoryCell {
let detail = revised_prompt.unwrap_or_else(|| call_id.clone());
let mut lines: Vec<Line<'static>> = vec![
vec!["• ".dim(), "Generated Image:".bold()].into(),
vec![" └ ".dim(), detail.dim()].into(),
];
let detail = revised_prompt.unwrap_or(call_id);
let heading = if status == "failed" {
vec!["✗ ".red().bold(), "Image generation failed".bold()].into()
} else {
vec!["• ".dim(), "Generated Image:".bold()].into()
};
let mut lines: Vec<Line<'static>> = vec![heading, vec![" └ ".dim(), detail.dim()].into()];
if let Some(saved_path) = saved_path {
let saved_path = Url::from_file_path(saved_path.as_path())
.map(|url| url.to_string())
+1
View File
@@ -474,6 +474,7 @@ fn image_generation_call_renders_saved_path() {
);
let cell = new_image_generation_call(
"call-image-generation".to_string(),
"completed",
Some("A tiny blue square".to_string()),
Some(saved_path),
);