From afac143cb33040df9fb9c205662ad6debffe603f Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Wed, 20 May 2026 13:37:21 -0500 Subject: [PATCH] fix: filter codex ReasoningSummary stderr noise on exit code 0 Codex writes informational ERROR messages about ReasoningSummaryDelta to stderr even on successful execution (exit code 0). The OpenClaw agent misinterprets this non-empty stderr as a failure. - Add filterStderrNoise() to strip known codex noise patterns from stderr when exit code is 0 - Preserve raw stderr in DebugInfo.rawStderr when --debug is active - Add 5 new tests covering noise filtering, preservation on failure, debug raw output, and non-codex client passthrough --- tools/ai-cli-dispatch/src/execute.ts | 32 ++++++++- tools/ai-cli-dispatch/src/types.ts | 2 + tools/ai-cli-dispatch/tests/execute.test.ts | 79 +++++++++++++++++++++ 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/tools/ai-cli-dispatch/src/execute.ts b/tools/ai-cli-dispatch/src/execute.ts index f66e057..626acf4 100644 --- a/tools/ai-cli-dispatch/src/execute.ts +++ b/tools/ai-cli-dispatch/src/execute.ts @@ -9,6 +9,28 @@ export const CLIENT_ARGS: Record string[]> = { opencode: (p) => ["run", "--dangerously-skip-permissions", p], }; +/** + * Known stderr noise patterns per client. + * When exit code is 0, lines matching these patterns are stripped from the + * returned stderr to prevent agents from misinterpreting informational + * diagnostics as errors. The raw (unfiltered) stderr is preserved in + * DebugInfo.rawStderr when --debug is active. + */ +const STDERR_NOISE_PATTERNS: Partial> = { + codex: [ + /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+ERROR\s+codex_core::util:\s+ReasoningSummary\w*\s+/, + ], +}; + +function filterStderrNoise(client: ClientName, stderr: string, exitCode: number): string { + if (exitCode !== 0) return stderr; + const patterns = STDERR_NOISE_PATTERNS[client]; + if (!patterns) return stderr; + const lines = stderr.split("\n"); + const filtered = lines.filter((line) => !patterns.some((p) => p.test(line))); + return filtered.join("\n").replace(/\n+$/, ""); +} + export async function executePrompt( client: ClientName, prompt: string, @@ -78,6 +100,8 @@ export async function executePrompt( settled = true; clearTimeout(timeout); const durationMs = Date.now() - startMs; + const rawStderr = stderr; + const cleanedStderr = filterStderrNoise(client, rawStderr, result?.exitCode ?? -1); if (options.debug || options.onDebug) { const effectiveExitCode = result?.exitCode ?? (err instanceof ExecError ? err.result.exitCode : null); const debugInfo: DebugInfo = { @@ -87,20 +111,22 @@ export async function executePrompt( exitCode: effectiveExitCode, exitSignal, durationMs, - stderrLength: stderr.length, + stderrLength: rawStderr.length, stdoutLength: stdout.length, - noisySuccess: effectiveExitCode === 0 && stderr.length > 0, + noisySuccess: effectiveExitCode === 0 && rawStderr.length > 0, + rawStderr: rawStderr !== cleanedStderr ? rawStderr : undefined, }; options.onDebug?.(debugInfo); } if (err) { if (err instanceof ExecError) { + err.result.stderr = cleanedStderr; err.result.client = client; err.result.durationMs = durationMs; } reject(err); } else { - resolve({ ...result!, client, durationMs }); + resolve({ ...result!, client, durationMs, stderr: cleanedStderr }); } } diff --git a/tools/ai-cli-dispatch/src/types.ts b/tools/ai-cli-dispatch/src/types.ts index fd0f2fe..348af67 100644 --- a/tools/ai-cli-dispatch/src/types.ts +++ b/tools/ai-cli-dispatch/src/types.ts @@ -28,6 +28,8 @@ export interface DebugInfo { stderrLength: number; stdoutLength: number; noisySuccess: boolean; + /** Unfiltered stderr before noise removal (only present when --debug). */ + rawStderr?: string; } export interface ExecuteOptions { diff --git a/tools/ai-cli-dispatch/tests/execute.test.ts b/tools/ai-cli-dispatch/tests/execute.test.ts index c2ea7df..92ffa6a 100644 --- a/tools/ai-cli-dispatch/tests/execute.test.ts +++ b/tools/ai-cli-dispatch/tests/execute.test.ts @@ -362,4 +362,83 @@ describe("executePrompt", () => { }); assert.strictEqual(debugInfos[0].noisySuccess, false); }); + + it("filters codex ReasoningSummary noise from stderr on exit code 0", async () => { + const noisyStderr = [ + '2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryPartAdded without active item', + '2026-05-20T18:33:03.281713Z ERROR codex_core::util: ReasoningSummaryDelta without active item', + '2026-05-20T18:33:03.348247Z ERROR codex_core::util: ReasoningSummaryDelta without active item', + ].join('\n'); + const scenarios = new Map([ + ["codex exec --yolo hello", { stdout: "Hello world!", stderr: noisyStderr, exitCode: 0 }], + ]); + const result = await executePrompt("codex", "hello", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + }); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stderr, ""); + assert.strictEqual(result.stdout, "Hello world!"); + }); + + it("preserves real error stderr from codex on non-zero exit code", async () => { + const noisyStderr = [ + '2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryDelta without active item', + 'Error: something actually went wrong', + ].join('\n'); + const scenarios = new Map([ + ["codex exec --yolo fail", { stdout: "", stderr: noisyStderr, exitCode: 1 }], + ]); + const result = await executePrompt("codex", "fail", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + }); + assert.strictEqual(result.exitCode, 1); + assert.ok(result.stderr.includes("ReasoningSummaryDelta")); + assert.ok(result.stderr.includes("something actually went wrong")); + }); + + it("provides rawStderr in debug info when noise is filtered", async () => { + const noisyStderr = '2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryDelta without active item\n'; + const scenarios = new Map([ + ["codex exec --yolo hello", { stdout: "ok", stderr: noisyStderr, 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(result.stderr, ""); + assert.strictEqual(debugInfos[0].rawStderr, noisyStderr); + }); + + it("does not set rawStderr when no noise filtering occurred", async () => { + const scenarios = new Map([ + ["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].rawStderr, undefined); + }); + + it("does not filter stderr for non-codex clients", async () => { + const noisyStderr = '2026-05-20T18:33:01.969310Z ERROR codex_core::util: ReasoningSummaryDelta without active item\n'; + const scenarios = new Map([ + ["claude -p hello --dangerously-skip-permissions", { stdout: "ok", stderr: noisyStderr, exitCode: 0 }], + ]); + const result = await executePrompt("claude", "hello", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + }); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(result.stderr, noisyStderr); + }); });