Skip to content

Commit 793b604

Browse files
authored
fix(media-understanding): parse nested Gemini output JSON (#96432)
1 parent 31e941c commit 793b604

2 files changed

Lines changed: 211 additions & 8 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
// Media Understanding Common tests cover provider output extraction behavior.
2+
import { describe, expect, it } from "vitest";
3+
import { extractGeminiResponse } from "./output-extract.js";
4+
5+
describe("extractGeminiResponse", () => {
6+
it("extracts the response from noisy output with nested JSON objects", () => {
7+
expect(
8+
extractGeminiResponse(
9+
[
10+
"debug: invoking gemini",
11+
JSON.stringify({
12+
response: "a useful description",
13+
usage: {
14+
inputTokens: 12,
15+
outputTokens: 4,
16+
},
17+
}),
18+
].join("\n"),
19+
),
20+
).toBe("a useful description");
21+
});
22+
23+
it("returns null for an incomplete JSON object", () => {
24+
expect(extractGeminiResponse("{")).toBeNull();
25+
});
26+
27+
it("ignores unmatched quotes in noisy output before the JSON object", () => {
28+
expect(extractGeminiResponse('debug: model said "hello\n{"response":"ok"}')).toBe("ok");
29+
});
30+
31+
it("ignores braces inside quoted noisy output", () => {
32+
expect(extractGeminiResponse('debug: "hello { world" {"response":"ok"}')).toBe("ok");
33+
});
34+
35+
it("ignores shell-quoted JSON-like noisy output", () => {
36+
expect(extractGeminiResponse('debug: \'{"response":"fake"}\'')).toBeNull();
37+
});
38+
39+
it("does not treat apostrophes inside noisy words as quote delimiters", () => {
40+
expect(extractGeminiResponse('debug: it\'s done {"response":"ok"}')).toBe("ok");
41+
});
42+
43+
it("resynchronizes after an unmatched brace in noisy output", () => {
44+
expect(extractGeminiResponse('debug: generated {\n{"response":"ok"}')).toBe("ok");
45+
});
46+
47+
it("preserves brace-heavy response text", () => {
48+
const response = "{".repeat(33);
49+
expect(extractGeminiResponse(JSON.stringify({ response }))).toBe(response);
50+
});
51+
52+
it("extracts pretty-printed JSON output", () => {
53+
expect(
54+
extractGeminiResponse(
55+
JSON.stringify(
56+
{
57+
response: "pretty response",
58+
usage: { inputTokens: 12 },
59+
},
60+
null,
61+
2,
62+
),
63+
),
64+
).toBe("pretty response");
65+
});
66+
67+
it("preserves pretty-printed object elements inside arrays", () => {
68+
expect(
69+
extractGeminiResponse(
70+
JSON.stringify(
71+
{
72+
response: "array response",
73+
items: [{ id: 1 }, { id: 2 }],
74+
},
75+
null,
76+
2,
77+
),
78+
),
79+
).toBe("array response");
80+
});
81+
82+
it("does not accept an inner response from a malformed trailing object", () => {
83+
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"} broken}')).toBe(
84+
"good",
85+
);
86+
expect(extractGeminiResponse('{"response":"good"} {"meta":{"response":"bad"}')).toBe("good");
87+
});
88+
89+
it("ignores a nested response inside an unfinished outer object", () => {
90+
expect(extractGeminiResponse('noise {"meta":{"response":"bad"}')).toBeNull();
91+
});
92+
93+
it("does not promote a child from a malformed outer object", () => {
94+
expect(extractGeminiResponse('{"response":"good"} {"meta" {"response":"bad"}}')).toBe("good");
95+
expect(extractGeminiResponse('noise {broken {"response":"bad"}}')).toBeNull();
96+
expect(extractGeminiResponse('{"response":"good"}\nnoise {broken\n{"response":"bad"}}')).toBe(
97+
"good",
98+
);
99+
});
100+
});

packages/media-understanding-common/src/output-extract.ts

Lines changed: 111 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,119 @@
33
/** Parse the last JSON object in a noisy provider output string. */
44
function extractLastJsonObject(raw: string): unknown {
55
const trimmed = raw.trim();
6-
const start = trimmed.lastIndexOf("{");
7-
if (start === -1) {
8-
return null;
6+
const ranges: Array<{ end: number; start: number }> = [];
7+
const starts: number[] = [];
8+
let inString = false;
9+
let escaped = false;
10+
let preambleQuote: string | undefined;
11+
let preambleEscaped = false;
12+
let previousSignificant: string | undefined;
13+
let lineHasNonWhitespace = false;
14+
let arrayDepth = 0;
15+
let candidateHasContent = false;
16+
17+
for (let index = 0; index < trimmed.length; index += 1) {
18+
const character = trimmed[index];
19+
if (inString) {
20+
if (character === "\n" || character === "\r") {
21+
starts.length = 0;
22+
inString = false;
23+
escaped = false;
24+
} else if (escaped) {
25+
escaped = false;
26+
} else if (character === "\\") {
27+
escaped = true;
28+
} else if (character === '"') {
29+
inString = false;
30+
}
31+
continue;
32+
}
33+
34+
if (starts.length === 0) {
35+
if (preambleQuote !== undefined) {
36+
if (character === "\n" || character === "\r") {
37+
preambleQuote = undefined;
38+
preambleEscaped = false;
39+
} else if (preambleEscaped) {
40+
preambleEscaped = false;
41+
} else if (character === "\\") {
42+
preambleEscaped = true;
43+
} else if (character === preambleQuote) {
44+
preambleQuote = undefined;
45+
}
46+
continue;
47+
}
48+
if (character === '"' || character === "'" || character === "`") {
49+
const previous = trimmed[index - 1];
50+
if (previous === undefined || /[\s:([{]/.test(previous)) {
51+
preambleQuote = character;
52+
preambleEscaped = false;
53+
continue;
54+
}
55+
}
56+
if (character === "{") {
57+
arrayDepth = 0;
58+
candidateHasContent = false;
59+
starts.push(index);
60+
}
61+
if (!/\s/.test(character)) {
62+
previousSignificant = character;
63+
lineHasNonWhitespace = true;
64+
} else if (character === "\n" || character === "\r") {
65+
lineHasNonWhitespace = false;
66+
}
67+
continue;
68+
}
69+
70+
const hadCandidateContent = candidateHasContent;
71+
if (character === '"') {
72+
inString = true;
73+
} else if (character === "{") {
74+
if (
75+
previousSignificant === ":" ||
76+
previousSignificant === "[" ||
77+
previousSignificant === '"' ||
78+
(previousSignificant === "," && (lineHasNonWhitespace || arrayDepth > 0))
79+
) {
80+
starts.push(index);
81+
} else if (!lineHasNonWhitespace && !hadCandidateContent) {
82+
// Only resync at a clean record boundary; otherwise keep malformed
83+
// outer objects from promoting diagnostic payloads as valid results.
84+
starts.length = 1;
85+
starts[0] = index;
86+
arrayDepth = 0;
87+
candidateHasContent = false;
88+
}
89+
} else if (character === "}" && starts.length > 0) {
90+
const start = starts.pop();
91+
if (start !== undefined && starts.length === 0) {
92+
ranges.push({ start, end: index });
93+
}
94+
} else if (character === "[") {
95+
arrayDepth += 1;
96+
} else if (character === "]" && arrayDepth > 0) {
97+
arrayDepth -= 1;
98+
}
99+
100+
if (!/\s/.test(character)) {
101+
candidateHasContent = true;
102+
previousSignificant = character;
103+
lineHasNonWhitespace = true;
104+
} else if (character === "\n" || character === "\r") {
105+
lineHasNonWhitespace = false;
106+
}
9107
}
10-
const slice = trimmed.slice(start);
11-
try {
12-
return JSON.parse(slice);
13-
} catch {
14-
return null;
108+
109+
for (let index = ranges.length - 1; index >= 0; index -= 1) {
110+
const range = ranges[index];
111+
try {
112+
return JSON.parse(trimmed.slice(range.start, range.end + 1));
113+
} catch {
114+
// Ignore malformed objects and try the previous completed range.
115+
}
15116
}
117+
118+
return null;
16119
}
17120

18121
/** Extract Gemini CLI-style response text from the last JSON object in output. */

0 commit comments

Comments
 (0)