fix: tolerate qa cli json startup logs

This commit is contained in:
Peter Steinberger
2026-04-21 06:21:10 +01:00
parent c92490881b
commit 05ba1335d9
2 changed files with 126 additions and 10 deletions

View File

@@ -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<typeof vi.fn>;
};
function createMockEmitter() {
return new EventEmitter() as unknown as MockEmitter;
}
function createSpawnedProcess() {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: ReturnType<typeof vi.fn>;
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 = {

View File

@@ -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(