feat(S-301): Test-drive and implement async default for run and dispatch

This commit is contained in:
2026-05-19 21:42:58 -05:00
parent e11c36b7d8
commit 62840b908e
2 changed files with 468 additions and 62 deletions
+57 -1
View File
@@ -1,6 +1,7 @@
import minimist from "minimist"; import minimist from "minimist";
import { detectClients as realDetectClients } from "./detect.js"; import { detectClients as realDetectClients } from "./detect.js";
import { executePrompt as realExecutePrompt } from "./execute.js"; import { executePrompt as realExecutePrompt } from "./execute.js";
import { startJob as realStartJob } from "./jobs.js";
import { resolveClient as realResolveClient } from "./dispatch.js"; import { resolveClient as realResolveClient } from "./dispatch.js";
import { resolveConfig as realResolveConfig } from "./config.js"; import { resolveConfig as realResolveConfig } from "./config.js";
import { CLIENT_NAMES } from "./constants.js"; import { CLIENT_NAMES } from "./constants.js";
@@ -9,6 +10,7 @@ import {
type ClientInfo, type ClientInfo,
type ExecResult, type ExecResult,
type DebugInfo, type DebugInfo,
type Job,
ClientNotFoundError, ClientNotFoundError,
} from "./types.js"; } from "./types.js";
@@ -19,6 +21,11 @@ export interface CliDeps {
prompt: string, prompt: string,
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void } options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
) => Promise<ExecResult>; ) => Promise<ExecResult>;
startJob?: (
client: ClientName,
prompt: string,
options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void }
) => Promise<Job>;
resolveClient?: ( resolveClient?: (
prompt: string, prompt: string,
config?: { client?: ClientName; defaultClient?: ClientName } config?: { client?: ClientName; defaultClient?: ClientName }
@@ -46,6 +53,7 @@ export async function main(
): Promise<number> { ): Promise<number> {
const detectClients = deps.detectClients ?? realDetectClients; const detectClients = deps.detectClients ?? realDetectClients;
const executePrompt = deps.executePrompt ?? realExecutePrompt; const executePrompt = deps.executePrompt ?? realExecutePrompt;
const startJob = deps.startJob ?? realStartJob;
const resolveClient = deps.resolveClient ?? realResolveClient; const resolveClient = deps.resolveClient ?? realResolveClient;
const resolveConfig = deps.resolveConfig ?? realResolveConfig; const resolveConfig = deps.resolveConfig ?? realResolveConfig;
const stdoutWrite = deps.stdoutWrite ?? ((c: string) => process.stdout.write(c)); const stdoutWrite = deps.stdoutWrite ?? ((c: string) => process.stdout.write(c));
@@ -57,7 +65,7 @@ export async function main(
const args = minimist(parseArgs, { const args = minimist(parseArgs, {
string: ["client", "prompt", "timeout"], string: ["client", "prompt", "timeout"],
boolean: ["json", "text", "help", "debug"], boolean: ["json", "text", "help", "debug", "sync"],
alias: { h: "help" }, alias: { h: "help" },
}); });
@@ -123,6 +131,7 @@ export async function main(
const timeoutMs = const timeoutMs =
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout; Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
if (args.sync) {
try { try {
const result = await executePrompt(client, prompt, { const result = await executePrompt(client, prompt, {
timeoutMs, timeoutMs,
@@ -145,6 +154,29 @@ export async function main(
} }
return 1; return 1;
} }
} else {
try {
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) {
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;
}
}
} }
if (command === "dispatch") { if (command === "dispatch") {
@@ -185,6 +217,7 @@ export async function main(
const timeoutMs = const timeoutMs =
Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout; Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout;
if (args.sync) {
try { try {
const result = await executePrompt(client, prompt, { const result = await executePrompt(client, prompt, {
timeoutMs, timeoutMs,
@@ -207,6 +240,29 @@ export async function main(
} }
return 1; return 1;
} }
} else {
try {
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) {
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 message = `Unknown command: ${command}`; const message = `Unknown command: ${command}`;
+373 -23
View File
@@ -107,12 +107,12 @@ describe("main", () => {
} }
}); });
it("run executes client with prompt and prints JSON result", async () => { it("run executes client with prompt and prints JSON result with --sync", async () => {
const out = captureOutput(); const out = captureOutput();
try { try {
const executed: { client: ClientName; prompt: string }[] = []; const executed: { client: ClientName; prompt: string }[] = [];
const code = await main( const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async (client, prompt) => { executePrompt: async (client, prompt) => {
@@ -132,7 +132,7 @@ describe("main", () => {
} }
}); });
it("run prints text output with --text", async () => { it("run prints text output with --text and --sync", async () => {
const out = captureOutput(); const out = captureOutput();
const stdoutChunks: string[] = []; const stdoutChunks: string[] = [];
const stderrChunks: string[] = []; const stderrChunks: string[] = [];
@@ -147,6 +147,7 @@ describe("main", () => {
"--prompt", "--prompt",
"hello", "hello",
"--text", "--text",
"--sync",
], ],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
@@ -230,11 +231,11 @@ describe("main", () => {
} }
}); });
it("run returns 1 and prints error JSON when client not found", async () => { it("run returns 1 and prints error JSON when client not found with --sync", async () => {
const out = captureOutput(); const out = captureOutput();
try { try {
const code = await main( const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async () => { executePrompt: async () => {
@@ -250,12 +251,12 @@ describe("main", () => {
} }
}); });
it("dispatch routes positional prompt and prints JSON result", async () => { it("dispatch routes positional prompt and prints JSON result with --sync", async () => {
const out = captureOutput(); const out = captureOutput();
try { try {
const executed: { client: ClientName; prompt: string }[] = []; const executed: { client: ClientName; prompt: string }[] = [];
const code = await main( const code = await main(
["node", "cli.ts", "dispatch", "use claude to write tests"], ["node", "cli.ts", "dispatch", "use claude to write tests", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async (client, prompt) => { executePrompt: async (client, prompt) => {
@@ -277,12 +278,12 @@ describe("main", () => {
} }
}); });
it("dispatch routes --prompt flag and prints JSON result", async () => { it("dispatch routes --prompt flag and prints JSON result with --sync", async () => {
const out = captureOutput(); const out = captureOutput();
try { try {
const executed: { client: ClientName; prompt: string }[] = []; const executed: { client: ClientName; prompt: string }[] = [];
const code = await main( const code = await main(
["node", "cli.ts", "dispatch", "--prompt", "use codex to refactor"], ["node", "cli.ts", "dispatch", "--prompt", "use codex to refactor", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async (client, prompt) => { executePrompt: async (client, prompt) => {
@@ -301,12 +302,12 @@ describe("main", () => {
} }
}); });
it("dispatch respects --client override", async () => { it("dispatch respects --client override with --sync", async () => {
const out = captureOutput(); const out = captureOutput();
try { try {
const executed: { client: ClientName; prompt: string }[] = []; const executed: { client: ClientName; prompt: string }[] = [];
const code = await main( const code = await main(
["node", "cli.ts", "dispatch", "do something", "--client", "opencode"], ["node", "cli.ts", "dispatch", "do something", "--client", "opencode", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async (client, prompt) => { executePrompt: async (client, prompt) => {
@@ -344,11 +345,11 @@ describe("main", () => {
} }
}); });
it("dispatch returns 1 when resolved client is not found", async () => { it("dispatch returns 1 when resolved client is not found with --sync", async () => {
const out = captureOutput(); const out = captureOutput();
try { try {
const code = await main( const code = await main(
["node", "cli.ts", "dispatch", "do something"], ["node", "cli.ts", "dispatch", "do something", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async () => { executePrompt: async () => {
@@ -380,12 +381,12 @@ describe("main", () => {
} }
}); });
it("dispatch prints text output with --text", async () => { it("dispatch prints text output with --text and --sync", async () => {
const out = captureOutput(); const out = captureOutput();
const stdoutChunks: string[] = []; const stdoutChunks: string[] = [];
try { try {
const code = await main( const code = await main(
["node", "cli.ts", "dispatch", "do something", "--text"], ["node", "cli.ts", "dispatch", "do something", "--text", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async () => ({ executePrompt: async () => ({
@@ -407,12 +408,12 @@ describe("main", () => {
} }
}); });
it("run prints debug diagnostic JSON to stderr with --debug", async () => { it("run prints debug diagnostic JSON to stderr with --debug and --sync", async () => {
const out = captureOutput(); const out = captureOutput();
const stderrChunks: string[] = []; const stderrChunks: string[] = [];
try { try {
const code = await main( const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug"], ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => { executePrompt: async (_client, _prompt, options?) => {
@@ -452,12 +453,12 @@ describe("main", () => {
} }
}); });
it("run ignores non-numeric --timeout and falls back to config timeout", async () => { it("run ignores non-numeric --timeout and falls back to config timeout with --sync", async () => {
const out = captureOutput(); const out = captureOutput();
try { try {
let receivedTimeout: number | undefined; let receivedTimeout: number | undefined;
const code = await main( const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "not-a-number"], ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "not-a-number", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => { executePrompt: async (_client, _prompt, options?) => {
@@ -474,12 +475,12 @@ describe("main", () => {
} }
}); });
it("dispatch ignores non-numeric --timeout and falls back to config timeout", async () => { it("dispatch ignores non-numeric --timeout and falls back to config timeout with --sync", async () => {
const out = captureOutput(); const out = captureOutput();
try { try {
let receivedTimeout: number | undefined; let receivedTimeout: number | undefined;
const code = await main( const code = await main(
["node", "cli.ts", "dispatch", "do something", "--timeout", "bad"], ["node", "cli.ts", "dispatch", "do something", "--timeout", "bad", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => { executePrompt: async (_client, _prompt, options?) => {
@@ -497,12 +498,12 @@ describe("main", () => {
} }
}); });
it("dispatch prints debug diagnostic JSON to stderr with --debug", async () => { it("dispatch prints debug diagnostic JSON to stderr with --debug and --sync", async () => {
const out = captureOutput(); const out = captureOutput();
const stderrChunks: string[] = []; const stderrChunks: string[] = [];
try { try {
const code = await main( const code = await main(
["node", "cli.ts", "dispatch", "do something", "--debug"], ["node", "cli.ts", "dispatch", "do something", "--debug", "--sync"],
{ {
detectClients: () => mockClients, detectClients: () => mockClients,
executePrompt: async (_client, _prompt, options?) => { executePrompt: async (_client, _prompt, options?) => {
@@ -538,4 +539,353 @@ describe("main", () => {
out.restore(); out.restore();
} }
}); });
// Async default tests (S-301)
it("run async default starts job and returns job ID JSON immediately", async () => {
const out = captureOutput();
try {
const started: { client: ClientName; prompt: string; options?: any }[] = [];
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
startJob: async (client, prompt, options) => {
started.push({ client, prompt, options });
return { id: "job-123", client, prompt, status: "running", startedAt: new Date().toISOString() };
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(started.length, 1);
assert.strictEqual(started[0].client, "codex");
assert.strictEqual(started[0].prompt, "hello");
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.jobId, "job-123");
assert.strictEqual(parsed.client, "codex");
assert.strictEqual(parsed.status, "running");
} finally {
out.restore();
}
});
it("dispatch async default starts job and returns job ID JSON immediately", async () => {
const out = captureOutput();
try {
const started: { client: ClientName; prompt: string; options?: any }[] = [];
const code = await main(
["node", "cli.ts", "dispatch", "use claude to write tests"],
{
detectClients: () => mockClients,
startJob: async (client, prompt, options) => {
started.push({ client, prompt, options });
return { id: "job-456", client, prompt, status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "claude",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(started.length, 1);
assert.strictEqual(started[0].client, "claude");
assert.strictEqual(started[0].prompt, "use claude to write tests");
const parsed = JSON.parse(out.logs[0]);
assert.strictEqual(parsed.jobId, "job-456");
assert.strictEqual(parsed.client, "claude");
assert.strictEqual(parsed.status, "running");
} finally {
out.restore();
}
});
it("run async passes --timeout to startJob", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "30000"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-789", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() };
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 30000);
} finally {
out.restore();
}
});
it("run async 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,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-t1", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() };
},
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 600_000);
} finally {
out.restore();
}
});
it("dispatch async passes --timeout to startJob", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--timeout", "45000"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-abc", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 45000);
} finally {
out.restore();
}
});
it("dispatch async 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,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-t2", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 600_000);
} finally {
out.restore();
}
});
it("run async passes --debug to startJob", async () => {
const out = captureOutput();
const stderrChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug"],
{
detectClients: () => mockClients,
startJob: 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 { id: "job-def", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() };
},
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);
} finally {
out.restore();
}
});
it("run async returns 1 and prints error JSON when startJob throws", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
startJob: async () => {
throw new Error("spawn ENOENT");
},
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("ENOENT"));
} finally {
out.restore();
}
});
it("dispatch async returns 1 and prints error JSON when startJob throws", async () => {
const out = captureOutput();
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something"],
{
detectClients: () => mockClients,
startJob: async () => {
throw new Error("spawn ENOENT");
},
resolveClient: () => "claude",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 1);
const parsed = JSON.parse(out.errors[0]);
assert.ok(parsed.error.includes("ENOENT"));
} finally {
out.restore();
}
});
it("run async uses config timeout when --timeout is not provided", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-nt1", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() };
},
resolveConfig: () => ({ paths: {}, timeout: 300_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 300_000);
} finally {
out.restore();
}
});
it("dispatch async uses config timeout when --timeout is not provided", async () => {
const out = captureOutput();
try {
let receivedTimeout: number | undefined;
const code = await main(
["node", "cli.ts", "dispatch", "do something"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedTimeout = options?.timeoutMs;
return { id: "job-nt2", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 300_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedTimeout, 300_000);
} finally {
out.restore();
}
});
it("run async without --debug does not pass onDebug to startJob", async () => {
const out = captureOutput();
let receivedOptions: any;
try {
const code = await main(
["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedOptions = options;
return { id: "job-nd1", client: "codex", prompt: "hello", status: "running", startedAt: new Date().toISOString() };
},
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedOptions.onDebug, undefined);
} finally {
out.restore();
}
});
it("dispatch async without --debug does not pass onDebug to startJob", async () => {
const out = captureOutput();
let receivedOptions: any;
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something"],
{
detectClients: () => mockClients,
startJob: async (_client, _prompt, options) => {
receivedOptions = options;
return { id: "job-nd2", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() };
},
resolveClient: () => "codex",
resolveConfig: () => ({ paths: {}, timeout: 600_000 }),
}
);
assert.strictEqual(code, 0);
assert.strictEqual(receivedOptions.onDebug, undefined);
} finally {
out.restore();
}
});
it("dispatch async passes --debug to startJob", async () => {
const out = captureOutput();
const stderrChunks: string[] = [];
try {
const code = await main(
["node", "cli.ts", "dispatch", "do something", "--debug"],
{
detectClients: () => mockClients,
startJob: 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 { id: "job-ghi", client: "codex", prompt: "do something", status: "running", startedAt: new Date().toISOString() };
},
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();
}
});
}); });