diff --git a/packages/media-understanding-common/src/output-extract.test.ts b/packages/media-understanding-common/src/output-extract.test.ts new file mode 100644 index 00000000000..336055d8779 --- /dev/null +++ b/packages/media-understanding-common/src/output-extract.test.ts @@ -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", + ); + }); +}); diff --git a/packages/media-understanding-common/src/output-extract.ts b/packages/media-understanding-common/src/output-extract.ts index 9b7cffea0e1..53cecd82e7a 100644 --- a/packages/media-understanding-common/src/output-extract.ts +++ b/packages/media-understanding-common/src/output-extract.ts @@ -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. */