fix(daemon): accept --prompt-file for od media generate (#5534)

* fix(daemon): accept --prompt-file for od media generate

AGENTS.md's capability-exposure contract requires every od action that
takes a prompt to accept long-form prompts via --prompt-file <path|->
(so jobs piping through heredoc / jq / an external agent stay clean).
`od media generate` was the one prompt-taking action that missed it: it
read `flags.prompt` directly, while od run / od brand / od automation /
od files version-create all route through the shared readPromptFromFlags
(inline --prompt, else file, else stdin `-`). A long or multi-line media
prompt (detailed image/video descriptions, program-generated prompts)
therefore couldn't be fed from a file or a pipe, and could hit the argv
length limit.

Add 'prompt-file' to MEDIA_GENERATE_STRING_FLAGS and read the prompt via
readPromptFromFlags; --prompt behavior is unchanged. Restores the
consistency an agent driving `od` expects.

Regression test spawns the real CLI against a fake daemon and asserts a
long prompt reaches /api/projects/:id/media/generate from both
--prompt-file <file> and --prompt-file - (stdin). Red on main, green here.

* test(daemon): assert od media generate exits cleanly in the prompt-file spec

Review follow-up (non-blocking): the runCli helper resolved on any close
and discarded stderr, so the spec could pass even if the CLI exited
non-zero after the POST (during polling / result handling). Capture the
exit code + stderr and assert exit 0 (the fake daemon resolves both
/media/generate and the /media/tasks poll), so a post-POST failure fails
the spec instead of passing on the recorded request alone.

---------

Co-authored-by: tomsen-ai <230283659+tomsen-ai@users.noreply.github.com>
This commit is contained in:
ShiZai
2026-07-15 11:55:22 +00:00
committed by GitHub
co-authored by tomsen-ai
parent a795322b63
commit c92b4aea44
2 changed files with 138 additions and 1 deletions
+9 -1
View File
@@ -63,6 +63,7 @@ const MEDIA_GENERATE_STRING_FLAGS = new Set([
'surface',
'model',
'prompt',
'prompt-file',
'output',
'aspect',
'length',
@@ -901,10 +902,16 @@ async function runMediaGenerate(rawArgs) {
process.exit(2);
}
// Long-form media prompts (detailed image/video descriptions, program-
// generated prompts) arrive via --prompt-file <path|-> (stdin) per the CLI
// contract; readPromptFromFlags prefers an inline --prompt and otherwise reads
// the file/stdin, matching od run / od brand / od automation.
const prompt = await readPromptFromFlags(flags);
const body = {
surface,
model: flags.model,
prompt: flags.prompt,
prompt,
output: flags.output,
aspect: flags.aspect,
voice: flags.voice,
@@ -1195,6 +1202,7 @@ Required:
Common options:
--prompt "<text>" Generation prompt. ElevenLabs SFX prompts must stay under 450 characters.
--prompt-file <path|-> Read the prompt from a file, or - for stdin (for long-form prompts).
--output <filename> File to write under the project. Auto-named if omitted.
--aspect 1:1|16:9|9:16|4:3|3:4
--length <seconds> Video length.
@@ -0,0 +1,129 @@
// Gap-fill spec — `od media generate` must accept --prompt-file <path|->.
//
// AGENTS.md (Capability exposure / UI-CLI dual-track):
// "The CLI form must ... accept long-form prompts via --prompt-file <path|->,
// so jobs that pipe through xargs, jq, and <heredoc stay clean."
//
// Every other prompt-taking `od` action (run redesign, brand create, automation
// create, files version-create) already routes through readPromptFromFlags,
// which supports --prompt / --prompt-file / stdin. `od media generate` was the
// only prompt-taking action reading `flags.prompt` directly, so a long-form
// prompt could not be supplied from a file or a heredoc. This pins the fill.
//
// Live end-to-end: spawn the real src/cli.ts against a fake daemon that records
// the POST body, and assert the prompt reached the media/generate endpoint.
import { spawn } from 'node:child_process';
import http from 'node:http';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const daemonRoot = fileURLToPath(new URL('..', import.meta.url));
const cliEntry = fileURLToPath(new URL('../src/cli.ts', import.meta.url));
let server: http.Server | undefined;
let baseUrl = '';
let seen: Array<{ url: string; body: string }> = [];
let tmp = '';
beforeEach(async () => {
seen = [];
server = http.createServer((req, res) => {
let body = '';
req.setEncoding('utf8');
req.on('data', (c) => (body += c));
req.on('end', () => {
seen.push({ url: req.url ?? '', body });
if (req.method === 'POST' && (req.url ?? '').includes('/media/generate')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ taskId: 'task-1', status: 'queued' }));
return;
}
if ((req.url ?? '').includes('/media/tasks/')) {
// Immediately resolve the poll so the CLI exits cleanly.
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status: 'done', file: { name: 'out.png', size: 3 } }));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
});
await new Promise<void>((resolve) => server!.listen(0, '127.0.0.1', () => resolve()));
const addr = server.address() as { port: number };
baseUrl = `http://127.0.0.1:${addr.port}`;
tmp = await mkdtemp(path.join(os.tmpdir(), 'od-media-pf-'));
});
afterEach(async () => {
if (server) await new Promise<void>((r) => server!.close(() => r()));
server = undefined;
await rm(tmp, { recursive: true, force: true }).catch(() => {});
});
const LONG_PROMPT =
'A sweeping cinematic vista of a neon city at dusk, rain-slicked streets,\n' +
'volumetric fog, 85mm, shallow depth of field — line two of a long prompt.';
async function runCli(args: string[], input?: string): Promise<void> {
const { code, stderr } = await new Promise<{ code: number; stderr: string }>((resolve, reject) => {
const child = spawn(process.execPath, ['--import', 'tsx', cliEntry, ...args], {
cwd: daemonRoot,
env: { ...process.env, OD_PROJECT_ID: 'p1' },
stdio: ['pipe', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (c) => (stderr += c));
child.on('error', reject);
child.on('close', (exitCode) => resolve({ code: exitCode ?? -1, stderr }));
if (input !== undefined) child.stdin.end(input);
else child.stdin.end();
});
// The fake daemon resolves both /media/generate and the /media/tasks poll, so
// the CLI must run the whole flow to a clean exit. Assert exit 0 (surfacing
// stderr) so a non-zero exit AFTER the POST — a failure during polling or
// result handling — fails the spec instead of passing on the recorded request
// alone.
expect(code, `od media generate exited ${code}; stderr:\n${stderr}`).toBe(0);
}
describe('od media generate --prompt-file', () => {
it('reads a long-form prompt from a file and posts it to media/generate', async () => {
const promptFile = path.join(tmp, 'prompt.txt');
await writeFile(promptFile, LONG_PROMPT, 'utf8');
await runCli([
'media', 'generate',
'--surface', 'image',
'--model', 'test-model',
'--prompt-file', promptFile,
'--daemon-url', baseUrl,
]);
const gen = seen.find((r) => r.url.includes('/media/generate'));
expect(gen, 'media/generate was called').toBeTruthy();
const sent = JSON.parse(gen!.body);
expect(sent.prompt).toBe(LONG_PROMPT);
});
it('reads the prompt from stdin when --prompt-file is -', async () => {
await runCli(
[
'media', 'generate',
'--surface', 'image',
'--model', 'test-model',
'--prompt-file', '-',
'--daemon-url', baseUrl,
],
LONG_PROMPT,
);
const gen = seen.find((r) => r.url.includes('/media/generate'));
expect(gen, 'media/generate was called').toBeTruthy();
expect(JSON.parse(gen!.body).prompt).toBe(LONG_PROMPT);
});
});