merge M3 into implement/2026-05-19-ai-cli-dispatch-fixes
This commit is contained in:
@@ -28,12 +28,13 @@ npm install
|
||||
|
||||
```bash
|
||||
scripts/ai-cli-dispatch list --json
|
||||
scripts/ai-cli-dispatch exec --client codex --prompt "refactor this function"
|
||||
scripts/ai-cli-dispatch exec --client claude --prompt "add tests for auth middleware"
|
||||
scripts/ai-cli-dispatch exec --client opencode --prompt "migrate to ESM"
|
||||
scripts/ai-cli-dispatch run --client codex --prompt "refactor this function"
|
||||
scripts/ai-cli-dispatch run --client claude --prompt "add tests for auth middleware"
|
||||
scripts/ai-cli-dispatch run --client opencode --prompt "migrate to ESM"
|
||||
scripts/ai-cli-dispatch dispatch "use codex to write tests"
|
||||
```
|
||||
|
||||
Use `--json` for machine-readable command output.
|
||||
Use `--json` for machine-readable command output. Use `--sync` with `run` or `dispatch` to block until the client returns; the default is async (starts a background job and returns the job ID immediately).
|
||||
|
||||
## Client Discovery
|
||||
|
||||
@@ -47,7 +48,7 @@ Run `list` to see which clients are installed and their resolved versions.
|
||||
|
||||
## Background Jobs
|
||||
|
||||
For long-running or fire-and-forget tasks, use the programmatic job API instead of `exec`:
|
||||
For long-running or fire-and-forget tasks, use the programmatic job API or invoke `run` / `dispatch` without `--sync`:
|
||||
|
||||
```typescript
|
||||
import { startJob, getJob, cancelJob, listJobs, cleanupJobs } from "./src/jobs.js";
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ClientName, ExecResult, DebugInfo, Job } from "./types.js";
|
||||
|
||||
export interface RunContext {
|
||||
jsonMode: boolean;
|
||||
stdoutWrite: (chunk: string) => void;
|
||||
stderrWrite: (chunk: string) => void;
|
||||
}
|
||||
|
||||
export function reportError(err: unknown, jsonMode: boolean): number {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(message);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function reportCliError(message: string, jsonMode: boolean): number {
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export async function handleSyncRun(
|
||||
executePrompt: (
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
||||
) => Promise<ExecResult>,
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
timeoutMs: number | undefined,
|
||||
debug: boolean,
|
||||
ctx: RunContext
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await executePrompt(client, prompt, {
|
||||
timeoutMs,
|
||||
debug,
|
||||
onDebug: debug ? (info) => ctx.stderrWrite(JSON.stringify(info) + "\n") : undefined,
|
||||
});
|
||||
if (ctx.jsonMode) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.stdout) ctx.stdoutWrite(result.stdout);
|
||||
if (result.stderr) ctx.stderrWrite(result.stderr);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, ctx.jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleAsyncRun(
|
||||
startJob: (
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
||||
) => Promise<Job>,
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
timeoutMs: number | undefined,
|
||||
debug: boolean,
|
||||
ctx: RunContext
|
||||
): Promise<number> {
|
||||
try {
|
||||
const job = await startJob(client, prompt, {
|
||||
timeoutMs,
|
||||
debug,
|
||||
onDebug: debug ? (info) => ctx.stderrWrite(JSON.stringify(info) + "\n") : undefined,
|
||||
});
|
||||
if (ctx.jsonMode) {
|
||||
console.log(JSON.stringify({ jobId: job.id, client: job.client, status: job.status }, null, 2));
|
||||
} else {
|
||||
console.log(`Job ${job.id} started (${job.client}): ${job.status}`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, ctx.jsonMode);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import minimist from "minimist";
|
||||
import { detectClients as realDetectClients } from "./detect.js";
|
||||
import { executePrompt as realExecutePrompt } from "./execute.js";
|
||||
import {
|
||||
startJob as realStartJob,
|
||||
getJob as realGetJob,
|
||||
getJobResult as realGetJobResult,
|
||||
cancelJob as realCancelJob,
|
||||
listJobs as realListJobs,
|
||||
cleanupJobs as realCleanupJobs,
|
||||
} from "./jobs.js";
|
||||
import { resolveClient as realResolveClient } from "./dispatch.js";
|
||||
import { resolveConfig as realResolveConfig } from "./config.js";
|
||||
import { CLIENT_NAMES } from "./constants.js";
|
||||
@@ -9,8 +17,16 @@ import {
|
||||
type ClientInfo,
|
||||
type ExecResult,
|
||||
type DebugInfo,
|
||||
type Job,
|
||||
type JobStatus,
|
||||
ClientNotFoundError,
|
||||
} from "./types.js";
|
||||
import {
|
||||
reportError,
|
||||
reportCliError,
|
||||
handleSyncRun,
|
||||
handleAsyncRun,
|
||||
} from "./cli-helpers.js";
|
||||
|
||||
export interface CliDeps {
|
||||
detectClients?: () => ClientInfo[];
|
||||
@@ -19,6 +35,11 @@ export interface CliDeps {
|
||||
prompt: string,
|
||||
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
||||
) => Promise<ExecResult>;
|
||||
startJob?: (
|
||||
client: ClientName,
|
||||
prompt: string,
|
||||
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
|
||||
) => Promise<Job>;
|
||||
resolveClient?: (
|
||||
prompt: string,
|
||||
config?: { client?: ClientName; defaultClient?: ClientName }
|
||||
@@ -26,6 +47,29 @@ export interface CliDeps {
|
||||
resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; defaultClient?: ClientName; timeout?: number };
|
||||
stdoutWrite?: (chunk: string) => void;
|
||||
stderrWrite?: (chunk: string) => void;
|
||||
getJob?: (jobId: string) => Job;
|
||||
getJobResult?: (jobId: string) => ExecResult;
|
||||
cancelJob?: (jobId: string) => void;
|
||||
listJobs?: (options?: { filter?: JobStatus }) => Job[];
|
||||
cleanupJobs?: (options?: { maxAgeMs?: number }) => void;
|
||||
}
|
||||
|
||||
function parseMaxAge(value: string): number | null {
|
||||
const match = value.match(/^(\d+(?:\.\d+)?)\s*([hmsd]?)$/i);
|
||||
if (!match) return null;
|
||||
const num = parseFloat(match[1]);
|
||||
if (!Number.isFinite(num) || num < 0) return null;
|
||||
const unit = match[2].toLowerCase();
|
||||
const multipliers: Record<string, number> = {
|
||||
s: 1000,
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
"": 60 * 60 * 1000,
|
||||
};
|
||||
const multiplier = multipliers[unit];
|
||||
if (multiplier === undefined) return null;
|
||||
return num * multiplier;
|
||||
}
|
||||
|
||||
function printHelp(): void {
|
||||
@@ -33,10 +77,25 @@ function printHelp(): void {
|
||||
|
||||
Usage:
|
||||
ai-cli-dispatch list [--json|--text]
|
||||
ai-cli-dispatch run --client <client> --prompt <prompt> [--json|--text]
|
||||
ai-cli-dispatch dispatch <prompt> [--client <client>] [--json|--text]
|
||||
ai-cli-dispatch run --client <client> --prompt <prompt> [--sync] [--timeout <ms>] [--debug] [--json|--text]
|
||||
ai-cli-dispatch dispatch <prompt> [--client <client>] [--sync] [--timeout <ms>] [--debug] [--json|--text]
|
||||
ai-cli-dispatch start --client <client> --prompt <prompt> [--timeout <ms>] [--debug] [--json|--text]
|
||||
ai-cli-dispatch status <job-id> [--json|--text]
|
||||
ai-cli-dispatch results <job-id> [--json|--text]
|
||||
ai-cli-dispatch cancel <job-id> [--json|--text]
|
||||
ai-cli-dispatch list-jobs [--status running|completed|failed] [--json|--text]
|
||||
ai-cli-dispatch cleanup-jobs [--max-age <number>[h|m|s|d]] [--json|--text]
|
||||
ai-cli-dispatch --help
|
||||
|
||||
Flags:
|
||||
--sync Run synchronously and block until the client returns (default is async)
|
||||
--timeout Timeout in milliseconds (or override via config)
|
||||
--debug Emit diagnostic JSON to stderr
|
||||
--max-age Maximum age for cleanup (default unit: hours, e.g. 24h or 30m)
|
||||
--status Filter jobs by status (running, completed, failed)
|
||||
--json Output JSON (default)
|
||||
--text Output plain text
|
||||
|
||||
Clients: codex, claude, opencode`);
|
||||
}
|
||||
|
||||
@@ -46,18 +105,24 @@ export async function main(
|
||||
): Promise<number> {
|
||||
const detectClients = deps.detectClients ?? realDetectClients;
|
||||
const executePrompt = deps.executePrompt ?? realExecutePrompt;
|
||||
const startJob = deps.startJob ?? realStartJob;
|
||||
const resolveClient = deps.resolveClient ?? realResolveClient;
|
||||
const resolveConfig = deps.resolveConfig ?? realResolveConfig;
|
||||
const stdoutWrite = deps.stdoutWrite ?? ((c: string) => process.stdout.write(c));
|
||||
const stderrWrite = deps.stderrWrite ?? ((c: string) => process.stderr.write(c));
|
||||
const getJob = deps.getJob ?? realGetJob;
|
||||
const getJobResult = deps.getJobResult ?? realGetJobResult;
|
||||
const cancelJob = deps.cancelJob ?? realCancelJob;
|
||||
const listJobs = deps.listJobs ?? realListJobs;
|
||||
const cleanupJobs = deps.cleanupJobs ?? realCleanupJobs;
|
||||
|
||||
const rawArgs = argv.slice(2);
|
||||
const parseArgs =
|
||||
rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs;
|
||||
|
||||
const args = minimist(parseArgs, {
|
||||
string: ["client", "prompt", "timeout"],
|
||||
boolean: ["json", "text", "help", "debug"],
|
||||
string: ["client", "prompt", "timeout", "max-age"],
|
||||
boolean: ["json", "text", "help", "debug", "sync"],
|
||||
alias: { h: "help" },
|
||||
});
|
||||
|
||||
@@ -96,25 +161,14 @@ export async function main(
|
||||
const prompt = args.prompt as string | undefined;
|
||||
|
||||
if (!client || !CLIENT_NAMES.includes(client)) {
|
||||
const message = !client
|
||||
? "--client is required"
|
||||
: `Unknown client: ${client}`;
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
return 1;
|
||||
return reportCliError(
|
||||
!client ? "--client is required" : `Unknown client: ${client}`,
|
||||
jsonMode
|
||||
);
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
const message = "--prompt is required";
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
return 1;
|
||||
return reportCliError("--prompt is required", jsonMode);
|
||||
}
|
||||
|
||||
const config = resolveConfig();
|
||||
@@ -123,28 +177,11 @@ export async function main(
|
||||
const timeoutMs =
|
||||
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
|
||||
|
||||
try {
|
||||
const result = await executePrompt(client, prompt, {
|
||||
timeoutMs,
|
||||
debug,
|
||||
onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined,
|
||||
});
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (result.stdout) stdoutWrite(result.stdout);
|
||||
if (result.stderr) stderrWrite(result.stderr);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(message);
|
||||
}
|
||||
return 1;
|
||||
const ctx = { jsonMode, stdoutWrite, stderrWrite };
|
||||
if (args.sync) {
|
||||
return await handleSyncRun(executePrompt, client, prompt, timeoutMs, debug, ctx);
|
||||
}
|
||||
return await handleAsyncRun(startJob, client, prompt, timeoutMs, debug, ctx);
|
||||
}
|
||||
|
||||
if (command === "dispatch") {
|
||||
@@ -154,13 +191,7 @@ export async function main(
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
const message = "prompt is required";
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
return 1;
|
||||
return reportCliError("prompt is required", jsonMode);
|
||||
}
|
||||
|
||||
const config = resolveConfig();
|
||||
@@ -171,26 +202,86 @@ export async function main(
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
const message = "Could not resolve client from prompt";
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(`Error: ${message}`);
|
||||
}
|
||||
return 1;
|
||||
return reportCliError("Could not resolve client from prompt", jsonMode);
|
||||
}
|
||||
|
||||
const parsedTimeout =
|
||||
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
|
||||
const timeoutMs =
|
||||
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
|
||||
|
||||
const ctx = { jsonMode, stdoutWrite, stderrWrite };
|
||||
if (args.sync) {
|
||||
return await handleSyncRun(executePrompt, client, prompt, timeoutMs, debug, ctx);
|
||||
}
|
||||
return await handleAsyncRun(startJob, client, prompt, timeoutMs, debug, ctx);
|
||||
}
|
||||
|
||||
if (command === "start") {
|
||||
const client = args.client as ClientName | undefined;
|
||||
const prompt = args.prompt as string | undefined;
|
||||
|
||||
if (!client || !CLIENT_NAMES.includes(client)) {
|
||||
return reportCliError(
|
||||
!client ? "--client is required" : `Unknown client: ${client}`,
|
||||
jsonMode
|
||||
);
|
||||
}
|
||||
|
||||
if (!prompt) {
|
||||
return reportCliError("--prompt is required", jsonMode);
|
||||
}
|
||||
|
||||
const config = resolveConfig();
|
||||
const parsedTimeout =
|
||||
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
|
||||
const timeoutMs =
|
||||
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
|
||||
|
||||
try {
|
||||
const result = await executePrompt(client, prompt, {
|
||||
const job = await startJob(client, prompt, {
|
||||
timeoutMs,
|
||||
debug,
|
||||
onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined,
|
||||
});
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify({ jobId: job.id, client: job.client, status: job.status }, null, 2));
|
||||
} else {
|
||||
console.log(`Job ${job.id} started (${job.client}): ${job.status}`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
if (command === "status") {
|
||||
const jobId = args._[1];
|
||||
if (!jobId) {
|
||||
return reportCliError("job-id is required", jsonMode);
|
||||
}
|
||||
|
||||
try {
|
||||
const job = getJob(jobId);
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(job, null, 2));
|
||||
} else {
|
||||
console.log(`Job ${jobId}: ${job.status}`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
if (command === "results") {
|
||||
const jobId = args._[1];
|
||||
if (!jobId) {
|
||||
return reportCliError("job-id is required", jsonMode);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = getJobResult(jobId);
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
@@ -199,23 +290,84 @@ export async function main(
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(message);
|
||||
}
|
||||
return 1;
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
const message = `Unknown command: ${command}`;
|
||||
if (jsonMode) {
|
||||
console.error(JSON.stringify({ error: message }, null, 2));
|
||||
} else {
|
||||
console.error(message);
|
||||
if (command === "cancel") {
|
||||
const jobId = args._[1];
|
||||
if (!jobId) {
|
||||
return reportCliError("job-id is required", jsonMode);
|
||||
}
|
||||
|
||||
try {
|
||||
const job = getJob(jobId);
|
||||
if (job.status !== "running") {
|
||||
return reportCliError(
|
||||
`Job is not running (status: ${job.status})`,
|
||||
jsonMode
|
||||
);
|
||||
}
|
||||
cancelJob(jobId);
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify({ jobId, cancelled: true }, null, 2));
|
||||
} else {
|
||||
console.log(`Job ${jobId} cancelled`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
|
||||
if (command === "list-jobs") {
|
||||
try {
|
||||
const filter = args.status as JobStatus | undefined;
|
||||
const jobs = listJobs({ filter });
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(jobs, null, 2));
|
||||
} else {
|
||||
for (const job of jobs) {
|
||||
console.log(`${job.id} (${job.client}): ${job.status}`);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
if (command === "cleanup-jobs") {
|
||||
const maxAgeRaw = args["max-age"] as string | undefined;
|
||||
let maxAgeMs: number | undefined;
|
||||
if (maxAgeRaw !== undefined) {
|
||||
const parsed = parseMaxAge(maxAgeRaw);
|
||||
if (parsed === null) {
|
||||
return reportCliError(
|
||||
"Invalid --max-age format. Use: <number>[h|m|s|d], e.g. 24h",
|
||||
jsonMode
|
||||
);
|
||||
}
|
||||
maxAgeMs = parsed;
|
||||
}
|
||||
|
||||
try {
|
||||
const jobsBefore = listJobs();
|
||||
cleanupJobs({ maxAgeMs });
|
||||
const jobsAfter = listJobs();
|
||||
const count = jobsBefore.length - jobsAfter.length;
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify({ count }, null, 2));
|
||||
} else {
|
||||
console.log(`Cleaned ${count} jobs`);
|
||||
}
|
||||
return 0;
|
||||
} catch (err) {
|
||||
return reportError(err, jsonMode);
|
||||
}
|
||||
}
|
||||
|
||||
return reportError(`Unknown command: ${command}`, jsonMode);
|
||||
}
|
||||
|
||||
const isMain =
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import {
|
||||
reportError,
|
||||
reportCliError,
|
||||
handleSyncRun,
|
||||
handleAsyncRun,
|
||||
} from "../src/cli-helpers.js";
|
||||
import type { ExecResult, Job } from "../src/types.js";
|
||||
|
||||
function captureConsole() {
|
||||
const logs: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const origLog = console.log;
|
||||
const origError = console.error;
|
||||
console.log = (...args: unknown[]) => logs.push(args.map(String).join(" "));
|
||||
console.error = (...args: unknown[]) => errors.push(args.map(String).join(" "));
|
||||
return {
|
||||
logs,
|
||||
errors,
|
||||
restore() {
|
||||
console.log = origLog;
|
||||
console.error = origError;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("reportError", () => {
|
||||
it("prints JSON error for Error instance when jsonMode=true", () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = reportError(new Error("boom"), true);
|
||||
assert.strictEqual(code, 1);
|
||||
assert.strictEqual(out.errors.length, 1);
|
||||
const parsed = JSON.parse(out.errors[0]);
|
||||
assert.strictEqual(parsed.error, "boom");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("prints plain text error for Error instance when jsonMode=false", () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = reportError(new Error("boom"), false);
|
||||
assert.strictEqual(code, 1);
|
||||
assert.strictEqual(out.errors.length, 1);
|
||||
assert.strictEqual(out.errors[0], "boom");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("prints JSON error for non-Error value when jsonMode=true", () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = reportError("plain string", true);
|
||||
assert.strictEqual(code, 1);
|
||||
const parsed = JSON.parse(out.errors[0]);
|
||||
assert.strictEqual(parsed.error, "plain string");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportCliError", () => {
|
||||
it("prints JSON error with jsonMode=true", () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = reportCliError("missing arg", true);
|
||||
assert.strictEqual(code, 1);
|
||||
const parsed = JSON.parse(out.errors[0]);
|
||||
assert.strictEqual(parsed.error, "missing arg");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("prints prefixed text error with jsonMode=false", () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = reportCliError("missing arg", false);
|
||||
assert.strictEqual(code, 1);
|
||||
assert.strictEqual(out.errors[0], "Error: missing arg");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleSyncRun", () => {
|
||||
it("returns 0 and prints JSON result in jsonMode", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const result: ExecResult = {
|
||||
stdout: "out",
|
||||
stderr: "err",
|
||||
exitCode: 0,
|
||||
client: "codex",
|
||||
durationMs: 10,
|
||||
};
|
||||
const code = await handleSyncRun(
|
||||
async () => result,
|
||||
"codex",
|
||||
"hello",
|
||||
5000,
|
||||
false,
|
||||
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(out.logs.length, 1);
|
||||
const parsed = JSON.parse(out.logs[0]);
|
||||
assert.strictEqual(parsed.stdout, "out");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 0 and writes stdout/stderr in text mode", async () => {
|
||||
const out = captureConsole();
|
||||
const stdout: string[] = [];
|
||||
const stderr: string[] = [];
|
||||
try {
|
||||
const result: ExecResult = {
|
||||
stdout: "out",
|
||||
stderr: "err",
|
||||
exitCode: 0,
|
||||
client: "codex",
|
||||
durationMs: 10,
|
||||
};
|
||||
const code = await handleSyncRun(
|
||||
async () => result,
|
||||
"codex",
|
||||
"hello",
|
||||
5000,
|
||||
false,
|
||||
{
|
||||
jsonMode: false,
|
||||
stdoutWrite: (c) => stdout.push(c),
|
||||
stderrWrite: (c) => stderr.push(c),
|
||||
}
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(stdout.join(""), "out");
|
||||
assert.strictEqual(stderr.join(""), "err");
|
||||
assert.strictEqual(out.logs.length, 0);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("passes timeoutMs and debug to executePrompt", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
let received: any;
|
||||
const code = await handleSyncRun(
|
||||
async (_c, _p, opts) => {
|
||||
received = opts;
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: 0,
|
||||
client: "codex",
|
||||
durationMs: 1,
|
||||
};
|
||||
},
|
||||
"codex",
|
||||
"hello",
|
||||
12345,
|
||||
true,
|
||||
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
assert.strictEqual(received?.timeoutMs, 12345);
|
||||
assert.strictEqual(received?.debug, true);
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 1 and prints error JSON when executePrompt throws", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = await handleSyncRun(
|
||||
async () => {
|
||||
throw new Error("fail");
|
||||
},
|
||||
"codex",
|
||||
"hello",
|
||||
undefined,
|
||||
false,
|
||||
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 1);
|
||||
const parsed = JSON.parse(out.errors[0]);
|
||||
assert.strictEqual(parsed.error, "fail");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAsyncRun", () => {
|
||||
it("returns 0 and prints JSON job in jsonMode", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const job: Job = {
|
||||
id: "j1",
|
||||
client: "codex",
|
||||
prompt: "hi",
|
||||
status: "running",
|
||||
startedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
const code = await handleAsyncRun(
|
||||
async () => job,
|
||||
"codex",
|
||||
"hi",
|
||||
undefined,
|
||||
false,
|
||||
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
const parsed = JSON.parse(out.logs[0]);
|
||||
assert.strictEqual(parsed.jobId, "j1");
|
||||
assert.strictEqual(parsed.status, "running");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 0 and prints text job info", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const job: Job = {
|
||||
id: "j1",
|
||||
client: "codex",
|
||||
prompt: "hi",
|
||||
status: "running",
|
||||
startedAt: "2024-01-01T00:00:00Z",
|
||||
};
|
||||
const code = await handleAsyncRun(
|
||||
async () => job,
|
||||
"codex",
|
||||
"hi",
|
||||
undefined,
|
||||
false,
|
||||
{ jsonMode: false, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 0);
|
||||
assert.ok(out.logs[0].includes("j1"));
|
||||
assert.ok(out.logs[0].includes("running"));
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 1 and prints error JSON when startJob throws", async () => {
|
||||
const out = captureConsole();
|
||||
try {
|
||||
const code = await handleAsyncRun(
|
||||
async () => {
|
||||
throw new Error("fail");
|
||||
},
|
||||
"codex",
|
||||
"hi",
|
||||
undefined,
|
||||
false,
|
||||
{ jsonMode: true, stdoutWrite: () => {}, stderrWrite: () => {} }
|
||||
);
|
||||
assert.strictEqual(code, 1);
|
||||
const parsed = JSON.parse(out.errors[0]);
|
||||
assert.strictEqual(parsed.error, "fail");
|
||||
} finally {
|
||||
out.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user