From 94389df6f1e44084f64a0c8dfe6d6402e777deec Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Tue, 19 May 2026 19:48:40 -0500 Subject: [PATCH] feat(S-103): Test-drive and implement --debug diagnostic mode --- tools/ai-cli-dispatch/src/cli.ts | 17 +++- tools/ai-cli-dispatch/src/execute.ts | 21 ++++- tools/ai-cli-dispatch/tests/cli.test.ts | 87 +++++++++++++++++++++ tools/ai-cli-dispatch/tests/execute.test.ts | 63 +++++++++++++++ 4 files changed, 183 insertions(+), 5 deletions(-) diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts index 3e5ab9a..f619b09 100644 --- a/tools/ai-cli-dispatch/src/cli.ts +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -8,6 +8,7 @@ import { type ClientName, type ClientInfo, type ExecResult, + type DebugInfo, ClientNotFoundError, } from "./types.js"; @@ -15,7 +16,8 @@ export interface CliDeps { detectClients?: () => ClientInfo[]; executePrompt?: ( client: ClientName, - prompt: string + prompt: string, + options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void } ) => Promise; resolveClient?: ( prompt: string, @@ -60,6 +62,7 @@ export async function main( }); const jsonMode = !args.text; + const debug = !!args.debug; if (args.help) { printHelp(); @@ -119,7 +122,11 @@ export async function main( typeof args.timeout === "string" ? Number(args.timeout) : config.timeout; try { - const result = await executePrompt(client, prompt, { timeoutMs }); + 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 { @@ -175,7 +182,11 @@ export async function main( typeof args.timeout === "string" ? Number(args.timeout) : config.timeout; try { - const result = await executePrompt(client, prompt, { timeoutMs }); + 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 { diff --git a/tools/ai-cli-dispatch/src/execute.ts b/tools/ai-cli-dispatch/src/execute.ts index e07fcb7..cf39ba3 100644 --- a/tools/ai-cli-dispatch/src/execute.ts +++ b/tools/ai-cli-dispatch/src/execute.ts @@ -2,7 +2,7 @@ import type { ChildProcess } from "node:child_process"; import { spawn as defaultSpawn } from "node:child_process"; import type { PathLike } 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"; const CLIENT_ARGS: Record string[]> = { @@ -14,6 +14,8 @@ const CLIENT_ARGS: Record string[]> = { export interface ExecuteOptions { clientPath?: string; timeoutMs?: number; + debug?: boolean; + onDebug?: (info: DebugInfo) => void; spawn?: (command: string, args: string[], options?: { shell?: boolean }) => ChildProcess; existsSync?: (path: PathLike) => boolean; } @@ -59,6 +61,7 @@ export async function executePrompt( let timedOut = false; let stdout = ""; let stderr = ""; + let exitSignal: NodeJS.Signals | null = null; const startMs = Date.now(); const child = spawnImpl(command, args, { @@ -86,6 +89,19 @@ export async function executePrompt( settled = true; clearTimeout(timeout); const durationMs = Date.now() - startMs; + if (options.debug || options.onDebug) { + const debugInfo: DebugInfo = { + command, + args, + pid: child.pid ?? undefined, + exitCode: result?.exitCode ?? (err instanceof ExecError ? err.result.exitCode : null), + exitSignal, + durationMs, + stderrLength: stderr.length, + stdoutLength: stdout.length, + }; + options.onDebug?.(debugInfo); + } if (err) { if (err instanceof ExecError) { err.result.client = client; @@ -107,7 +123,8 @@ export async function executePrompt( } }); - child.on("close", (code: number | null) => { + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { + exitSignal = signal; if (timedOut) { settle( new ExecError(`Execution timed out after ${timeoutMs}ms`, { diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts index 4d2bc24..03b412b 100644 --- a/tools/ai-cli-dispatch/tests/cli.test.ts +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -406,4 +406,91 @@ describe("main", () => { 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("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(); + } + }); }); diff --git a/tools/ai-cli-dispatch/tests/execute.test.ts b/tools/ai-cli-dispatch/tests/execute.test.ts index 6bdeac6..e8410e8 100644 --- a/tools/ai-cli-dispatch/tests/execute.test.ts +++ b/tools/ai-cli-dispatch/tests/execute.test.ts @@ -16,6 +16,7 @@ interface MockScenario { function createMockChildProcess(scenario: MockScenario): any { const child = new EventEmitter() as any; + child.pid = 12345; child.stdout = Readable.from( scenario.stdout !== undefined ? [scenario.stdout] : [] ); @@ -257,4 +258,66 @@ describe("executePrompt", () => { global.setTimeout = origSetTimeout; } }); + + it("emits debug info via onDebug when debug is true for successful execution", async () => { + const scenarios = new Map([ + ["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([ + ["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(); + 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); + }); });