From 9f1b9d148601f084c83c7b93c8b6115502d23088 Mon Sep 17 00:00:00 2001 From: lifeodyssey Date: Wed, 3 Jun 2026 15:57:48 +0800 Subject: [PATCH] feat: add managed image generation (/codex:imagegen) Fixes #356. The companion runs code tasks through a single, serialized codex app-server connection. Image generation never had a managed path: the only working route was a bare parallel Codex CLI, which contends for the same websocket and returns 403/429 while still spending quota. Routing an image prompt through the app-server task runtime instead just sat in the "starting" phase. The hang was not a missing capability. The active provider advertises imageGeneration, image_generation is a stable default-on feature, and a turn does emit an `imageGeneration` item carrying the PNG (base64 `result` plus a `savedPath`). The real problem is that after producing the image the model often keeps going, running shell commands to verify or convert the file, so the turn runs for minutes while the companion waits for it to finish. This adds a managed image path that captures the `imageGeneration` item the moment it arrives, writes the bytes to --out, then interrupts the turn so the post-image tail never runs. Because it reuses the one serialized app-server connection, image jobs never run concurrently, the exact contention the bare-CLI route caused. - /codex:imagegen plus an `imagegen` companion subcommand - --out (write target), --image ref[,ref...] (reference images for editing), --background (tracked job), --force (allow overwrite), --model - a read-only thread: the managed path decodes and writes the bytes itself, so the model needs no workspace write access - a startup timeout (CODEX_PLUGIN_IMAGE_TIMEOUT_MS, default 180s) and fast failure on an empty turn or an error notification - fake-codex coverage for the happy path, the savedPath fallback, no-image, error, timeout, reference inputs, the JSON payload, and overwrite refusal --- plugins/codex/commands/imagegen.md | 30 +++ plugins/codex/scripts/codex-companion.mjs | 111 +++++++++- plugins/codex/scripts/lib/codex.mjs | 238 ++++++++++++++++++++++ plugins/codex/scripts/lib/render.mjs | 14 ++ tests/commands.test.mjs | 1 + tests/fake-codex-fixture.mjs | 26 ++- tests/helpers.mjs | 1 + tests/runtime.test.mjs | 197 ++++++++++++++++++ 8 files changed, 611 insertions(+), 7 deletions(-) create mode 100644 plugins/codex/commands/imagegen.md diff --git a/plugins/codex/commands/imagegen.md b/plugins/codex/commands/imagegen.md new file mode 100644 index 00000000..e601d97f --- /dev/null +++ b/plugins/codex/commands/imagegen.md @@ -0,0 +1,30 @@ +--- +description: Generate an image with Codex (managed and serialized) and save it to a file +argument-hint: '[--out ] [--force] [--image ] [--background] [--model ] [what the image should be]' +allowed-tools: Bash(node:*) +--- + +Generate an image through the Codex companion's managed, serialized image path and return its output verbatim. + +Raw user request: +$ARGUMENTS + +Run one `Bash` call: + +```bash +node "${CLAUDE_PLUGIN_ROOT}/scripts/codex-companion.mjs" imagegen "$ARGUMENTS" +``` + +How it works: + +- `imagegen` runs a single Codex app-server turn, captures the generated image the moment it is ready, writes it to `--out` (or reports the Codex copy under `~/.codex/generated_images/`), and interrupts the turn so the model's post-image steps do not run. +- It is serialized like every other companion job, so it never runs Codex concurrently. That is what avoids the 403/429 websocket contention and wasted quota that raw parallel `codex exec` causes. +- Pass a reference image for editing with `--image ref.png`. Comma-separate several: `--image a.png,b.png`. + +Operating rules: + +- Default to foreground. With `--background`, the job is queued: report the job id and tell the user they can watch it with `/codex:status ` and fetch it with `/codex:result `. +- `--out` refuses to overwrite an existing file. If the user wants to replace it, add `--force`. +- Return the companion stdout to the user. Do not paraphrase the `Saved image:` path. +- If the companion reports that Codex is missing or unauthenticated, tell the user to run `/codex:setup`. +- If the user gave no prompt, ask what the image should be. diff --git a/plugins/codex/scripts/codex-companion.mjs b/plugins/codex/scripts/codex-companion.mjs index 35222fd5..e936388e 100644 --- a/plugins/codex/scripts/codex-companion.mjs +++ b/plugins/codex/scripts/codex-companion.mjs @@ -17,6 +17,7 @@ import { interruptAppServerTurn, parseStructuredOutput, readOutputSchema, + runAppServerImageGen, runAppServerReview, runAppServerTurn } from "./lib/codex.mjs"; @@ -59,7 +60,8 @@ import { renderJobStatusReport, renderSetupReport, renderStatusReport, - renderTaskResult + renderTaskResult, + renderImageGenResult } from "./lib/render.mjs"; const ROOT_DIR = path.resolve(fileURLToPath(new URL("..", import.meta.url))); @@ -69,6 +71,7 @@ const DEFAULT_STATUS_POLL_INTERVAL_MS = 2000; const VALID_REASONING_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh"]); const MODEL_ALIASES = new Map([["spark", "gpt-5.3-codex-spark"]]); const STOP_REVIEW_TASK_MARKER = "Run a stop-gate review of the previous Claude turn."; +const IMAGE_JOB_TITLE = "Codex Image"; function printUsage() { console.log( @@ -78,6 +81,7 @@ function printUsage() { " node scripts/codex-companion.mjs review [--wait|--background] [--base ] [--scope ]", " node scripts/codex-companion.mjs adversarial-review [--wait|--background] [--base ] [--scope ] [focus text]", " node scripts/codex-companion.mjs task [--background] [--write] [--resume-last|--resume|--fresh] [--model ] [--effort ] [prompt]", + " node scripts/codex-companion.mjs imagegen [--background] [--out ] [--force] [--image ] [--model ] [prompt]", " node scripts/codex-companion.mjs status [job-id] [--all] [--json]", " node scripts/codex-companion.mjs result [job-id] [--json]", " node scripts/codex-companion.mjs cancel [job-id] [--json]" @@ -558,6 +562,9 @@ function getJobKindLabel(kind, jobClass) { if (kind === "adversarial-review") { return "adversarial-review"; } + if (jobClass === "image") { + return "image"; + } return jobClass === "review" ? "review" : "rescue"; } @@ -822,21 +829,110 @@ async function handleTaskWorker(argv) { logFile: storedJob.logFile ?? null } ); + const runner = + storedJob.jobClass === "image" + ? () => executeImageGenRun({ ...request, onProgress: progress }) + : () => executeTaskRun({ ...request, onProgress: progress }); await runTrackedJob( { ...storedJob, workspaceRoot, logFile }, - () => - executeTaskRun({ - ...request, - onProgress: progress - }), + runner, { logFile } ); } +async function executeImageGenRun(request) { + ensureCodexAvailable(request.cwd); + + const result = await runAppServerImageGen(request.cwd, { + prompt: request.prompt, + images: request.images, + model: request.model, + outPath: request.outPath, + overwrite: request.overwrite, + onProgress: request.onProgress + }); + + const rendered = renderImageGenResult(result, { title: IMAGE_JOB_TITLE }); + const payload = { + status: 0, + threadId: result.threadId, + outPath: result.outPath, + savedPath: result.savedPath, + revisedPrompt: result.revisedPrompt + }; + + return { + exitStatus: 0, + threadId: result.threadId, + turnId: result.turnId, + payload, + rendered, + summary: `Image saved to ${result.outPath ?? result.savedPath ?? "(unknown path)"}.`, + jobTitle: IMAGE_JOB_TITLE, + jobClass: "image", + write: true + }; +} + +function buildImageGenJob(workspaceRoot, summary) { + return createCompanionJob({ + prefix: "image", + kind: "image", + title: IMAGE_JOB_TITLE, + workspaceRoot, + jobClass: "image", + summary, + write: true + }); +} + +function buildImageGenRequest({ cwd, model, prompt, images, outPath, overwrite, jobId }) { + return { cwd, model, prompt, images, outPath, overwrite, jobId }; +} + +async function handleImageGen(argv) { + const { options, positionals } = parseCommandInput(argv, { + valueOptions: ["model", "cwd", "out", "image", "prompt-file"], + booleanOptions: ["json", "background", "force"], + aliasMap: { m: "model", o: "out" } + }); + + const cwd = resolveCommandCwd(options); + const workspaceRoot = resolveCommandWorkspace(options); + const model = normalizeRequestedModel(options.model); + const prompt = readTaskPrompt(cwd, options, positionals).trim(); + if (!prompt) { + throw new Error("Provide an image prompt (positional text, --prompt-file, or piped stdin)."); + } + const outPath = options.out ? path.resolve(cwd, options.out) : null; + const images = options.image + ? options.image.split(",").map((value) => value.trim()).filter(Boolean) + : []; + const overwrite = Boolean(options.force); + const summary = `Image: ${shorten(prompt, 80)}`; + + if (options.background) { + ensureCodexAvailable(cwd); + const job = buildImageGenJob(workspaceRoot, summary); + const request = buildImageGenRequest({ cwd, model, prompt, images, outPath, overwrite, jobId: job.id }); + const { payload } = enqueueBackgroundTask(cwd, job, request); + outputCommandResult(payload, renderQueuedTaskLaunch(payload), options.json); + return; + } + + const job = buildImageGenJob(workspaceRoot, summary); + await runForegroundCommand( + job, + (progress) => + executeImageGenRun({ cwd, model, prompt, images, outPath, overwrite, jobId: job.id, onProgress: progress }), + { json: options.json } + ); +} + async function handleStatus(argv) { const { options, positionals } = parseCommandInput(argv, { valueOptions: ["cwd", "timeout-ms", "poll-interval-ms"], @@ -1000,6 +1096,9 @@ async function main() { case "task": await handleTask(argv); break; + case "imagegen": + await handleImageGen(argv); + break; case "task-worker": await handleTaskWorker(argv); break; diff --git a/plugins/codex/scripts/lib/codex.mjs b/plugins/codex/scripts/lib/codex.mjs index f2fe88bd..be0d2e50 100644 --- a/plugins/codex/scripts/lib/codex.mjs +++ b/plugins/codex/scripts/lib/codex.mjs @@ -34,6 +34,8 @@ * onProgress: ProgressReporter | null * }} TurnCaptureState */ +import fs from "node:fs"; +import path from "node:path"; import { readJsonFile } from "./fs.mjs"; import { BROKER_BUSY_RPC_CODE, BROKER_ENDPOINT_ENV, CodexAppServerClient } from "./app-server.mjs"; import { loadBrokerSession } from "./broker-lifecycle.mjs"; @@ -1085,4 +1087,240 @@ export function readOutputSchema(schemaPath) { return readJsonFile(schemaPath); } +// ── Managed image generation ───────────────────────────────────────────────── +// codex app-server can generate images: the active provider advertises +// `imageGeneration` and the model emits an `imageGeneration` thread item that +// carries the base64 PNG (`result`) and a `savedPath`. The catch is that after +// producing the image the model often keeps going, running shell commands to +// verify or convert the file, which makes a plain task turn look like it hangs. +// The managed path below captures the image item the moment it arrives, writes +// the bytes to the requested path, then interrupts the turn so the wasteful tail +// never runs. The single app-server connection is serialized by the broker, so +// image jobs never run concurrently (the failure mode that triggers 403/429). + +const DEFAULT_IMAGE_TIMEOUT_MS = 180000; + +function resolveImageTimeoutMs(optionValue) { + if (typeof optionValue === "number" && Number.isFinite(optionValue) && optionValue > 0) { + return optionValue; + } + const raw = process.env.CODEX_PLUGIN_IMAGE_TIMEOUT_MS; + if (raw != null && raw !== "") { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return DEFAULT_IMAGE_TIMEOUT_MS; +} + +function composeImagePrompt(prompt) { + return ( + "Use your built-in image generation to create the image described below, " + + "then stop. Do not run shell commands, write code, or open files.\n\n" + + prompt + ); +} + +/** @returns {UserInput[]} */ +function buildImageTurnInput(prompt, images = [], cwd = process.cwd()) { + /** @type {UserInput[]} */ + const input = [{ type: "text", text: composeImagePrompt(prompt), text_elements: [] }]; + for (const ref of Array.isArray(images) ? images : []) { + if (typeof ref === "string" && ref.trim()) { + input.push({ type: "localImage", path: path.resolve(cwd, ref.trim()) }); + } + } + return input; +} + +// Write the generated image to `outPath`, preferring the inline base64 bytes and +// falling back to the app-server's own `savedPath`. Returns null until the item +// actually carries image data (the `item/started` event has an empty result), so +// the caller only settles once a real image exists. +function saveImageFromItem(item, outPath) { + const savedPath = typeof item?.savedPath === "string" && item.savedPath ? item.savedPath : null; + const base64 = typeof item?.result === "string" && item.result.length > 0 ? item.result : null; + if (!base64 && !(savedPath && fs.existsSync(savedPath))) { + return null; + } + const revisedPrompt = typeof item?.revisedPrompt === "string" ? item.revisedPrompt : null; + let written = null; + if (outPath) { + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + if (base64) { + fs.writeFileSync(outPath, Buffer.from(base64, "base64")); + written = outPath; + } else if (savedPath) { + fs.copyFileSync(savedPath, outPath); + written = outPath; + } + } + return { outPath: written ?? savedPath, savedPath, revisedPrompt }; +} + +async function captureImageTurn(client, threadId, input, options = {}) { + const previousHandler = client.notificationHandler; + const timeoutMs = resolveImageTimeoutMs(options.timeoutMs); + let turnId = null; + let settled = false; + let resolveDone; + let rejectDone; + const done = new Promise((resolve, reject) => { + resolveDone = resolve; + rejectDone = reject; + }); + + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + const seconds = Math.round(timeoutMs / 1000); + rejectDone( + new Error( + `Codex produced no image within ${seconds}s. Set CODEX_PLUGIN_IMAGE_TIMEOUT_MS to adjust the window.` + ) + ); + }, timeoutMs); + timer.unref?.(); + + const settleOk = (result) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolveDone(result); + }; + const settleErr = (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + rejectDone(error); + }; + + // Stop the turn so the model's post-image shell tail never runs. Fire and + // forget: the image is already captured and written, and withAppServer closes + // the connection on return, so settlement must never block on this RPC. + const stopTurn = () => { + if (turnId) { + client.request("turn/interrupt", { threadId, turnId }).catch(() => {}); + } + }; + + client.setNotificationHandler((message) => { + try { + if (settled) { + return; + } + // The broker connection is shared and serialized; only act on our own + // thread so a sibling or subagent turn is never captured or interrupted. + const eventThreadId = message?.params?.threadId ?? message?.params?.thread?.id ?? null; + if (eventThreadId && eventThreadId !== threadId) { + return; + } + // Learn the turn id from notifications (turn/started carries it under + // params.turn.id, item events under params.turnId), so the interrupt can + // fire even if the image item arrives before the turn/start response. + if (!turnId) { + turnId = extractTurnId(message); + } + const method = message?.method; + const item = message?.params?.item; + if ((method === "item/completed" || method === "item/started") && item?.type === "imageGeneration") { + const saved = saveImageFromItem(item, options.outPath ?? null); + if (saved) { + emitProgress(options.onProgress, "Image ready; stopping the turn.", "completing"); + settleOk({ ...saved, threadId, turnId }); + stopTurn(); + } + return; + } + if (method === "turn/completed") { + const status = message?.params?.turn?.status; + // Our own interrupt resolves the turn as "interrupted"; that is success. + if (status !== "interrupted") { + settleErr(new Error(`Codex finished the turn (${status ?? "unknown"}) without generating an image.`)); + } + return; + } + if (method === "error") { + const err = message?.params?.error; + settleErr(new Error((err && typeof err.message === "string" && err.message) || "Codex image turn failed.")); + } + } catch { + // Ignore handler errors; the timeout is the backstop. + } + }); + + try { + const response = await client.request("turn/start", { + threadId, + input, + model: options.model ?? null, + effort: options.effort ?? null, + outputSchema: null + }); + if (!turnId) { + turnId = response.turn?.id ?? null; + } + // Surface the turn id so a tracked job persists it for graceful `/codex:cancel`. + if (turnId) { + emitProgress(options.onProgress, `Image turn started (${turnId}).`, "starting", { threadId, turnId }); + } + if (response.turn?.status && response.turn.status !== "inProgress" && !settled) { + settleErr(new Error("Codex finished the turn without generating an image.")); + } + return await done; + } finally { + clearTimeout(timer); + client.setNotificationHandler(previousHandler ?? null); + } +} + +export async function runAppServerImageGen(cwd, options = {}) { + const prompt = options.prompt?.trim(); + if (!prompt) { + throw new Error("An image prompt is required for image generation."); + } + const outPath = options.outPath ? path.resolve(cwd, options.outPath) : null; + if (outPath && !options.overwrite && fs.existsSync(outPath)) { + throw new Error(`Refusing to overwrite ${outPath}. Pass --force to replace it.`); + } + + return withAppServer(cwd, async (client) => { + emitProgress(options.onProgress, "Starting Codex image thread.", "starting"); + // read-only: the managed path decodes the base64 and writes the file itself, + // and the prompt forbids shell/file actions, so the model needs no write + // access to the workspace. This matches the task and review paths. + const startResponse = await startThread(client, cwd, { + model: options.model, + sandbox: "read-only", + ephemeral: true + }); + const threadId = startResponse.thread.id; + emitProgress(options.onProgress, `Thread ready (${threadId}).`, "starting", { threadId }); + + const input = buildImageTurnInput(prompt, options.images, cwd); + const result = await captureImageTurn(client, threadId, input, { + outPath, + model: options.model, + effort: options.effort, + timeoutMs: options.timeoutMs, + onProgress: options.onProgress + }); + + return { + threadId, + turnId: result.turnId, + outPath: result.outPath, + savedPath: result.savedPath, + revisedPrompt: result.revisedPrompt + }; + }); +} + export { DEFAULT_CONTINUE_PROMPT, TASK_THREAD_PREFIX }; diff --git a/plugins/codex/scripts/lib/render.mjs b/plugins/codex/scripts/lib/render.mjs index 2ec18523..38d6812a 100644 --- a/plugins/codex/scripts/lib/render.mjs +++ b/plugins/codex/scripts/lib/render.mjs @@ -322,6 +322,20 @@ export function renderTaskResult(parsedResult, meta) { return `${message}\n`; } +export function renderImageGenResult(result, meta = {}) { + const title = meta.title ?? "Codex Image"; + const outPath = result?.outPath ?? result?.savedPath ?? null; + const lines = [`# ${title}`, ""]; + lines.push(outPath ? `Saved image: ${outPath}` : "No image was saved."); + if (result?.savedPath && result.savedPath !== outPath) { + lines.push(`Codex copy: ${result.savedPath}`); + } + if (result?.revisedPrompt) { + lines.push("", "Revised prompt:", result.revisedPrompt); + } + return `${lines.join("\n")}\n`; +} + export function renderStatusReport(report) { const lines = [ "# Codex Status", diff --git a/tests/commands.test.mjs b/tests/commands.test.mjs index 3724ffa4..ac8ed214 100644 --- a/tests/commands.test.mjs +++ b/tests/commands.test.mjs @@ -75,6 +75,7 @@ test("continue is not exposed as a user-facing command", () => { assert.deepEqual(commandFiles, [ "adversarial-review.md", "cancel.md", + "imagegen.md", "rescue.md", "result.md", "review.md", diff --git a/tests/fake-codex-fixture.mjs b/tests/fake-codex-fixture.mjs index debcadce..16643660 100644 --- a/tests/fake-codex-fixture.mjs +++ b/tests/fake-codex-fixture.mjs @@ -380,7 +380,8 @@ rl.on("line", (line) => { turnId, model: message.params.model ?? null, effort: message.params.effort ?? null, - prompt + prompt, + input: message.params.input ?? null }; saveState(state); send({ id: message.id, result: { turn: buildTurn(turnId) } }); @@ -533,6 +534,29 @@ rl.on("line", (line) => { interruptibleTurns.set(turnId, { threadId: thread.id, timer }); } else if (BEHAVIOR === "slow-task") { emitTurnCompletedLater(thread.id, turnId, items, 400); + } else if (BEHAVIOR === "imagegen" || BEHAVIOR === "imagegen-saved-only") { + // result carries a real PNG (RESULT_B64); savedPath holds DISTINCT + // sentinel bytes, so a test can prove the written bytes came from the + // item's base64 result and not from copying savedPath. + // imagegen-saved-only sends an empty result to exercise the copy fallback. + const RESULT_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + const savedPath = path.join(path.dirname(STATE_PATH), "fake-image-" + turnId + ".png"); + fs.writeFileSync(savedPath, "SAVED_FALLBACK_SENTINEL\\n"); + const resultField = BEHAVIOR === "imagegen-saved-only" ? "" : RESULT_B64; + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ method: "item/started", params: { threadId: thread.id, turnId, item: { type: "imageGeneration", id: "ig_" + turnId, status: "in_progress", revisedPrompt: null, result: "" } } }); + send({ method: "item/completed", params: { threadId: thread.id, turnId, item: { type: "imageGeneration", id: "ig_" + turnId, status: "completed", revisedPrompt: "A tiny test image.", result: resultField, savedPath } } }); + // Intentionally NO turn/completed: emulate the post-image tail loop so + // the client must interrupt the turn after capturing the image. + } else if (BEHAVIOR === "imagegen-no-image") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ method: "turn/completed", params: { threadId: thread.id, turn: buildTurn(turnId, "completed") } }); + } else if (BEHAVIOR === "imagegen-error") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + send({ method: "error", params: { error: { message: "image generation is not available for this account" } } }); + } else if (BEHAVIOR === "imagegen-silent") { + send({ method: "turn/started", params: { threadId: thread.id, turn: buildTurn(turnId) } }); + // no item and no completion: exercise the startup-timeout backstop. } else { emitTurnCompleted(thread.id, turnId, items); } diff --git a/tests/helpers.mjs b/tests/helpers.mjs index 945ae0e7..2c534dfc 100644 --- a/tests/helpers.mjs +++ b/tests/helpers.mjs @@ -18,6 +18,7 @@ export function run(command, args, options = {}) { env: options.env, encoding: "utf8", input: options.input, + timeout: options.timeout, shell: process.platform === "win32" && !path.isAbsolute(command), windowsHide: true }); diff --git a/tests/runtime.test.mjs b/tests/runtime.test.mjs index 90408372..80a0d54b 100644 --- a/tests/runtime.test.mjs +++ b/tests/runtime.test.mjs @@ -211,6 +211,203 @@ test("task reports the actual Codex auth error when the run is rejected", () => assert.match(result.stderr, /authentication expired; run codex login/); }); +const IMAGEGEN_RESULT_B64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + +function setupImagegenRepo(binDir, behavior) { + const repo = makeTempDir(); + installFakeCodex(binDir, behavior); + initGitRepo(repo); + fs.writeFileSync(path.join(repo, "README.md"), "hello\n"); + run("git", ["add", "README.md"], { cwd: repo }); + run("git", ["commit", "-m", "init"], { cwd: repo }); + return repo; +} + +test("imagegen writes the captured base64 image and interrupts the post-image tail", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + const outPath = path.join(repo, "out", "fox.png"); + + const result = run("node", [SCRIPT, "imagegen", "--out", outPath, "a small fox charm"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Saved image:/); + // The bytes must come from the item's base64 result, not from copying savedPath + // (the fixture stores distinct sentinel bytes at savedPath). + const written = fs.readFileSync(outPath); + assert.ok( + written.equals(Buffer.from(IMAGEGEN_RESULT_B64, "base64")), + "image bytes must equal the decoded base64 result" + ); + + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + assert.ok(fakeState.lastInterrupt, "the turn must be interrupted after the image arrived"); + assert.equal( + fakeState.lastInterrupt.turnId, + fakeState.lastTurnStart.turnId, + "the in-flight image turn must be the one interrupted" + ); +}); + +test("imagegen --json reports the saved image payload", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + const outPath = path.join(repo, "p.png"); + + const result = run("node", [SCRIPT, "imagegen", "--out", outPath, "--json", "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.status, 0); + assert.equal(payload.outPath, outPath); + assert.equal(typeof payload.savedPath, "string"); + assert.equal(payload.revisedPrompt, "A tiny test image."); +}); + +test("imagegen forwards --image references as localImage inputs", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + const refA = path.join(repo, "a.png"); + const refB = path.join(repo, "b.png"); + fs.writeFileSync(refA, "x"); + fs.writeFileSync(refB, "y"); + + const result = run( + "node", + [SCRIPT, "imagegen", "--out", path.join(repo, "o.png"), "--image", `${refA},${refB}`, "edit it"], + { cwd: repo, env: buildEnv(binDir), timeout: 20000 } + ); + + assert.equal(result.status, 0, result.stderr); + const fakeState = JSON.parse(fs.readFileSync(path.join(binDir, "fake-codex-state.json"), "utf8")); + const input = fakeState.lastTurnStart.input; + assert.equal(input[0].type, "text"); + const localImages = input.filter((item) => item.type === "localImage"); + assert.equal(localImages.length, 2); + assert.equal(localImages[0].path, refA); + assert.equal(localImages[1].path, refB); +}); + +test("imagegen fails fast when the turn completes without an image", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen-no-image"); + + const result = run("node", [SCRIPT, "imagegen", "--out", path.join(repo, "x.png"), "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /without generating an image/); +}); + +test("imagegen surfaces the actual Codex error message", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen-error"); + + const result = run("node", [SCRIPT, "imagegen", "--out", path.join(repo, "x.png"), "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /image generation is not available for this account/); +}); + +test("imagegen times out when Codex never produces an image", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen-silent"); + + const result = run("node", [SCRIPT, "imagegen", "--out", path.join(repo, "x.png"), "a fox"], { + cwd: repo, + env: { ...buildEnv(binDir), CODEX_PLUGIN_IMAGE_TIMEOUT_MS: "800" }, + timeout: 20000 + }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /produced no image within/); +}); + +test("imagegen copies savedPath when the item carries no base64", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen-saved-only"); + const outPath = path.join(repo, "saved.png"); + + const result = run("node", [SCRIPT, "imagegen", "--out", outPath, "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(fs.readFileSync(outPath, "utf8"), /SAVED_FALLBACK_SENTINEL/); +}); + +test("imagegen refuses to overwrite an existing --out unless --force is given", () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + const outPath = path.join(repo, "exists.png"); + fs.writeFileSync(outPath, "original"); + + const refused = run("node", [SCRIPT, "imagegen", "--out", outPath, "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + assert.notEqual(refused.status, 0); + assert.match(refused.stderr, /Refusing to overwrite/); + assert.equal(fs.readFileSync(outPath, "utf8"), "original", "the existing file must be untouched"); + + const forced = run("node", [SCRIPT, "imagegen", "--out", outPath, "--force", "a fox"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + assert.equal(forced.status, 0, forced.stderr); + assert.ok(fs.readFileSync(outPath).equals(Buffer.from(IMAGEGEN_RESULT_B64, "base64"))); +}); + +test("imagegen --background runs as a tracked job and saves the image", async () => { + const binDir = makeTempDir(); + const repo = setupImagegenRepo(binDir, "imagegen"); + const outPath = path.join(repo, "bg-fox.png"); + + const launched = run("node", [SCRIPT, "imagegen", "--background", "--out", outPath, "--json", "a small fox charm"], { + cwd: repo, + env: buildEnv(binDir), + timeout: 20000 + }); + assert.equal(launched.status, 0, launched.stderr); + const launchPayload = JSON.parse(launched.stdout); + assert.equal(launchPayload.status, "queued"); + assert.match(launchPayload.jobId, /^image-/); + + const waited = run( + "node", + [SCRIPT, "status", launchPayload.jobId, "--wait", "--timeout-ms", "15000", "--json"], + { cwd: repo, env: buildEnv(binDir), timeout: 20000 } + ); + assert.equal(waited.status, 0, waited.stderr); + const waitedPayload = JSON.parse(waited.stdout); + assert.equal(waitedPayload.job.id, launchPayload.jobId); + assert.equal(waitedPayload.job.status, "completed"); + assert.ok( + fs.readFileSync(outPath).equals(Buffer.from(IMAGEGEN_RESULT_B64, "base64")), + "the background job must write the captured image bytes" + ); +}); + test("review accepts the quoted raw argument style for built-in base-branch review", () => { const repo = makeTempDir(); const binDir = makeTempDir();