diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts index 32ec4cd..3e5ab9a 100644 --- a/tools/ai-cli-dispatch/src/cli.ts +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -21,7 +21,7 @@ export interface CliDeps { prompt: string, config?: { client?: ClientName; defaultClient?: ClientName } ) => ClientName | null; - resolveConfig?: () => { paths: Partial>; defaultClient?: ClientName }; + resolveConfig?: () => { paths: Partial>; defaultClient?: ClientName; timeout?: number }; stdoutWrite?: (chunk: string) => void; stderrWrite?: (chunk: string) => void; } @@ -54,7 +54,7 @@ export async function main( rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs; const args = minimist(parseArgs, { - string: ["client", "prompt"], + string: ["client", "prompt", "timeout"], boolean: ["json", "text", "help", "debug"], alias: { h: "help" }, }); @@ -114,8 +114,12 @@ export async function main( return 1; } + const config = resolveConfig(); + const timeoutMs = + typeof args.timeout === "string" ? Number(args.timeout) : config.timeout; + try { - const result = await executePrompt(client, prompt); + const result = await executePrompt(client, prompt, { timeoutMs }); if (jsonMode) { console.log(JSON.stringify(result, null, 2)); } else { @@ -167,8 +171,11 @@ export async function main( return 1; } + const timeoutMs = + typeof args.timeout === "string" ? Number(args.timeout) : config.timeout; + try { - const result = await executePrompt(client, prompt); + const result = await executePrompt(client, prompt, { timeoutMs }); if (jsonMode) { console.log(JSON.stringify(result, null, 2)); } else { diff --git a/tools/ai-cli-dispatch/src/config.ts b/tools/ai-cli-dispatch/src/config.ts index 6fd1c15..8c0365c 100644 --- a/tools/ai-cli-dispatch/src/config.ts +++ b/tools/ai-cli-dispatch/src/config.ts @@ -10,6 +10,7 @@ import { CLIENT_NAMES, isWindows } from "./constants.js"; export interface ResolvedConfig { paths: Partial>; defaultClient?: ClientName; + timeout?: number; } export interface ResolveConfigOptions { @@ -89,5 +90,22 @@ export function resolveConfig( ) { 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; } diff --git a/tools/ai-cli-dispatch/src/execute.ts b/tools/ai-cli-dispatch/src/execute.ts index 010fcf8..e07fcb7 100644 --- a/tools/ai-cli-dispatch/src/execute.ts +++ b/tools/ai-cli-dispatch/src/execute.ts @@ -28,12 +28,14 @@ export async function executePrompt( stdout: "", stderr: "", exitCode: -1, + client, + durationMs: 0, }); } const spawnImpl = options.spawn ?? defaultSpawn; const existsSyncImpl = options.existsSync ?? defaultExistsSync; - const timeoutMs = options.timeoutMs ?? 300_000; + const timeoutMs = options.timeoutMs ?? 600_000; const command = options.clientPath ?? client; if (options.clientPath && !existsSyncImpl(options.clientPath)) { @@ -46,6 +48,8 @@ export async function executePrompt( stdout: "", stderr: "", exitCode: -1, + client, + durationMs: 0, }); } const args = argBuilder(prompt); @@ -55,6 +59,7 @@ export async function executePrompt( let timedOut = false; let stdout = ""; let stderr = ""; + const startMs = Date.now(); const child = spawnImpl(command, args, { shell: false, @@ -80,8 +85,16 @@ export async function executePrompt( if (settled) return; settled = true; clearTimeout(timeout); - if (err) reject(err); - else resolve(result!); + const durationMs = Date.now() - startMs; + 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) => { @@ -89,7 +102,7 @@ export async function executePrompt( settle(new ClientNotFoundError(client)); } else { settle( - new ExecError(err.message, { stdout, stderr, exitCode: -1 }) + new ExecError(err.message, { stdout, stderr, exitCode: -1, client, durationMs: 0 }) ); } }); @@ -101,10 +114,12 @@ export async function executePrompt( stdout, stderr, exitCode: -1, + client, + durationMs: 0, }) ); } else { - settle(undefined, { stdout, stderr, exitCode: code ?? -1 }); + settle(undefined, { stdout, stderr, exitCode: code ?? -1, client, durationMs: 0 }); } }); }); diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts index ec5c80d..4d2bc24 100644 --- a/tools/ai-cli-dispatch/tests/cli.test.ts +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -39,6 +39,8 @@ describe("main", () => { stdout: "output", stderr: "", exitCode: 0, + client: "codex", + durationMs: 42, }; it("returns 0 for --help and prints usage", async () => { @@ -152,6 +154,8 @@ describe("main", () => { stdout: "hello-out", stderr: "hello-err", exitCode: 0, + client: "codex", + durationMs: 42, }), stdoutWrite: (chunk) => stdoutChunks.push(chunk), stderrWrite: (chunk) => stderrChunks.push(chunk), @@ -259,7 +263,7 @@ describe("main", () => { return mockResult; }, resolveClient: () => "claude", - resolveConfig: () => ({ paths: {} }), + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), } ); assert.strictEqual(code, 0); @@ -286,7 +290,7 @@ describe("main", () => { return mockResult; }, resolveClient: () => "codex", - resolveConfig: () => ({ paths: {} }), + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), } ); assert.strictEqual(code, 0); @@ -310,7 +314,7 @@ describe("main", () => { return mockResult; }, resolveClient: (_p, cfg) => cfg?.client ?? null, - resolveConfig: () => ({ paths: {}, defaultClient: "claude" }), + resolveConfig: () => ({ paths: {}, defaultClient: "claude", timeout: 600_000 }), } ); assert.strictEqual(code, 0); @@ -329,7 +333,7 @@ describe("main", () => { detectClients: () => mockClients, executePrompt: async () => mockResult, resolveClient: () => null, - resolveConfig: () => ({ paths: {} }), + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), } ); assert.strictEqual(code, 1); @@ -351,7 +355,7 @@ describe("main", () => { throw new ClientNotFoundError("claude"); }, resolveClient: () => "claude", - resolveConfig: () => ({ paths: {} }), + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), } ); assert.strictEqual(code, 1); @@ -388,9 +392,11 @@ describe("main", () => { stdout: "done", stderr: "", exitCode: 0, + client: "codex", + durationMs: 42, }), resolveClient: () => "codex", - resolveConfig: () => ({ paths: {} }), + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), stdoutWrite: (chunk) => stdoutChunks.push(chunk), } ); diff --git a/tools/ai-cli-dispatch/tests/config.test.ts b/tools/ai-cli-dispatch/tests/config.test.ts index 99d505c..1598dd9 100644 --- a/tools/ai-cli-dispatch/tests/config.test.ts +++ b/tools/ai-cli-dispatch/tests/config.test.ts @@ -135,4 +135,71 @@ describe("resolveConfig", () => { }); 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); + }); }); diff --git a/tools/ai-cli-dispatch/tests/execute.test.ts b/tools/ai-cli-dispatch/tests/execute.test.ts index f50225f..6bdeac6 100644 --- a/tools/ai-cli-dispatch/tests/execute.test.ts +++ b/tools/ai-cli-dispatch/tests/execute.test.ts @@ -203,4 +203,58 @@ describe("executePrompt", () => { err instanceof ExecError && err.message.includes("Unknown client") ); }); + + it("includes client and durationMs in result", async () => { + const scenarios = new Map([ + ["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([ + ["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([ + ["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; + } + }); });