diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts index 5be1ef3..328e714 100644 --- a/tools/ai-cli-dispatch/src/cli.ts +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -1,6 +1,7 @@ import minimist from "minimist"; import { detectClients as realDetectClients } from "./detect.js"; import { executePrompt as realExecutePrompt } from "./execute.js"; +import { startJob as realStartJob } from "./jobs.js"; import { resolveClient as realResolveClient } from "./dispatch.js"; import { resolveConfig as realResolveConfig } from "./config.js"; import { CLIENT_NAMES } from "./constants.js"; @@ -9,6 +10,7 @@ import { type ClientInfo, type ExecResult, type DebugInfo, + type Job, ClientNotFoundError, } from "./types.js"; @@ -19,6 +21,11 @@ export interface CliDeps { prompt: string, options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void } ) => Promise; + startJob?: ( + client: ClientName, + prompt: string, + options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void } + ) => Promise; resolveClient?: ( prompt: string, config?: { client?: ClientName; defaultClient?: ClientName } @@ -46,6 +53,7 @@ export async function main( ): Promise { 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)); @@ -57,7 +65,7 @@ export async function main( const args = minimist(parseArgs, { string: ["client", "prompt", "timeout"], - boolean: ["json", "text", "help", "debug"], + boolean: ["json", "text", "help", "debug", "sync"], alias: { h: "help" }, }); @@ -123,27 +131,51 @@ 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); + if (args.sync) { + 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; } - 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); + } 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; } - return 1; } } @@ -185,27 +217,51 @@ 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); + if (args.sync) { + 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; } - 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); + } 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; } - return 1; } } diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts index 5d0d846..4c9ec39 100644 --- a/tools/ai-cli-dispatch/tests/cli.test.ts +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -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(); try { const executed: { client: ClientName; prompt: string }[] = []; const code = await main( - ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--sync"], { detectClients: () => mockClients, 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 stdoutChunks: string[] = []; const stderrChunks: string[] = []; @@ -147,6 +147,7 @@ describe("main", () => { "--prompt", "hello", "--text", + "--sync", ], { 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(); try { const code = await main( - ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello"], + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--sync"], { detectClients: () => mockClients, 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(); try { const executed: { client: ClientName; prompt: string }[] = []; 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, 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(); try { const executed: { client: ClientName; prompt: string }[] = []; 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, 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(); try { const executed: { client: ClientName; prompt: string }[] = []; const code = await main( - ["node", "cli.ts", "dispatch", "do something", "--client", "opencode"], + ["node", "cli.ts", "dispatch", "do something", "--client", "opencode", "--sync"], { detectClients: () => mockClients, 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(); try { const code = await main( - ["node", "cli.ts", "dispatch", "do something"], + ["node", "cli.ts", "dispatch", "do something", "--sync"], { detectClients: () => mockClients, 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 stdoutChunks: string[] = []; try { const code = await main( - ["node", "cli.ts", "dispatch", "do something", "--text"], + ["node", "cli.ts", "dispatch", "do something", "--text", "--sync"], { detectClients: () => mockClients, 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 stderrChunks: string[] = []; try { 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, 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(); try { let receivedTimeout: number | undefined; 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, 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(); try { let receivedTimeout: number | undefined; const code = await main( - ["node", "cli.ts", "dispatch", "do something", "--timeout", "bad"], + ["node", "cli.ts", "dispatch", "do something", "--timeout", "bad", "--sync"], { detectClients: () => mockClients, 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 stderrChunks: string[] = []; try { const code = await main( - ["node", "cli.ts", "dispatch", "do something", "--debug"], + ["node", "cli.ts", "dispatch", "do something", "--debug", "--sync"], { detectClients: () => mockClients, executePrompt: async (_client, _prompt, options?) => { @@ -538,4 +539,353 @@ describe("main", () => { 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(); + } + }); });