fix(media-understanding): parse nested Gemini output JSON (#96432)

This commit is contained in:
linhongkuan 2026-06-25 15:50:39 +08:00 committed by GitHub
parent 31e941c3fc
commit 793b604b23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 211 additions and 8 deletions

View file

@ -0,0 +1,100 @@
// Media Understanding Common tests cover provider output extraction behavior.
import { describe, expect, it } from "vitest";
import { extractGeminiResponse } from "./output-extract.js";
describe("extractGeminiResponse", () => {
it("extracts the response from noisy output with nested JSON objects", () => {
expect(
extractGeminiResponse(
[
"debug: invoking gemini",
JSON.stringify({
response: "a useful description",
usage: {
inputTokens: 12,
outputTokens: 4,
},
}),
].join("\n"),
),
).toBe("a useful description");
});
it("returns null for an incomplete JSON object", () => {
expect(extractGeminiResponse("{")).toBeNull();
});
it("ignores unmatched quotes in noisy output before the JSON object", () => {
expect(extractGeminiResponse('debug: model said "hello\n{"response":"ok"}')).toBe("ok");
});
it("ignores braces inside quoted noisy output", () => {
expect(extractGeminiResponse('debug: "hello { world" {"response":"ok"}')).toBe("ok");
});
it("ignores shell-quoted JSON-like noisy output", () => {
expect(extractGeminiResponse('debug: \'{"response":"fake"}\'')).toBeNull();
});
it("does not treat apostrophes inside noisy words as quote delimiters", () => {
expect(extractGeminiResponse('debug: it\'s done {"response":"ok"}')).toBe("ok");
});
it("resynchronizes after an unmatched brace in noisy output", () => {
expect(extractGeminiResponse('debug: generated {\n{"response":"ok"}')).toBe("ok");
});
it("preserves brace-heavy response text", () => {
const response = "{".repeat(33);
expect(extractGeminiResponse(JSON.stringify({ response }))).toBe(response);
});
it("extracts pretty-printed JSON output", () => {
expect(
extractGeminiResponse(
JSON.stringify(
{
response: "pretty response",
usage: { inputTokens: 12 },
},
null,
2,
),
),
).toBe("pretty response");
});
it("preserves pretty-printed object elements inside arrays", () => {
expect(
extractGeminiResponse(
JSON.stringify(
{
response: "array response",
items: [{ id: 1 }, { id: 2 }],
},
null,
2,
),
),
).toBe("array response");
});
it("does not accept an inner response from a malformed trailing object", () => {
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"} broken}')).toBe(
"good",
);
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"}')).toBe("good");
});
it("ignores a nested response inside an unfinished outer object", () => {
expect(extractGeminiResponse('noise {"meta":{"response":"bad"}')).toBeNull();
});
it("does not promote a child from a malformed outer object", () => {
expect(extractGeminiResponse('{"response":"good"} {"meta" {"response":"bad"}}')).toBe("good");
expect(extractGeminiResponse('noise {broken {"response":"bad"}}')).toBeNull();
expect(extractGeminiResponse('{"response":"good"}\nnoise {broken\n{"response":"bad"}}')).toBe(
"good",
);
});
});

View file

@ -3,16 +3,119 @@
/** Parse the last JSON object in a noisy provider output string. */
function extractLastJsonObject(raw: string): unknown {
const trimmed = raw.trim();
const start = trimmed.lastIndexOf("{");
if (start === -1) {
return null;
const ranges: Array<{ end: number; start: number }> = [];
const starts: number[] = [];
let inString = false;
let escaped = false;
let preambleQuote: string | undefined;
let preambleEscaped = false;
let previousSignificant: string | undefined;
let lineHasNonWhitespace = false;
let arrayDepth = 0;
let candidateHasContent = false;
for (let index = 0; index < trimmed.length; index += 1) {
const character = trimmed[index];
if (inString) {
if (character === "\n" || character === "\r") {
starts.length = 0;
inString = false;
escaped = false;
} else if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === '"') {
inString = false;
}
continue;
}
if (starts.length === 0) {
if (preambleQuote !== undefined) {
if (character === "\n" || character === "\r") {
preambleQuote = undefined;
preambleEscaped = false;
} else if (preambleEscaped) {
preambleEscaped = false;
} else if (character === "\\") {
preambleEscaped = true;
} else if (character === preambleQuote) {
preambleQuote = undefined;
}
continue;
}
if (character === '"' || character === "'" || character === "`") {
const previous = trimmed[index - 1];
if (previous === undefined || /[\s:([{]/.test(previous)) {
preambleQuote = character;
preambleEscaped = false;
continue;
}
}
if (character === "{") {
arrayDepth = 0;
candidateHasContent = false;
starts.push(index);
}
if (!/\s/.test(character)) {
previousSignificant = character;
lineHasNonWhitespace = true;
} else if (character === "\n" || character === "\r") {
lineHasNonWhitespace = false;
}
continue;
}
const hadCandidateContent = candidateHasContent;
if (character === '"') {
inString = true;
} else if (character === "{") {
if (
previousSignificant === ":" ||
previousSignificant === "[" ||
previousSignificant === '"' ||
(previousSignificant === "," && (lineHasNonWhitespace || arrayDepth > 0))
) {
starts.push(index);
} else if (!lineHasNonWhitespace && !hadCandidateContent) {
// Only resync at a clean record boundary; otherwise keep malformed
// outer objects from promoting diagnostic payloads as valid results.
starts.length = 1;
starts[0] = index;
arrayDepth = 0;
candidateHasContent = false;
}
} else if (character === "}" && starts.length > 0) {
const start = starts.pop();
if (start !== undefined && starts.length === 0) {
ranges.push({ start, end: index });
}
} else if (character === "[") {
arrayDepth += 1;
} else if (character === "]" && arrayDepth > 0) {
arrayDepth -= 1;
}
if (!/\s/.test(character)) {
candidateHasContent = true;
previousSignificant = character;
lineHasNonWhitespace = true;
} else if (character === "\n" || character === "\r") {
lineHasNonWhitespace = false;
}
}
const slice = trimmed.slice(start);
try {
return JSON.parse(slice);
} catch {
return null;
for (let index = ranges.length - 1; index >= 0; index -= 1) {
const range = ranges[index];
try {
return JSON.parse(trimmed.slice(range.start, range.end + 1));
} catch {
// Ignore malformed objects and try the previous completed range.
}
}
return null;
}
/** Extract Gemini CLI-style response text from the last JSON object in output. */