From 05ba1335d93a2b525966f889cbf0080d5e57809c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 21 Apr 2026 06:21:10 +0100 Subject: [PATCH] fix: tolerate qa cli json startup logs --- .../src/suite-runtime-agent-process.test.ts | 90 +++++++++++++++++-- .../qa-lab/src/suite-runtime-agent-process.ts | 46 +++++++++- 2 files changed, 126 insertions(+), 10 deletions(-) diff --git a/extensions/qa-lab/src/suite-runtime-agent-process.test.ts b/extensions/qa-lab/src/suite-runtime-agent-process.test.ts index 2eec215ab13..344ea7940b0 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-process.test.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-process.test.ts @@ -29,16 +29,26 @@ import { waitForMemorySearchMatch, } from "./suite-runtime-agent-process.js"; +type MockEmitter = { + emit: (eventName: string | symbol, ...args: unknown[]) => boolean; + on: (eventName: string | symbol, listener: (...args: unknown[]) => void) => MockEmitter; + once: (eventName: string | symbol, listener: (...args: unknown[]) => void) => MockEmitter; +}; + +type MockChildProcess = MockEmitter & { + stdout: MockEmitter; + stderr: MockEmitter; + kill: ReturnType; +}; + +function createMockEmitter() { + return new EventEmitter() as unknown as MockEmitter; +} + function createSpawnedProcess() { - const child = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - kill: ReturnType; - once: (event: string, listener: (...args: unknown[]) => void) => unknown; - on: (event: string, listener: (...args: unknown[]) => void) => unknown; - }; - child.stdout = new EventEmitter(); - child.stderr = new EventEmitter(); + const child = createMockEmitter() as MockChildProcess; + child.stdout = createMockEmitter(); + child.stderr = createMockEmitter(); child.kill = vi.fn(); return child; } @@ -117,6 +127,68 @@ describe("qa suite runtime agent process helpers", () => { await expect(pending).resolves.toEqual({ ok: true }); }); + it("parses json qa cli output after colored startup logs", async () => { + const child = createSpawnedProcess(); + spawnMock.mockReturnValue(child); + + const pending = runQaCli( + { + repoRoot: "/repo", + gateway: { + tempRoot: "/tmp/runtime", + runtimeEnv: {}, + }, + primaryModel: "openai/gpt-5.4", + alternateModel: "openai/gpt-5.4-mini", + providerMode: "mock-openai", + } as never, + ["memory", "search", "--json"], + { json: true }, + ); + + await waitForSpawnCount(1); + child.stdout.emit( + "data", + Buffer.from( + '\u001b[35m[plugins]\u001b[39m \u001b[36mcodex installed bundled runtime deps\u001b[39m\n{"results":[{"text":"ORBIT-10"}]}\n', + ), + ); + child.emit("exit", 0); + + await expect(pending).resolves.toEqual({ results: [{ text: "ORBIT-10" }] }); + }); + + it("parses pretty json qa cli output after startup logs", async () => { + const child = createSpawnedProcess(); + spawnMock.mockReturnValue(child); + + const pending = runQaCli( + { + repoRoot: "/repo", + gateway: { + tempRoot: "/tmp/runtime", + runtimeEnv: {}, + }, + primaryModel: "openai/gpt-5.4", + alternateModel: "openai/gpt-5.4-mini", + providerMode: "mock-openai", + } as never, + ["memory", "search", "--json"], + { json: true }, + ); + + await waitForSpawnCount(1); + child.stdout.emit( + "data", + Buffer.from( + '[plugins] memory-core installed bundled runtime deps\n{\n "results": [\n {\n "text": "ORBIT-10"\n }\n ]\n}\n', + ), + ); + child.emit("exit", 0); + + await expect(pending).resolves.toEqual({ results: [{ text: "ORBIT-10" }] }); + }); + it("starts an agent run with transport-derived delivery metadata", async () => { const gatewayCall = vi.fn(async () => ({ runId: "run-1" })); const env = { diff --git a/extensions/qa-lab/src/suite-runtime-agent-process.ts b/extensions/qa-lab/src/suite-runtime-agent-process.ts index 4f513cc24f1..9081ac93deb 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-process.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-process.ts @@ -10,6 +10,50 @@ type QaMemorySearchResult = { results?: Array<{ snippet?: string; text?: string; path?: string }>; }; +const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\x1B\[[0-?]*[ -/]*[@-~]`, "g"); + +function stripAnsiCodes(text: string) { + return text.replace(ANSI_ESCAPE_PATTERN, ""); +} + +function parseQaCliJsonOutput(text: string) { + const cleaned = stripAnsiCodes(text).trim(); + if (!cleaned) { + return {}; + } + try { + return JSON.parse(cleaned) as unknown; + } catch { + // Some startup repair logs are emitted on stdout before command JSON. + const lines = cleaned.split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const candidate = lines[index].trim(); + if (!candidate.startsWith("{") && !candidate.startsWith("[")) { + continue; + } + try { + return JSON.parse(lines.slice(index).join("\n")) as unknown; + } catch { + // Keep looking for the actual payload start. + } + } + + // Keep a line-oriented fallback for compact payloads followed by diagnostics. + for (const line of lines.toReversed()) { + const candidate = line.trim(); + if (!candidate.startsWith("{") && !candidate.startsWith("[")) { + continue; + } + try { + return JSON.parse(candidate) as unknown; + } catch { + // Keep looking for the actual payload line. + } + } + throw new Error(`qa cli returned non-JSON stdout: ${cleaned.slice(0, 240)}`); + } +} + async function runQaCli( env: Pick< QaSuiteRuntimeEnv, @@ -55,7 +99,7 @@ async function runQaCli( if (!opts?.json) { return text; } - return text ? (JSON.parse(text) as unknown) : {}; + return parseQaCliJsonOutput(text); } async function startAgentRun(