Expose image generation usage-limit failures (#38024)

## What changed

- Add structured `usageLimitExceeded` metadata to failed image-generation
  items, including the image limit ID and optional reset timestamp.
- Preserve the failure metadata in completion events, app-server thread
  history, thread reads and resumes, and migrated legacy rollouts.
- Export the new failure type in the generated JSON and TypeScript schemas.

## Testing

- Cover usage-limit responses through item completion, persistence, and thread
  resume.
- Verify legacy rollout migration retains image-generation failure metadata.

GitOrigin-RevId: 91c31a2a47456b6b916ee03002ff314b05946dd4
This commit is contained in:
arun eswara authored and copyberry committed 2026-08-11 15:40:52 +00:00
1 parent 4c5fc230a9
commit edcec13372
41 files changed
+1104 -18

No files matched your search

+1
View File
@@ -4252,6 +4252,7 @@ version = "0.0.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"codex-app-server-protocol", "codex-app-server-protocol",
"codex-extension-items",
"codex-git-utils", "codex-git-utils",
"codex-install-context", "codex-install-context",
"codex-otel", "codex-otel",
@@ -4787,6 +4787,7 @@ async fn turn_event_counts_completed_tool_items() {
revised_prompt: None, revised_prompt: None,
result: "ok".to_string(), result: "ok".to_string(),
transparent_background: None, transparent_background: None,
failure: None,
saved_path: None, saved_path: None,
}), }),
]; ];
@@ -2286,6 +2286,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"ItemCompletedNotification": { "ItemCompletedNotification": {
"properties": { "properties": {
"completedAtMs": { "completedAtMs": {
@@ -4706,6 +4737,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -12159,6 +12159,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"InputModality": { "InputModality": {
"description": "Canonical user-input modality tags advertised by a model.", "description": "Canonical user-input modality tags advertised by a model.",
"oneOf": [ "oneOf": [
@@ -19950,6 +19981,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/v2/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -8435,6 +8435,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"InitializeCapabilities": { "InitializeCapabilities": {
"description": "Client-declared capabilities negotiated during initialize.", "description": "Client-declared capabilities negotiated during initialize.",
"properties": { "properties": {
@@ -17709,6 +17740,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -314,6 +314,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1216,6 +1247,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -314,6 +314,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1216,6 +1247,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -452,6 +452,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1361,6 +1392,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -556,6 +556,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1910,6 +1941,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -478,6 +478,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1701,6 +1732,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -478,6 +478,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1701,6 +1732,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -478,6 +478,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1701,6 +1732,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -556,6 +556,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1910,6 +1941,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -478,6 +478,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1701,6 +1732,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -556,6 +556,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1910,6 +1941,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -478,6 +478,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1701,6 +1732,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -478,6 +478,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1701,6 +1732,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -452,6 +452,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1361,6 +1392,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -452,6 +452,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1361,6 +1392,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -452,6 +452,37 @@
], ],
"type": "string" "type": "string"
}, },
"ImageGenerationFailure": {
"oneOf": [
{
"properties": {
"limitId": {
"type": "string"
},
"resetsAt": {
"format": "int64",
"type": [
"integer",
"null"
]
},
"type": {
"enum": [
"usageLimitExceeded"
],
"title": "UsageLimitExceededImageGenerationFailureType",
"type": "string"
}
},
"required": [
"limitId",
"type"
],
"title": "UsageLimitExceededImageGenerationFailure",
"type": "object"
}
]
},
"LegacyAppPathString": { "LegacyAppPathString": {
"type": "string" "type": "string"
}, },
@@ -1361,6 +1392,17 @@
}, },
{ {
"properties": { "properties": {
"failure": {
"anyOf": [
{
"$ref": "#/definitions/ImageGenerationFailure"
},
{
"type": "null"
}
],
"default": null
},
"id": { "id": {
"type": "string" "type": "string"
}, },
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ImageGenerationFailure = { "type": "usageLimitExceeded", limitId: string, resetsAt: number | null, };
@@ -2,5 +2,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "./AbsolutePathBuf"; import type { AbsolutePathBuf } from "./AbsolutePathBuf";
import type { ImageGenerationFailure } from "./ImageGenerationFailure";
export type ImageGenerationItem = { id: string, status: string, revisedPrompt: string | null, result: string, transparentBackground?: boolean, savedPath?: AbsolutePathBuf, }; export type ImageGenerationItem = { id: string, status: string, revisedPrompt: string | null, result: string, transparentBackground?: boolean, failure: ImageGenerationFailure | null, savedPath?: AbsolutePathBuf, };
+1
View File
@@ -37,6 +37,7 @@ export type { GitDiffToRemoteParams } from "./GitDiffToRemoteParams";
export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse"; export type { GitDiffToRemoteResponse } from "./GitDiffToRemoteResponse";
export type { GitSha } from "./GitSha"; export type { GitSha } from "./GitSha";
export type { ImageDetail } from "./ImageDetail"; export type { ImageDetail } from "./ImageDetail";
export type { ImageGenerationFailure } from "./ImageGenerationFailure";
export type { ImageGenerationItem } from "./ImageGenerationItem"; export type { ImageGenerationItem } from "./ImageGenerationItem";
export type { InitializeCapabilities } from "./InitializeCapabilities"; export type { InitializeCapabilities } from "./InitializeCapabilities";
export type { InitializeParams } from "./InitializeParams"; export type { InitializeParams } from "./InitializeParams";
@@ -863,6 +863,7 @@ impl ThreadHistoryBuilder {
revised_prompt: None, revised_prompt: None,
result: String::new(), result: String::new(),
transparent_background: None, transparent_background: None,
failure: None,
saved_path: None, saved_path: None,
}); });
self.upsert_item_in_current_turn(item); self.upsert_item_in_current_turn(item);
@@ -875,6 +876,7 @@ impl ThreadHistoryBuilder {
revised_prompt: payload.revised_prompt.clone(), revised_prompt: payload.revised_prompt.clone(),
result: payload.result.clone(), result: payload.result.clone(),
transparent_background: payload.transparent_background, transparent_background: payload.transparent_background,
failure: payload.failure.clone(),
saved_path: payload.saved_path.clone(), saved_path: payload.saved_path.clone(),
}); });
self.upsert_item_in_current_turn(item); self.upsert_item_in_current_turn(item);
@@ -2079,6 +2081,7 @@ mod tests {
revised_prompt: Some("A blue square".to_string()), revised_prompt: Some("A blue square".to_string()),
result: "cG5n".to_string(), result: "cG5n".to_string(),
transparent_background: Some(true), transparent_background: Some(true),
failure: None,
saved_path: Some(saved_path.clone()), saved_path: Some(saved_path.clone()),
}, },
)), )),
@@ -2110,6 +2113,7 @@ mod tests {
revised_prompt: Some("A blue square".to_string()), revised_prompt: Some("A blue square".to_string()),
result: "cG5n".to_string(), result: "cG5n".to_string(),
transparent_background: Some(true), transparent_background: Some(true),
failure: None,
saved_path: Some(saved_path), saved_path: Some(saved_path),
})] })]
); );
@@ -2373,6 +2377,12 @@ mod tests {
revised_prompt: Some("final prompt".into()), revised_prompt: Some("final prompt".into()),
result: "Zm9v".into(), result: "Zm9v".into(),
transparent_background: Some(true), transparent_background: Some(true),
failure: Some(
codex_extension_items::image_generation::ImageGenerationFailure::UsageLimitExceeded {
limit_id: "image_gen".into(),
resets_at: Some(1_786_150_800),
},
),
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()), saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
})), })),
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
@@ -2413,6 +2423,12 @@ mod tests {
revised_prompt: Some("final prompt".into()), revised_prompt: Some("final prompt".into()),
result: "Zm9v".into(), result: "Zm9v".into(),
transparent_background: Some(true), transparent_background: Some(true),
failure: Some(
codex_extension_items::image_generation::ImageGenerationFailure::UsageLimitExceeded {
limit_id: "image_gen".into(),
resets_at: Some(1_786_150_800),
},
),
saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()), saved_path: Some(test_path_buf("/tmp/ig_123.png").abs()),
}), }),
], ],
@@ -15,6 +15,7 @@ use crate::protocol::item_builders::convert_patch_changes;
use crate::protocol::item_builders::review_output_text; use crate::protocol::item_builders::review_output_text;
use codex_experimental_api_macros::ExperimentalApi; use codex_experimental_api_macros::ExperimentalApi;
use codex_extension_items::ExtensionItem; use codex_extension_items::ExtensionItem;
pub use codex_extension_items::image_generation::ImageGenerationFailure;
pub use codex_extension_items::image_generation::ImageGenerationItem; pub use codex_extension_items::image_generation::ImageGenerationItem;
pub use codex_extension_items::sleep::SleepItem; pub use codex_extension_items::sleep::SleepItem;
pub use codex_extension_items::web_search::WebSearchAction; pub use codex_extension_items::web_search::WebSearchAction;
@@ -929,6 +930,7 @@ impl From<CoreTurnItem> for ThreadItem {
revised_prompt: image.revised_prompt, revised_prompt: image.revised_prompt,
result: image.result, result: image.result,
transparent_background: None, transparent_background: None,
failure: None,
saved_path: image.saved_path, saved_path: image.saved_path,
}) })
} }
@@ -107,6 +107,7 @@ mod tests {
revised_prompt: Some("revised".to_string()), revised_prompt: Some("revised".to_string()),
result: "base64-result".to_string(), result: "base64-result".to_string(),
transparent_background: None, transparent_background: None,
failure: None,
saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()), saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()),
}), }),
]); ]);
@@ -7,6 +7,7 @@ use app_test_support::ChatGptAuthFixture;
use app_test_support::MockResponsesConfig; use app_test_support::MockResponsesConfig;
use app_test_support::TestAppServer; use app_test_support::TestAppServer;
use app_test_support::write_chatgpt_auth; use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::ImageGenerationFailure;
use codex_app_server_protocol::ImageGenerationItem; use codex_app_server_protocol::ImageGenerationItem;
use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadItem;
@@ -416,6 +417,7 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()>
revised_prompt: Some("paint a blue whale".to_string()), revised_prompt: Some("paint a blue whale".to_string()),
result: String::new(), result: String::new(),
transparent_background: None, transparent_background: None,
failure: None,
saved_path: None, saved_path: None,
}) })
); );
@@ -430,10 +432,145 @@ async fn standalone_image_generation_failure_emits_terminal_item() -> Result<()>
let (output, _) = requests[1] let (output, _) = requests[1]
.function_call_output_content_and_success(call_id) .function_call_output_content_and_success(call_id)
.context("image generation function output should be present")?; .context("image generation function output should be present")?;
assert!( assert_eq!(
output output.as_deref(),
.as_deref() Some(
.is_some_and(|text| text.contains("image generation failed")) "image generation failed: http 500 Internal Server Error: Some(\"image backend failed\")"
)
);
Ok(())
}
#[tokio::test]
async fn image_generation_usage_limit_preserves_correlated_failure_metadata() -> Result<()> {
let call_id = "image-run-limited";
let reset_at = 1_786_150_800;
let server = responses::start_mock_server().await;
Mock::given(method("POST"))
.and(path("/api/codex/images/generations"))
.respond_with(
ResponseTemplate::new(429)
.insert_header("x-codex-active-limit", "image_gen")
.insert_header("x-image-gen-primary-used-percent", "100")
.insert_header("x-image-gen-primary-window-minutes", "1440")
.insert_header("x-image-gen-primary-reset-at", reset_at.to_string())
.set_body_json(json!({
"error": {
"type": "usage_limit_reached",
"message": "image limit reached",
"resets_at": reset_at,
"plan_type": "plus"
}
})),
)
.expect(1)
.mount(&server)
.await;
responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call_with_namespace(
call_id,
"image_gen",
"imagegen",
&json!({"prompt": "paint a blue whale"}).to_string(),
),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_assistant_message("msg-1", "The image limit was reached."),
responses::ev_completed("resp-2"),
]),
],
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), ImagegenTestMode::Direct)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
start_image_generation_turn(&mut mcp, ThreadStartParams::default()).await?;
let completed = timeout(
DEFAULT_READ_TIMEOUT,
wait_for_image_generation_completed(&mut mcp),
)
.await??;
let thread_id = completed.thread_id.clone();
let ThreadItem::ImageGeneration(image) = completed.item else {
panic!("expected failed image-generation item");
};
assert_eq!(image.status, "failed");
assert_eq!(
image.failure,
Some(ImageGenerationFailure::UsageLimitExceeded {
limit_id: "image_gen".to_string(),
resets_at: Some(reset_at),
})
);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let read_id = mcp
.send_thread_read_request(ThreadReadParams {
thread_id: thread_id.clone(),
include_turns: true,
})
.await?;
let ThreadReadResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??;
let persisted_failure = thread
.turns
.iter()
.flat_map(|turn| turn.items.iter())
.find_map(|item| match item {
ThreadItem::ImageGeneration(item) => item.failure.as_ref(),
_ => None,
});
assert_eq!(
persisted_failure,
Some(&ImageGenerationFailure::UsageLimitExceeded {
limit_id: "image_gen".to_string(),
resets_at: Some(reset_at),
})
);
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id,
..Default::default()
})
.await?;
let ThreadResumeResponse { thread, .. } =
timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??;
let resumed_failure = thread
.turns
.iter()
.flat_map(|turn| turn.items.iter())
.find_map(|item| match item {
ThreadItem::ImageGeneration(item) => item.failure.as_ref(),
_ => None,
});
assert_eq!(
resumed_failure,
Some(&ImageGenerationFailure::UsageLimitExceeded {
limit_id: "image_gen".to_string(),
resets_at: Some(reset_at),
})
); );
Ok(()) Ok(())
@@ -1844,6 +1844,7 @@ fn append_resume_redaction_history(
revised_prompt: Some("secret revised prompt".to_string()), revised_prompt: Some("secret revised prompt".to_string()),
result: "base64-image-result".to_string(), result: "base64-image-result".to_string(),
transparent_background: None, transparent_background: None,
failure: None,
saved_path: Some(test_absolute_path("/tmp/ig-1.png")), saved_path: Some(test_absolute_path("/tmp/ig-1.png")),
}), }),
] ]
@@ -473,6 +473,7 @@ mod tests {
revised_prompt: None, revised_prompt: None,
result: String::new(), result: String::new(),
transparent_background: None, transparent_background: None,
failure: None,
saved_path: None, saved_path: None,
}); });
let expected_completed_item = ExtensionItem::ImageGeneration(ImageGenerationItem { let expected_completed_item = ExtensionItem::ImageGeneration(ImageGenerationItem {
@@ -481,6 +482,7 @@ mod tests {
revised_prompt: Some("A tiny blue square".to_string()), revised_prompt: Some("A tiny blue square".to_string()),
result: "cG5n".to_string(), result: "cG5n".to_string(),
transparent_background: Some(true), transparent_background: Some(true),
failure: None,
saved_path: Some(expected_path.clone()), saved_path: Some(expected_path.clone()),
}); });
codex_tools::TurnItemEmitter::emit_started( codex_tools::TurnItemEmitter::emit_started(
@@ -503,6 +505,7 @@ mod tests {
revised_prompt: Some("A tiny blue square".to_string()), revised_prompt: Some("A tiny blue square".to_string()),
result: "cG5n".to_string(), result: "cG5n".to_string(),
transparent_background: Some(true), transparent_background: Some(true),
failure: None,
saved_path: Some(expected_path.clone()), saved_path: Some(expected_path.clone()),
})], })],
}, },
+39 -7
View File
@@ -3,14 +3,46 @@ use codex_api::ImageGenerationRequest;
use codex_api::ImageResponse; use codex_api::ImageResponse;
use codex_api::ImagesClient; use codex_api::ImagesClient;
use codex_api::ReqwestTransport; use codex_api::ReqwestTransport;
use codex_api::map_api_error;
use codex_login::default_client::add_originator_header; use codex_login::default_client::add_originator_header;
use codex_login::default_client::create_client; use codex_login::default_client::create_client;
use codex_model_provider::SharedModelProvider; use codex_model_provider::SharedModelProvider;
use codex_protocol::error::CodexErr;
use http::HeaderMap; use http::HeaderMap;
use http::HeaderValue; use http::HeaderValue;
const X_CODEX_IMAGE_TURN_ID_HEADER: &str = "x-codex-image-turn-id"; const X_CODEX_IMAGE_TURN_ID_HEADER: &str = "x-codex-image-turn-id";
pub(crate) struct ImageBackendError {
message: String,
codex_error: CodexErr,
}
impl ImageBackendError {
fn from_api(error: codex_api::ApiError) -> Self {
let message = error.to_string();
Self {
message,
codex_error: map_api_error(error),
}
}
fn from_message(message: String) -> Self {
Self {
codex_error: CodexErr::Stream(message.clone()),
message,
}
}
pub(crate) fn message(&self) -> &str {
&self.message
}
pub(crate) fn codex_error(&self) -> &CodexErr {
&self.codex_error
}
}
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct CodexImagesBackend { pub(crate) struct CodexImagesBackend {
provider: SharedModelProvider, provider: SharedModelProvider,
@@ -27,17 +59,17 @@ impl CodexImagesBackend {
} }
/// Resolves the provider and auth required for the current image API request. /// Resolves the provider and auth required for the current image API request.
async fn client(&self) -> Result<ImagesClient<ReqwestTransport>, String> { async fn client(&self) -> Result<ImagesClient<ReqwestTransport>, ImageBackendError> {
let provider = self let provider = self
.provider .provider
.api_provider() .api_provider()
.await .await
.map_err(|err| err.to_string())?; .map_err(|err| ImageBackendError::from_message(err.to_string()))?;
let auth = self let auth = self
.provider .provider
.api_auth() .api_auth()
.await .await
.map_err(|err| err.to_string())?; .map_err(|err| ImageBackendError::from_message(err.to_string()))?;
Ok(ImagesClient::new( Ok(ImagesClient::new(
ReqwestTransport::from_http_client(create_client()), ReqwestTransport::from_http_client(create_client()),
provider, provider,
@@ -50,7 +82,7 @@ impl CodexImagesBackend {
&self, &self,
request: ImageGenerationRequest, request: ImageGenerationRequest,
turn_id: &str, turn_id: &str,
) -> Result<ImageResponse, String> { ) -> Result<ImageResponse, ImageBackendError> {
self.client() self.client()
.await? .await?
.generate( .generate(
@@ -58,7 +90,7 @@ impl CodexImagesBackend {
image_request_headers(self.originator.as_deref(), turn_id), image_request_headers(self.originator.as_deref(), turn_id),
) )
.await .await
.map_err(|err| err.to_string()) .map_err(ImageBackendError::from_api)
} }
/// Sends a standalone image edit request through the configured Images client. /// Sends a standalone image edit request through the configured Images client.
@@ -66,7 +98,7 @@ impl CodexImagesBackend {
&self, &self,
request: ImageEditRequest, request: ImageEditRequest,
turn_id: &str, turn_id: &str,
) -> Result<ImageResponse, String> { ) -> Result<ImageResponse, ImageBackendError> {
self.client() self.client()
.await? .await?
.edit( .edit(
@@ -74,7 +106,7 @@ impl CodexImagesBackend {
image_request_headers(self.originator.as_deref(), turn_id), image_request_headers(self.originator.as_deref(), turn_id),
) )
.await .await
.map_err(|err| err.to_string()) .map_err(ImageBackendError::from_api)
} }
} }
+42 -3
View File
@@ -22,7 +22,10 @@ use codex_extension_api::ToolPayload;
use codex_extension_api::ToolSpec; use codex_extension_api::ToolSpec;
use codex_extension_api::parse_tool_input_schema; use codex_extension_api::parse_tool_input_schema;
use codex_extension_items::ExtensionItem; use codex_extension_items::ExtensionItem;
use codex_extension_items::image_generation::ImageGenerationFailure;
use codex_extension_items::image_generation::ImageGenerationItem; use codex_extension_items::image_generation::ImageGenerationItem;
use codex_protocol::error::CodexErr;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::models::ContentItem; use codex_protocol::models::ContentItem;
use codex_protocol::models::DEFAULT_IMAGE_DETAIL; use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputBody;
@@ -97,6 +100,7 @@ fn legacy_end_event(item: &ImageGenerationItem) -> EventMsg {
revised_prompt: item.revised_prompt.clone(), revised_prompt: item.revised_prompt.clone(),
result: item.result.clone(), result: item.result.clone(),
transparent_background: item.transparent_background, transparent_background: item.transparent_background,
failure: item.failure.clone(),
saved_path: item.saved_path.clone(), saved_path: item.saved_path.clone(),
}) })
} }
@@ -144,6 +148,7 @@ impl ImageGenerationTool {
revised_prompt: None, revised_prompt: None,
result: String::new(), result: String::new(),
transparent_background: None, transparent_background: None,
failure: None,
saved_path: None, saved_path: None,
}, },
EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent {
@@ -155,7 +160,12 @@ impl ImageGenerationTool {
ImageRequest::Generate(request) => self.backend.generate(request, &call.turn_id).await, ImageRequest::Generate(request) => self.backend.generate(request, &call.turn_id).await,
ImageRequest::Edit(request) => self.backend.edit(request, &call.turn_id).await, ImageRequest::Edit(request) => self.backend.edit(request, &call.turn_id).await,
} }
.map_err(|err| format!("image generation failed: {err}")) .map_err(|error| {
(
format!("image generation failed: {}", error.message()),
usage_limit_failure(error.codex_error()),
)
})
.and_then(|response| { .and_then(|response| {
let transparent_background = match response.background { let transparent_background = match response.background {
Some(ImageBackground::Transparent) => Some(true), Some(ImageBackground::Transparent) => Some(true),
@@ -167,17 +177,18 @@ impl ImageGenerationTool {
.into_iter() .into_iter()
.next() .next()
.map(|data| (data.b64_json, transparent_background)) .map(|data| (data.b64_json, transparent_background))
.ok_or_else(|| "image generation returned no image data".to_string()) .ok_or_else(|| ("image generation returned no image data".to_string(), None))
}); });
let (result, transparent_background) = match result { let (result, transparent_background) = match result {
Ok(result) => result, Ok(result) => result,
Err(message) => { Err((message, failure)) => {
let item = ImageGenerationItem { let item = ImageGenerationItem {
id: call.call_id.clone(), id: call.call_id.clone(),
status: "failed".to_string(), status: "failed".to_string(),
revised_prompt: Some(args.prompt), revised_prompt: Some(args.prompt),
result: String::new(), result: String::new(),
transparent_background: None, transparent_background: None,
failure,
saved_path: None, saved_path: None,
}; };
let legacy_event = legacy_end_event(&item); let legacy_event = legacy_end_event(&item);
@@ -218,6 +229,7 @@ impl ImageGenerationTool {
revised_prompt: Some(args.prompt), revised_prompt: Some(args.prompt),
result: result.clone(), result: result.clone(),
transparent_background, transparent_background,
failure: None,
saved_path: saved_path.clone(), saved_path: saved_path.clone(),
}; };
let legacy_event = legacy_end_event(&item); let legacy_event = legacy_end_event(&item);
@@ -235,6 +247,33 @@ impl ImageGenerationTool {
} }
} }
fn usage_limit_failure(error: &CodexErr) -> Option<ImageGenerationFailure> {
let CodexErrorDetails::UsageLimitReached(usage_limit) = error.details() else {
return None;
};
let rate_limits = usage_limit.rate_limits.as_deref()?;
let limit_id = rate_limits.limit_id.as_deref()?;
if limit_id != "image_gen" {
return None;
}
let resets_at = if let Some(reset_at) = usage_limit.resets_at.as_ref() {
Some(reset_at.timestamp())
} else {
[rate_limits.primary.as_ref(), rate_limits.secondary.as_ref()]
.into_iter()
.flatten()
.filter(|window| window.used_percent >= 100.0)
.filter_map(|window| window.resets_at)
.max()
};
Some(ImageGenerationFailure::UsageLimitExceeded {
limit_id: limit_id.to_string(),
resets_at,
})
}
async fn save_image_generation_result( async fn save_image_generation_result(
fs: &dyn ExecutorFileSystem, fs: &dyn ExecutorFileSystem,
save_root: &AbsolutePathBuf, save_root: &AbsolutePathBuf,
@@ -4,6 +4,21 @@ use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use ts_rs::TS; use ts_rs::TS;
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", rename_all = "camelCase")]
pub enum ImageGenerationFailure {
UsageLimitExceeded {
#[serde(rename = "limitId")]
#[ts(rename = "limitId")]
limit_id: String,
#[serde(rename = "resetsAt")]
#[ts(rename = "resetsAt")]
#[ts(type = "number | null")]
resets_at: Option<i64>,
},
}
// Standalone image-generation item owned by the image extension. This is also // Standalone image-generation item owned by the image extension. This is also
// the field-level representation exposed by app-server; core and rollout // the field-level representation exposed by app-server; core and rollout
// persistence only carry it inside an ExtensionItem envelope. // persistence only carry it inside an ExtensionItem envelope.
@@ -18,6 +33,8 @@ pub struct ImageGenerationItem {
#[serde(default)] #[serde(default)]
#[ts(optional)] #[ts(optional)]
pub transparent_background: Option<bool>, pub transparent_background: Option<bool>,
#[serde(default)]
pub failure: Option<ImageGenerationFailure>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)] #[ts(optional)]
pub saved_path: Option<AbsolutePathBuf>, pub saved_path: Option<AbsolutePathBuf>,
+3
View File
@@ -15,6 +15,7 @@ fn completed_image_generation_item() -> ExtensionItem {
revised_prompt: Some("A blue square".to_string()), revised_prompt: Some("A blue square".to_string()),
result: "cG5n".to_string(), result: "cG5n".to_string(),
transparent_background: None, transparent_background: None,
failure: None,
saved_path: None, saved_path: None,
}) })
} }
@@ -33,6 +34,7 @@ fn image_generation_item_preserves_stable_wire_shape() {
"revisedPrompt": "A blue square", "revisedPrompt": "A blue square",
"result": "cG5n", "result": "cG5n",
"transparentBackground": null, "transparentBackground": null,
"failure": null,
}) })
); );
assert_eq!( assert_eq!(
@@ -70,6 +72,7 @@ fn image_generation_item_preserves_authoritative_transparency() {
"revisedPrompt": "A blue square", "revisedPrompt": "A blue square",
"result": "cG5n", "result": "cG5n",
"transparentBackground": true, "transparentBackground": true,
"failure": null,
}) })
); );
assert_eq!( assert_eq!(
+1
View File
@@ -430,6 +430,7 @@ impl ImageGenerationItem {
revised_prompt: self.revised_prompt.clone(), revised_prompt: self.revised_prompt.clone(),
result: self.result.clone(), result: self.result.clone(),
transparent_background: None, transparent_background: None,
failure: None,
saved_path: self.saved_path.clone(), saved_path: self.saved_path.clone(),
}) })
} }
+4
View File
@@ -55,6 +55,7 @@ use crate::request_permissions::RequestPermissionsEvent;
use crate::request_permissions::RequestPermissionsResponse; use crate::request_permissions::RequestPermissionsResponse;
use crate::request_user_input::RequestUserInputResponse; use crate::request_user_input::RequestUserInputResponse;
use crate::user_input::UserInput; use crate::user_input::UserInput;
use codex_extension_items::image_generation::ImageGenerationFailure;
use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathUri; use codex_utils_path_uri::PathUri;
use schemars::JsonSchema; use schemars::JsonSchema;
@@ -2549,6 +2550,9 @@ pub struct ImageGenerationEndEvent {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)] #[ts(optional)]
pub transparent_background: Option<bool>, pub transparent_background: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub failure: Option<ImageGenerationFailure>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional)] #[ts(optional)]
pub saved_path: Option<AbsolutePathBuf>, pub saved_path: Option<AbsolutePathBuf>,
+1
View File
@@ -15,6 +15,7 @@ workspace = true
[dependencies] [dependencies]
chrono = { workspace = true, features = ["serde"] } chrono = { workspace = true, features = ["serde"] }
codex-app-server-protocol = { workspace = true } codex-app-server-protocol = { workspace = true }
codex-extension-items = { workspace = true }
codex-git-utils = { workspace = true } codex-git-utils = { workspace = true }
codex-install-context = { workspace = true } codex-install-context = { workspace = true }
codex-otel = { workspace = true } codex-otel = { workspace = true }
@@ -8,6 +8,8 @@
//! It intentionally stays separate from live thread-history reduction because migration needs a //! It intentionally stays separate from live thread-history reduction because migration needs a
//! frozen adapter for historical rollout payloads. //! frozen adapter for historical rollout payloads.
use codex_extension_items::ExtensionItem;
use codex_extension_items::image_generation::ImageGenerationItem;
use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem; use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::CommandExecutionItem; use codex_protocol::items::CommandExecutionItem;
@@ -17,7 +19,6 @@ use codex_protocol::items::DynamicToolCallStatus;
use codex_protocol::items::EnteredReviewModeItem; use codex_protocol::items::EnteredReviewModeItem;
use codex_protocol::items::ExitedReviewModeItem; use codex_protocol::items::ExitedReviewModeItem;
use codex_protocol::items::FileChangeItem; use codex_protocol::items::FileChangeItem;
use codex_protocol::items::ImageGenerationItem;
use codex_protocol::items::McpToolCallError; use codex_protocol::items::McpToolCallError;
use codex_protocol::items::McpToolCallItem; use codex_protocol::items::McpToolCallItem;
use codex_protocol::items::McpToolCallStatus; use codex_protocol::items::McpToolCallStatus;
@@ -156,13 +157,15 @@ pub(super) fn completed_item(
None, None,
)), )),
EventMsg::ImageGenerationEnd(event) => Some(( EventMsg::ImageGenerationEnd(event) => Some((
TurnItem::ImageGeneration(ImageGenerationItem { TurnItem::Extension(ExtensionItem::ImageGeneration(ImageGenerationItem {
id: event.call_id.clone(), id: event.call_id.clone(),
status: event.status.clone(), status: event.status.clone(),
revised_prompt: event.revised_prompt.clone(), revised_prompt: event.revised_prompt.clone(),
result: event.result.clone(), result: event.result.clone(),
transparent_background: event.transparent_background,
failure: event.failure.clone(),
saved_path: event.saved_path.clone(), saved_path: event.saved_path.clone(),
}), })),
None, None,
)), )),
EventMsg::ContextCompacted(_) => Some(( EventMsg::ContextCompacted(_) => Some((
@@ -7,6 +7,9 @@ use std::path::PathBuf;
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
use codex_app_server_protocol::build_turns_from_rollout_items; use codex_app_server_protocol::build_turns_from_rollout_items;
use codex_extension_items::ExtensionItem;
use codex_extension_items::image_generation::ImageGenerationFailure;
use codex_extension_items::image_generation::ImageGenerationItem;
use codex_protocol::AgentPath; use codex_protocol::AgentPath;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ReasoningSummary;
@@ -17,6 +20,7 @@ use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ImageGenerationEndEvent;
use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::ItemCompletedEvent;
use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SandboxPolicy;
@@ -397,6 +401,62 @@ async fn migration_projects_explicit_and_implicit_legacy_completed_items() {
assert_eq!(reasoning["content"], json!(["raw"])); assert_eq!(reasoning["content"], json!(["raw"]));
} }
#[tokio::test]
async fn migration_preserves_image_generation_failure_metadata() {
let home = TempDir::new().expect("create Codex home");
let thread_id = ThreadId::new();
let expected_item = ImageGenerationItem {
id: "image-call".to_string(),
status: "failed".to_string(),
revised_prompt: Some("paint a blue whale".to_string()),
result: String::new(),
transparent_background: None,
failure: Some(ImageGenerationFailure::UsageLimitExceeded {
limit_id: "image_gen".to_string(),
resets_at: Some(1_786_150_800),
}),
saved_path: None,
};
let image_completion =
RolloutItem::EventMsg(EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {
call_id: expected_item.id.clone(),
status: expected_item.status.clone(),
revised_prompt: expected_item.revised_prompt.clone(),
result: expected_item.result.clone(),
transparent_background: expected_item.transparent_background,
failure: expected_item.failure.clone(),
saved_path: expected_item.saved_path.clone(),
}));
let path = write_rollout(
home.path(),
thread_id,
SessionSource::Cli,
vec![
started("image-turn"),
image_completion,
completed("image-turn"),
],
);
let store = indexed_store(home.path()).await;
store
.migrate_rollouts(apply_options())
.await
.expect("migrate legacy image completion");
let migrated_item = read_rollout(&path)
.into_iter()
.find_map(|line| match line.item {
RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) => Some(event.item),
_ => None,
})
.expect("migrated image completion");
let TurnItem::Extension(ExtensionItem::ImageGeneration(migrated_item)) = migrated_item else {
panic!("expected migrated extension image-generation item");
};
assert_eq!(migrated_item, expected_item);
}
#[tokio::test] #[tokio::test]
async fn migration_keeps_late_completions_in_their_original_turn() { async fn migration_keeps_late_completions_in_their_original_turn() {
let home = TempDir::new().expect("create Codex home"); let home = TempDir::new().expect("create Codex home");
@@ -713,6 +713,7 @@ pub(super) fn handle_image_generation_end(
revised_prompt, revised_prompt,
result: String::new(), result: String::new(),
transparent_background: None, transparent_background: None,
failure: None,
saved_path, saved_path,
}), }),
}), }),