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();