diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 928f2b16..2b8af01d 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1461,7 +1461,7 @@ export class CodexAcpServer { case "imageView": return [createImageViewUpdate(item)]; case "imageGeneration": - return [createImageGenerationUpdate(item)]; + return [createImageGenerationUpdate(item, { terminalStatus: true })]; case "enteredReviewMode": return [this.createReviewModeUpdate(item, true)]; case "exitedReviewMode": diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index 3402cfc0..a86c11cb 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -198,11 +198,13 @@ export function createImageGenerationStartUpdate( export function createImageGenerationCompleteUpdate( item: ThreadItem & { type: "imageGeneration" } ): UpdateSessionEvent { + const locations = imageGenerationLocations(item); return { sessionUpdate: "tool_call_update", toolCallId: item.id, status: imageGenerationTerminalStatus(item.status), content: imageGenerationContent(item), + ...(locations ? { locations } : {}), rawOutput: imageGenerationRawOutput(item), }; } @@ -211,6 +213,7 @@ export function createImageGenerationUpdate( item: ThreadItem & { type: "imageGeneration" }, options?: { terminalStatus?: boolean }, ): UpdateSessionEvent { + const locations = imageGenerationLocations(item); return { sessionUpdate: "tool_call", toolCallId: item.id, @@ -220,6 +223,7 @@ export function createImageGenerationUpdate( ? imageGenerationTerminalStatus(item.status) : imageGenerationToolStatus(item.status), content: imageGenerationContent(item), + ...(locations ? { locations } : {}), rawOutput: imageGenerationRawOutput(item), }; } @@ -758,6 +762,14 @@ function imageGenerationTerminalStatus(status: string): AcpToolCallStatus { } } +function imageGenerationSavedPath( + item: ThreadItem & { type: "imageGeneration" } +): string | undefined { + return item.savedPath && item.savedPath.trim() !== "" + ? item.savedPath + : undefined; +} + function imageGenerationContent( item: ThreadItem & { type: "imageGeneration" } ): ToolCallContent[] { @@ -771,12 +783,13 @@ function imageGenerationContent( } if (item.result.trim() !== "") { - const image: ContentBlock = item.savedPath && item.savedPath.trim() !== "" + const savedPath = imageGenerationSavedPath(item); + const image: ContentBlock = savedPath ? { type: "image", data: item.result, mimeType: "image/png", - uri: item.savedPath, + uri: savedPath, } : { type: "image", @@ -789,6 +802,13 @@ function imageGenerationContent( return content; } +function imageGenerationLocations( + item: ThreadItem & { type: "imageGeneration" } +): { path: string }[] | undefined { + const savedPath = imageGenerationSavedPath(item); + return savedPath ? [{ path: savedPath }] : undefined; +} + function imageGenerationRawOutput( item: ThreadItem & { type: "imageGeneration" } ): Record { diff --git a/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json b/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json index 73d9bf87..d964b128 100644 --- a/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json +++ b/src/__tests__/CodexACPAgent/data/image-generation-completed-only.json @@ -15,14 +15,21 @@ "content": { "type": "image", "data": "iVBORw0KGgo=", - "mimeType": "image/png" + "mimeType": "image/png", + "uri": "/tmp/codex/generated-completed-only.png" } } ], + "locations": [ + { + "path": "/tmp/codex/generated-completed-only.png" + } + ], "rawOutput": { "status": "generating", "revisedPrompt": null, - "result": "iVBORw0KGgo=" + "result": "iVBORw0KGgo=", + "savedPath": "/tmp/codex/generated-completed-only.png" } } } diff --git a/src/__tests__/CodexACPAgent/data/image-generation-flow.json b/src/__tests__/CodexACPAgent/data/image-generation-flow.json index 176aefdd..59680ac1 100644 --- a/src/__tests__/CodexACPAgent/data/image-generation-flow.json +++ b/src/__tests__/CodexACPAgent/data/image-generation-flow.json @@ -43,6 +43,11 @@ } } ], + "locations": [ + { + "path": "/tmp/codex/generated-blue-square.png" + } + ], "rawOutput": { "status": "generating", "revisedPrompt": "A tiny blue square", diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 0018e170..b94114af 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -373,8 +373,13 @@ } } ], + "locations": [ + { + "path": "/test/project/generated-blue-square.png" + } + ], "rawOutput": { - "status": "completed", + "status": "generating", "revisedPrompt": "A tiny blue square", "result": "iVBORw0KGgo=", "savedPath": "/test/project/generated-blue-square.png" diff --git a/src/__tests__/CodexACPAgent/image-events.test.ts b/src/__tests__/CodexACPAgent/image-events.test.ts index 8eb8102e..3883432b 100644 --- a/src/__tests__/CodexACPAgent/image-events.test.ts +++ b/src/__tests__/CodexACPAgent/image-events.test.ts @@ -3,6 +3,83 @@ import type { SessionState } from "../../CodexAcpServer"; import type { ServerNotification } from "../../app-server"; import { createCodexMockTestFixture, createTestSessionState, setupPromptAndSendNotifications, type CodexMockTestFixture } from "../acp-test-utils"; import { AgentMode } from "../../AgentMode"; +import { + createImageGenerationCompleteUpdate, + createImageGenerationUpdate, +} from "../../CodexToolCallMapper"; +import type { ThreadItem } from "../../app-server/v2"; + +describe("image generation updates", () => { + const imageGenerationItem = ( + overrides: Partial = {}, + ): ThreadItem & { type: "imageGeneration" } => ({ + type: "imageGeneration", + id: "image-generation-1", + status: "completed", + revisedPrompt: null, + result: "iVBORw0KGgo=", + savedPath: "/tmp/codex/generated-blue-square.png", + ...overrides, + }); + + it.each([ + ["completion", createImageGenerationCompleteUpdate], + ["completed-only/replay", createImageGenerationUpdate], + ])("maps the canonical saved path to matching image URI and locations for %s", (_, createUpdate) => { + const update = createUpdate(imageGenerationItem()) as Extract< + ReturnType, + { sessionUpdate: "tool_call" | "tool_call_update" } + >; + + expect(update).toMatchObject({ + status: "completed", + locations: [{ path: "/tmp/codex/generated-blue-square.png" }], + content: [{ + type: "content", + content: { + type: "image", + uri: "/tmp/codex/generated-blue-square.png", + }, + }], + }); + }); + + it.each([ + ["missing", undefined], + ["empty", ""], + ["whitespace-only", " "], + ])("omits locations and the image URI when savedPath is %s", (_, savedPath) => { + const item = imageGenerationItem(); + if (savedPath === undefined) { + delete item.savedPath; + } else { + item.savedPath = savedPath; + } + const update = createImageGenerationCompleteUpdate(item) as Extract< + ReturnType, + { sessionUpdate: "tool_call_update" } + >; + + expect(update.locations).toBeUndefined(); + expect(update.content).toEqual([{ + type: "content", + content: { + type: "image", + data: "iVBORw0KGgo=", + mimeType: "image/png", + }, + }]); + }); + + it("preserves failed status while reporting the saved image location", () => { + const update = createImageGenerationCompleteUpdate(imageGenerationItem({ status: "failed" })); + + expect(update).toMatchObject({ + status: "failed", + locations: [{ path: "/tmp/codex/generated-blue-square.png" }], + }); + }); +}); describe("CodexEventHandler - image events", () => { let mockFixture: CodexMockTestFixture; @@ -74,6 +151,7 @@ describe("CodexEventHandler - image events", () => { status: "generating", revisedPrompt: null, result: "iVBORw0KGgo=", + savedPath: "/tmp/codex/generated-completed-only.png", }, }, }; diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index a64089e1..bd555e21 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -156,7 +156,7 @@ describe("CodexACPAgent - loadSession", () => { { type: "imageGeneration", id: "item-image-generation-1", - status: "completed", + status: "generating", revisedPrompt: "A tiny blue square", result: "iVBORw0KGgo=", savedPath: "/test/project/generated-blue-square.png",