merge M1 into implement/2026-05-19-ai-cli-dispatch-fixes

This commit is contained in:
2026-05-19 19:54:27 -05:00
8 changed files with 469 additions and 23 deletions
+27 -5
View File
@@ -8,6 +8,7 @@ import {
type ClientName, type ClientName,
type ClientInfo, type ClientInfo,
type ExecResult, type ExecResult,
type DebugInfo,
ClientNotFoundError, ClientNotFoundError,
} from "./types.js"; } from "./types.js";
@@ -15,13 +16,14 @@ export interface CliDeps {
detectClients?: () => ClientInfo[]; detectClients?: () => ClientInfo[];
executePrompt?: ( executePrompt?: (
client: ClientName, client: ClientName,
prompt: string prompt: string,
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
) => Promise<ExecResult>; ) => Promise<ExecResult>;
resolveClient?: ( resolveClient?: (
prompt: string, prompt: string,
config?: { client?: ClientName; defaultClient?: ClientName } config?: { client?: ClientName; defaultClient?: ClientName }
) => ClientName | null; ) => ClientName | null;
resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; defaultClient?: ClientName }; resolveConfig?: () => { paths: Partial<Record<ClientName, string>>; defaultClient?: ClientName; timeout?: number };
stdoutWrite?: (chunk: string) => void; stdoutWrite?: (chunk: string) => void;
stderrWrite?: (chunk: string) => void; stderrWrite?: (chunk: string) => void;
} }
@@ -54,12 +56,13 @@ export async function main(
rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs; rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs;
const args = minimist(parseArgs, { const args = minimist(parseArgs, {
string: ["client", "prompt"], string: ["client", "prompt", "timeout"],
boolean: ["json", "text", "help", "debug"], boolean: ["json", "text", "help", "debug"],
alias: { h: "help" }, alias: { h: "help" },
}); });
const jsonMode = !args.text; const jsonMode = !args.text;
const debug = !!args.debug;
if (args.help) { if (args.help) {
printHelp(); printHelp();
@@ -114,8 +117,18 @@ export async function main(
return 1; return 1;
} }
const config = resolveConfig();
const parsedTimeout =
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
const timeoutMs =
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
try { try {
const result = await executePrompt(client, prompt); const result = await executePrompt(client, prompt, {
timeoutMs,
debug,
onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined,
});
if (jsonMode) { if (jsonMode) {
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
} else { } else {
@@ -167,8 +180,17 @@ export async function main(
return 1; return 1;
} }
const parsedTimeout =
typeof args.timeout === "string" ? Number(args.timeout) : undefined;
const timeoutMs =
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
try { try {
const result = await executePrompt(client, prompt); const result = await executePrompt(client, prompt, {
timeoutMs,
debug,
onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined,
});
if (jsonMode) { if (jsonMode) {
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
} else { } else {
+18
View File
@@ -10,6 +10,7 @@ import { CLIENT_NAMES, isWindows } from "./constants.js";
export interface ResolvedConfig { export interface ResolvedConfig {
paths: Partial<Record<ClientName, string>>; paths: Partial<Record<ClientName, string>>;
defaultClient?: ClientName; defaultClient?: ClientName;
timeout?: number;
} }
export interface ResolveConfigOptions { export interface ResolveConfigOptions {
@@ -89,5 +90,22 @@ export function resolveConfig(
) { ) {
result.defaultClient = defaultClient as ClientName; result.defaultClient = defaultClient as ClientName;
} }
const flagTimeout =
typeof flags.timeout === "string" ? Number(flags.timeout) : undefined;
const envTimeout =
typeof env.AI_CLI_TIMEOUT === "string"
? Number(env.AI_CLI_TIMEOUT)
: undefined;
const fileTimeout =
typeof fileConfig.timeout === "number" ? fileConfig.timeout : undefined;
const resolvedTimeout =
(Number.isFinite(flagTimeout) ? flagTimeout : undefined) ??
(Number.isFinite(envTimeout) ? envTimeout : undefined) ??
(Number.isFinite(fileTimeout) ? fileTimeout : undefined) ??
600_000;
result.timeout = resolvedTimeout;
return result; return result;
} }
+1 -2
View File
@@ -1,3 +1,4 @@
import { CLIENT_NAMES } from "./constants.js";
import type { ClientName } from "./types.js"; import type { ClientName } from "./types.js";
export interface DispatchConfig { export interface DispatchConfig {
@@ -5,8 +6,6 @@ export interface DispatchConfig {
client?: ClientName; client?: ClientName;
} }
const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"];
export function resolveClient( export function resolveClient(
prompt: string, prompt: string,
config?: DispatchConfig config?: DispatchConfig
+41 -7
View File
@@ -2,7 +2,7 @@ import type { ChildProcess } from "node:child_process";
import { spawn as defaultSpawn } from "node:child_process"; import { spawn as defaultSpawn } from "node:child_process";
import type { PathLike } from "node:fs"; import type { PathLike } from "node:fs";
import { existsSync as defaultExistsSync } from "node:fs"; import { existsSync as defaultExistsSync } from "node:fs";
import type { ClientName, ExecResult } from "./types.js"; import type { ClientName, ExecResult, DebugInfo } from "./types.js";
import { ClientNotFoundError, ExecError } from "./types.js"; import { ClientNotFoundError, ExecError } from "./types.js";
const CLIENT_ARGS: Record<ClientName, (prompt: string) => string[]> = { const CLIENT_ARGS: Record<ClientName, (prompt: string) => string[]> = {
@@ -14,6 +14,8 @@ const CLIENT_ARGS: Record<ClientName, (prompt: string) => string[]> = {
export interface ExecuteOptions { export interface ExecuteOptions {
clientPath?: string; clientPath?: string;
timeoutMs?: number; timeoutMs?: number;
debug?: boolean;
onDebug?: (info: DebugInfo) => void;
spawn?: (command: string, args: string[], options?: { shell?: boolean }) => ChildProcess; spawn?: (command: string, args: string[], options?: { shell?: boolean }) => ChildProcess;
existsSync?: (path: PathLike) => boolean; existsSync?: (path: PathLike) => boolean;
} }
@@ -28,12 +30,14 @@ export async function executePrompt(
stdout: "", stdout: "",
stderr: "", stderr: "",
exitCode: -1, exitCode: -1,
client,
durationMs: 0,
}); });
} }
const spawnImpl = options.spawn ?? defaultSpawn; const spawnImpl = options.spawn ?? defaultSpawn;
const existsSyncImpl = options.existsSync ?? defaultExistsSync; const existsSyncImpl = options.existsSync ?? defaultExistsSync;
const timeoutMs = options.timeoutMs ?? 300_000; const timeoutMs = options.timeoutMs ?? 600_000;
const command = options.clientPath ?? client; const command = options.clientPath ?? client;
if (options.clientPath && !existsSyncImpl(options.clientPath)) { if (options.clientPath && !existsSyncImpl(options.clientPath)) {
@@ -46,6 +50,8 @@ export async function executePrompt(
stdout: "", stdout: "",
stderr: "", stderr: "",
exitCode: -1, exitCode: -1,
client,
durationMs: 0,
}); });
} }
const args = argBuilder(prompt); const args = argBuilder(prompt);
@@ -55,6 +61,8 @@ export async function executePrompt(
let timedOut = false; let timedOut = false;
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let exitSignal: NodeJS.Signals | null = null;
const startMs = Date.now();
const child = spawnImpl(command, args, { const child = spawnImpl(command, args, {
shell: false, shell: false,
@@ -80,8 +88,31 @@ export async function executePrompt(
if (settled) return; if (settled) return;
settled = true; settled = true;
clearTimeout(timeout); clearTimeout(timeout);
if (err) reject(err); const durationMs = Date.now() - startMs;
else resolve(result!); if (options.debug || options.onDebug) {
const effectiveExitCode = result?.exitCode ?? (err instanceof ExecError ? err.result.exitCode : null);
const debugInfo: DebugInfo = {
command,
args,
pid: child.pid ?? undefined,
exitCode: effectiveExitCode,
exitSignal,
durationMs,
stderrLength: stderr.length,
stdoutLength: stdout.length,
noisySuccess: effectiveExitCode === 0 && stderr.length > 0,
};
options.onDebug?.(debugInfo);
}
if (err) {
if (err instanceof ExecError) {
err.result.client = client;
err.result.durationMs = durationMs;
}
reject(err);
} else {
resolve({ ...result!, client, durationMs });
}
} }
child.on("error", (err: NodeJS.ErrnoException) => { child.on("error", (err: NodeJS.ErrnoException) => {
@@ -89,22 +120,25 @@ export async function executePrompt(
settle(new ClientNotFoundError(client)); settle(new ClientNotFoundError(client));
} else { } else {
settle( settle(
new ExecError(err.message, { stdout, stderr, exitCode: -1 }) new ExecError(err.message, { stdout, stderr, exitCode: -1, client, durationMs: 0 })
); );
} }
}); });
child.on("close", (code: number | null) => { child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
exitSignal = signal;
if (timedOut) { if (timedOut) {
settle( settle(
new ExecError(`Execution timed out after ${timeoutMs}ms`, { new ExecError(`Execution timed out after ${timeoutMs}ms`, {
stdout, stdout,
stderr, stderr,
exitCode: -1, exitCode: -1,
client,
durationMs: 0,
}) })
); );
} else { } else {
settle(undefined, { stdout, stderr, exitCode: code ?? -1 }); settle(undefined, { stdout, stderr, exitCode: code ?? -1, client, durationMs: 0 });
} }
}); });
}); });
+12 -3
View File
@@ -11,11 +11,20 @@ export interface ExecResult {
stdout: string; stdout: string;
stderr: string; stderr: string;
exitCode: number; exitCode: number;
client: ClientName;
durationMs: number;
} }
export interface ToolConfig { export interface DebugInfo {
clients: ClientName[]; command: string;
defaultClient?: ClientName; args: string[];
pid?: number;
exitCode: number | null;
exitSignal: NodeJS.Signals | null;
durationMs: number;
stderrLength: number;
stdoutLength: number;
noisySuccess: boolean;
} }
export class ClientNotFoundError extends Error { export class ClientNotFoundError extends Error {
+144 -6
View File
@@ -39,6 +39,8 @@ describe("main", () => {
stdout: "output", stdout: "output",
stderr: "", stderr: "",
exitCode: 0, exitCode: 0,
client: "codex",
durationMs: 42,
}; };
it("returns 0 for --help and prints usage", async () => { it("returns 0 for --help and prints usage", async () => {
@@ -152,6 +154,8 @@ describe("main", () => {
stdout: "hello-out", stdout: "hello-out",
stderr: "hello-err", stderr: "hello-err",
exitCode: 0, exitCode: 0,
client: "codex",
durationMs: 42,
}), }),
stdoutWrite: (chunk) => stdoutChunks.push(chunk), stdoutWrite: (chunk) => stdoutChunks.push(chunk),
stderrWrite: (chunk) => stderrChunks.push(chunk), stderrWrite: (chunk) => stderrChunks.push(chunk),
@@ -259,7 +263,7 @@ describe("main", () => {
return mockResult; return mockResult;
}, },
resolveClient: () => "claude", resolveClient: () => "claude",
resolveConfig: () => ({ paths: {} }), resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
} }
); );
assert.strictEqual(code, 0); assert.strictEqual(code, 0);
@@ -286,7 +290,7 @@ describe("main", () => {
return mockResult; return mockResult;
}, },
resolveClient: () => "codex", resolveClient: () => "codex",
resolveConfig: () => ({ paths: {} }), resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
} }
); );
assert.strictEqual(code, 0); assert.strictEqual(code, 0);
@@ -310,7 +314,7 @@ describe("main", () => {
return mockResult; return mockResult;
}, },
resolveClient: (_p, cfg) => cfg?.client ?? null, resolveClient: (_p, cfg) => cfg?.client ?? null,
resolveConfig: () => ({ paths: {}, defaultClient: "claude" }), resolveConfig: () => ({ paths: {}, defaultClient: "claude", timeout: 600_000 }),
} }
); );
assert.strictEqual(code, 0); assert.strictEqual(code, 0);
@@ -329,7 +333,7 @@ describe("main", () => {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async () => mockResult, executePrompt: async () => mockResult,
resolveClient: () => null, resolveClient: () => null,
resolveConfig: () => ({ paths: {} }), resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
} }
); );
assert.strictEqual(code, 1); assert.strictEqual(code, 1);
@@ -351,7 +355,7 @@ describe("main", () => {
throw new ClientNotFoundError("claude"); throw new ClientNotFoundError("claude");
}, },
resolveClient: () => "claude", resolveClient: () => "claude",
resolveConfig: () => ({ paths: {} }), resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
} }
); );
assert.strictEqual(code, 1); assert.strictEqual(code, 1);
@@ -388,9 +392,11 @@ describe("main", () => {
stdout: "done", stdout: "done",
stderr: "", stderr: "",
exitCode: 0, exitCode: 0,
client: "codex",
durationMs: 42,
}), }),
resolveClient: () => "codex", resolveClient: () => "codex",
resolveConfig: () => ({ paths: {} }), resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
stdoutWrite: (chunk) => stdoutChunks.push(chunk), stdoutWrite: (chunk) => stdoutChunks.push(chunk),
} }
); );
@@ -400,4 +406,136 @@ describe("main", () => {
out.restore(); out.restore();
} }
}); });
it("run prints debug diagnostic JSON to stderr with --debug", async () => {
const out = captureOutput();
const stderrChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug"],
{
detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => {
options?.onDebug?.({
command: "codex",
args: ["exec", "--yolo", "hello"],
pid: 12345,
exitCode: 0,
exitSignal: null,
durationMs: 42,
stderrLength: 0,
stdoutLength: 6,
} as any);
return {
stdout: "output",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 42,
};
},
stderrWrite: (chunk) => stderrChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stderrChunks.length, 1);
const diag = JSON.parse(stderrChunks[0]);
assert.strictEqual(diag.command, "codex");
assert.deepStrictEqual(diag.args, ["exec", "--yolo", "hello"]);
assert.strictEqual(diag.pid, 12345);
assert.strictEqual(diag.exitCode, 0);
assert.strictEqual(diag.exitSignal, null);
assert.strictEqual(diag.durationMs, 42);
assert.strictEqual(diag.stderrLength, 0);
} finally {
out.restore();
}
});
it("run ignores non-numeric --timeout and falls back to config timeout", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "not-a-number"],
{
detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => {
receivedTimeout = options?.timeoutMs;
return mockResult;
},
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 600_000);
} finally {
out.restore();
}
});
it("dispatch ignores non-numeric --timeout and falls back to config timeout", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--timeout", "bad"],
{
detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => {
receivedTimeout = options?.timeoutMs;
return mockResult;
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 600_000);
} finally {
out.restore();
}
});
it("dispatch prints debug diagnostic JSON to stderr with --debug", async () => {
const out = captureOutput();
const stderrChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--debug"],
{
detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => {
options?.onDebug?.({
command: "codex",
args: ["exec", "--yolo", "do something"],
pid: 12345,
exitCode: 0,
exitSignal: null,
durationMs: 42,
stderrLength: 0,
stdoutLength: 6,
} as any);
return {
stdout: "output",
stderr: "",
exitCode: 0,
client: "codex",
durationMs: 42,
};
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
stderrWrite: (chunk) => stderrChunks.push(chunk),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(stderrChunks.length, 1);
const diag = JSON.parse(stderrChunks[0]);
assert.strictEqual(diag.command, "codex");
assert.strictEqual(diag.durationMs, 42);
} finally {
out.restore();
}
});
}); });
@@ -135,4 +135,71 @@ describe("resolveConfig", () => {
}); });
assert.strictEqual(config.defaultClient, undefined); assert.strictEqual(config.defaultClient, undefined);
}); });
it("returns default timeout of 600000 when no sources are present", () => {
const config = resolveConfig({
existsSync: () => false,
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 600_000);
});
it("loads timeout from file config", () => {
const config = resolveConfig({
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 120_000);
});
it("overrides file timeout with env var", () => {
const config = resolveConfig({
env: { AI_CLI_TIMEOUT: "240000" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 240_000);
});
it("overrides env timeout with CLI flag", () => {
const config = resolveConfig({
flags: { timeout: "480000" },
env: { AI_CLI_TIMEOUT: "240000" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 480_000);
});
it("respects full priority ordering for timeout: flag > env > file > default", () => {
const config = resolveConfig({
flags: { timeout: "480000" },
env: { AI_CLI_TIMEOUT: "240000" },
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: 120_000 }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 480_000);
});
it("ignores invalid timeout from env var and falls back to default", () => {
const config = resolveConfig({
env: { AI_CLI_TIMEOUT: "not-a-number" },
existsSync: () => false,
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 600_000);
});
it("ignores invalid timeout from file and falls back to default", () => {
const config = resolveConfig({
existsSync: () => true,
readFileSync: () => JSON.stringify({ timeout: "not-a-number" }),
whichSync: () => undefined,
});
assert.strictEqual(config.timeout, 600_000);
});
}); });
+159
View File
@@ -16,6 +16,7 @@ interface MockScenario {
function createMockChildProcess(scenario: MockScenario): any { function createMockChildProcess(scenario: MockScenario): any {
const child = new EventEmitter() as any; const child = new EventEmitter() as any;
child.pid = 12345;
child.stdout = Readable.from( child.stdout = Readable.from(
scenario.stdout !== undefined ? [scenario.stdout] : [] scenario.stdout !== undefined ? [scenario.stdout] : []
); );
@@ -203,4 +204,162 @@ describe("executePrompt", () => {
err instanceof ExecError && err.message.includes("Unknown client") err instanceof ExecError && err.message.includes("Unknown client")
); );
}); });
it("includes client and durationMs in result", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }],
]);
const result = await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(result.client, "codex");
assert.strictEqual(typeof result.durationMs, "number");
assert.ok(result.durationMs >= 0);
});
it("rejects with ExecError containing custom timeout value", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo slow", { hang: true }],
]);
await assert.rejects(
executePrompt("codex", "slow", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
timeoutMs: 50,
}),
(err: unknown) =>
err instanceof ExecError &&
err.message === "Execution timed out after 50ms" &&
err.result.exitCode === -1 &&
err.result.client === "codex" &&
typeof err.result.durationMs === "number"
);
});
it("uses default timeout of 600000 when timeoutMs is not provided", async () => {
const delays: number[] = [];
const origSetTimeout = global.setTimeout;
(global as any).setTimeout = function(callback: any, delay: number) {
delays.push(delay);
return origSetTimeout(callback, delay);
};
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }],
]);
try {
await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
});
assert.strictEqual(delays[0], 600_000);
} finally {
global.setTimeout = origSetTimeout;
}
});
it("emits debug info via onDebug when debug is true for successful execution", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", stderr: "warn", exitCode: 0 }],
]);
const debugInfos: any[] = [];
const result = await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(result.exitCode, 0);
assert.strictEqual(debugInfos.length, 1);
const info = debugInfos[0];
assert.strictEqual(info.command, "codex");
assert.deepStrictEqual(info.args, ["exec", "--yolo", "hello"]);
assert.strictEqual(info.pid, 12345);
assert.strictEqual(info.exitCode, 0);
assert.strictEqual(info.exitSignal, null);
assert.strictEqual(info.stderrLength, 4);
assert.strictEqual(info.stdoutLength, 2);
assert.strictEqual(typeof info.durationMs, "number");
assert.ok(info.durationMs >= 0);
});
it("emits debug info via onDebug when debug is true for failed execution", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo fail", { stdout: "", stderr: "error", exitCode: 1 }],
]);
const debugInfos: any[] = [];
const result = await executePrompt("codex", "fail", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(result.exitCode, 1);
assert.strictEqual(debugInfos.length, 1);
assert.strictEqual(debugInfos[0].exitCode, 1);
assert.strictEqual(debugInfos[0].stderrLength, 5);
assert.strictEqual(debugInfos[0].stdoutLength, 0);
});
it("emits debug info via onDebug for spawn errors", async () => {
const scenarios = new Map<string, MockScenario>();
const debugInfos: any[] = [];
await assert.rejects(
executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
}),
(err: unknown) => err instanceof ClientNotFoundError
);
assert.strictEqual(debugInfos.length, 1);
assert.strictEqual(debugInfos[0].command, "codex");
assert.deepStrictEqual(debugInfos[0].args, ["exec", "--yolo", "hello"]);
assert.strictEqual(debugInfos[0].exitCode, null);
assert.strictEqual(debugInfos[0].exitSignal, null);
});
it("reports noisySuccess=true when stderr is non-empty and exitCode is 0", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", stderr: "warn", exitCode: 0 }],
]);
const debugInfos: any[] = [];
await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(debugInfos[0].noisySuccess, true);
});
it("reports noisySuccess=false when stderr is empty and exitCode is 0", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo hello", { stdout: "ok", stderr: "", exitCode: 0 }],
]);
const debugInfos: any[] = [];
await executePrompt("codex", "hello", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(debugInfos[0].noisySuccess, false);
});
it("reports noisySuccess=false when exitCode is non-zero even if stderr is non-empty", async () => {
const scenarios = new Map<string, MockScenario>([
["codex exec --yolo fail", { stdout: "", stderr: "error", exitCode: 1 }],
]);
const debugInfos: any[] = [];
await executePrompt("codex", "fail", {
spawn: mockSpawn(scenarios),
existsSync: () => true,
debug: true,
onDebug: (info) => debugInfos.push(info),
});
assert.strictEqual(debugInfos[0].noisySuccess, false);
});
}); });