From 2f7fbeb57fa6448fd9c385366332d75f0259b218 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Thu, 27 Nov 2025 17:33:48 +0100 Subject: [PATCH 1/6] fix(core): allow parsing more partial JSON Previous partial JSON parser did not handle all cases of partial JSON, causing incomplete tool calls to be erroneously marked as invalid. --- .../langchain-core/src/output_parsers/json.ts | 4 +- .../src/output_parsers/tests/json.test.ts | 4 +- libs/langchain-core/src/utils/json.ts | 320 +++++++++++++++--- .../tests/__mocks__/json.kitchen-sink.jsonl | 179 ++++++++++ .../src/utils/tests/json.test.ts | 282 +++++++++++++++ 5 files changed, 736 insertions(+), 53 deletions(-) create mode 100644 libs/langchain-core/src/utils/tests/__mocks__/json.kitchen-sink.jsonl create mode 100644 libs/langchain-core/src/utils/tests/json.test.ts diff --git a/libs/langchain-core/src/output_parsers/json.ts b/libs/langchain-core/src/output_parsers/json.ts index 8f9a10119fe3..c752c8f21be7 100644 --- a/libs/langchain-core/src/output_parsers/json.ts +++ b/libs/langchain-core/src/output_parsers/json.ts @@ -44,11 +44,11 @@ export class JsonOutputParser< async parsePartialResult( generations: ChatGeneration[] | Generation[] ): Promise { - return parseJsonMarkdown(generations[0].text); + return parseJsonMarkdown(generations[0].text) as T | undefined; } async parse(text: string): Promise { - return parseJsonMarkdown(text, JSON.parse); + return parseJsonMarkdown(text, JSON.parse) as T; } getFormatInstructions(): string { diff --git a/libs/langchain-core/src/output_parsers/tests/json.test.ts b/libs/langchain-core/src/output_parsers/tests/json.test.ts index 6deb94dec23b..7b940535b89c 100644 --- a/libs/langchain-core/src/output_parsers/tests/json.test.ts +++ b/libs/langchain-core/src/output_parsers/tests/json.test.ts @@ -267,12 +267,12 @@ const MARKDOWN_STREAM_TEST_CASES = [ { name: "Markdown with split code block", input: ['```json\n{"', 'countries": [{"n', 'ame": "China"}]}', "\n```"], - expected: [{ countries: [{ name: "China" }] }], + expected: [{}, { countries: [{}] }, { countries: [{ name: "China" }] }], }, { name: "Markdown without json identifier, split", input: ['```\n{"', 'key": "val', '"}\n```'], - expected: [{ key: "val" }], + expected: [{}, { key: "val" }], }, { name: "Ignores text after closing markdown backticks", diff --git a/libs/langchain-core/src/utils/json.ts b/libs/langchain-core/src/utils/json.ts index e67d5849db78..29d8343031a2 100644 --- a/libs/langchain-core/src/utils/json.ts +++ b/libs/langchain-core/src/utils/json.ts @@ -26,75 +26,297 @@ export function parseJsonMarkdown(s: string, parser = parsePartialJson) { return parser(finalContent.trim()); } -// Adapted from https://github.com/KillianLucas/open-interpreter/blob/main/interpreter/core/llm/utils/parse_partial_json.py -// MIT License -export function parsePartialJson(s: string) { - // If the input is undefined, return null to indicate failure. - if (typeof s === "undefined") { - return null; - } +/** + * Recursive descent partial JSON parser. + * @param s - The string to parse. + * @returns The parsed value. + */ +export function strictParsePartialJson( + s: string | undefined +): unknown | undefined { + if (typeof s === "undefined") return undefined; - // Attempt to parse the string as-is. try { return JSON.parse(s); } catch { - // Pass + // Continue to partial parsing + } + + const buffer = s.trim(); + if (buffer.length === 0) return undefined; + + let pos = 0; + + function skipWhitespace(): void { + while (pos < buffer.length && /\s/.test(buffer[pos])) { + pos += 1; + } } - // Initialize variables. - let new_s = ""; - const stack = []; - let isInsideString = false; - let escaped = false; - - // Process each character in the string one at a time. - for (let char of s) { - if (isInsideString) { - if (char === '"' && !escaped) { - isInsideString = false; - } else if (char === "\n" && !escaped) { - char = "\\n"; // Replace the newline character with the escape sequence. + function parseString(): string { + if (buffer[pos] !== '"') { + throw new Error(`Expected '"' at position ${pos}, got '${buffer[pos]}'`); + } + + pos += 1; + let result = ""; + let escaped = false; + + while (pos < buffer.length) { + const char = buffer[pos]; + + if (escaped) { + if (char === "n") { + result += "\n"; + } else if (char === "t") { + result += "\t"; + } else if (char === "r") { + result += "\r"; + } else if (char === "\\") { + result += "\\"; + } else if (char === '"') { + result += '"'; + } else if (char === "u") { + const hex = buffer.substring(pos + 1, pos + 5); + if (/^[0-9A-Fa-f]{0,4}$/.test(hex)) { + if (hex.length === 4) { + result += String.fromCharCode(Number.parseInt(hex, 16)); + } else { + result += `u${hex}`; + } + + pos += hex.length; + } else { + throw new Error( + `Invalid unicode escape sequence '\\u${hex}' at position ${pos}` + ); + } + } else { + throw new Error( + `Invalid escape sequence '\\${char}' at position ${pos}` + ); + } + escaped = false; } else if (char === "\\") { - escaped = !escaped; + escaped = true; + } else if (char === '"') { + pos += 1; + return result; } else { - escaped = false; + result += char; } - } else { - if (char === '"') { - isInsideString = true; - escaped = false; - } else if (char === "{") { - stack.push("}"); - } else if (char === "[") { - stack.push("]"); - } else if (char === "}" || char === "]") { - if (stack && stack[stack.length - 1] === char) { - stack.pop(); - } else { - // Mismatched closing character; the input is malformed. - return null; - } + + pos += 1; + } + + return result; + } + + function parseNumber(): number { + const start = pos; + let numStr = ""; + + if (buffer[pos] === "-") { + numStr += "-"; + pos += 1; + } + + if (pos < buffer.length && buffer[pos] === "0") { + numStr += "0"; + pos += 1; + + if (buffer[pos] >= "0" && buffer[pos] <= "9") { + throw new Error(`Invalid number at position ${start}`); + } + } + + if (pos < buffer.length && buffer[pos] >= "1" && buffer[pos] <= "9") { + while (pos < buffer.length && buffer[pos] >= "0" && buffer[pos] <= "9") { + numStr += buffer[pos]; + pos += 1; + } + } + + if (pos < buffer.length && buffer[pos] === ".") { + numStr += "."; + pos += 1; + while (pos < buffer.length && buffer[pos] >= "0" && buffer[pos] <= "9") { + numStr += buffer[pos]; + pos += 1; + } + } + + if (pos < buffer.length && (buffer[pos] === "e" || buffer[pos] === "E")) { + numStr += buffer[pos]; + pos += 1; + if (pos < buffer.length && (buffer[pos] === "+" || buffer[pos] === "-")) { + numStr += buffer[pos]; + pos += 1; + } + while (pos < buffer.length && buffer[pos] >= "0" && buffer[pos] <= "9") { + numStr += buffer[pos]; + pos += 1; + } + } + + if (numStr === "-") return -0; + + const num = Number.parseFloat(numStr); + + if (Number.isNaN(num)) { + pos = start; + throw new Error(`Invalid number '${numStr}' at position ${start}`); + } + + return num; + } + + function parseValue(): unknown { + skipWhitespace(); + + if (pos >= buffer.length) { + throw new Error(`Unexpected end of input at position ${pos}`); + } + + const char = buffer[pos]; + + if (char === "{") return parseObject(); + if (char === "[") return parseArray(); + if (char === '"') return parseString(); + + if ("null".startsWith(buffer.substring(pos, pos + 4))) { + pos += Math.min(4, buffer.length - pos); + return null; + } + + if ("true".startsWith(buffer.substring(pos, pos + 4))) { + pos += Math.min(4, buffer.length - pos); + return true; + } + + if ("false".startsWith(buffer.substring(pos, pos + 5))) { + pos += Math.min(5, buffer.length - pos); + return false; + } + + if (char === "-" || (char >= "0" && char <= "9")) { + return parseNumber(); + } + + throw new Error(`Unexpected character '${char}' at position ${pos}`); + } + + function parseArray(): unknown[] { + if (buffer[pos] !== "[") { + throw new Error(`Expected '[' at position ${pos}, got '${buffer[pos]}'`); + } + + const arr: unknown[] = []; + + pos += 1; + skipWhitespace(); + + if (pos >= buffer.length) return arr; + if (buffer[pos] === "]") { + pos += 1; + return arr; + } + + while (pos < buffer.length) { + skipWhitespace(); + if (pos >= buffer.length) return arr; + + arr.push(parseValue()); + + skipWhitespace(); + if (pos >= buffer.length) return arr; + + if (buffer[pos] === "]") { + pos += 1; + return arr; + } else if (buffer[pos] === ",") { + pos += 1; + continue; } + + throw new Error( + `Expected ',' or ']' at position ${pos}, got '${buffer[pos]}'` + ); } - // Append the processed character to the new string. - new_s += char; + return arr; } - // If we're still inside a string at the end of processing, - // we need to close the string. - if (isInsideString) { - new_s += '"'; + function parseObject(): Record { + if (buffer[pos] !== "{") { + throw new Error(`Expected '{' at position ${pos}, got '${buffer[pos]}'`); + } + + const obj: Record = {}; + pos += 1; + skipWhitespace(); + + if (pos >= buffer.length) return obj; + if (buffer[pos] === "}") { + pos += 1; + return obj; + } + + while (pos < buffer.length) { + skipWhitespace(); + if (pos >= buffer.length) return obj; + + const key = parseString(); + + skipWhitespace(); + if (pos >= buffer.length) return obj; + + if (buffer[pos] !== ":") { + throw new Error( + `Expected ':' at position ${pos}, got '${buffer[pos]}'` + ); + } + pos += 1; + + skipWhitespace(); + if (pos >= buffer.length) return obj; + + obj[key] = parseValue(); + + skipWhitespace(); + if (pos >= buffer.length) return obj; + + if (buffer[pos] === "}") { + pos += 1; + return obj; + } else if (buffer[pos] === ",") { + pos += 1; + continue; + } + + throw new Error( + `Expected ',' or '}' at position ${pos}, got '${buffer[pos]}'` + ); + } + + return obj; } - // Close any remaining open structures in the reverse order that they were opened. - for (let i = stack.length - 1; i >= 0; i -= 1) { - new_s += stack[i]; + const value = parseValue(); + skipWhitespace(); + + if (pos < buffer.length) { + throw new Error(`Unexpected character '${buffer[pos]}' at position ${pos}`); } + return value; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function parsePartialJson(s: string): any | null { // Attempt to parse the modified string as JSON. try { - return JSON.parse(new_s); + return strictParsePartialJson(s) ?? null; } catch { // If we still can't parse the string as JSON, return null to indicate failure. return null; diff --git a/libs/langchain-core/src/utils/tests/__mocks__/json.kitchen-sink.jsonl b/libs/langchain-core/src/utils/tests/__mocks__/json.kitchen-sink.jsonl new file mode 100644 index 000000000000..52363d74db07 --- /dev/null +++ b/libs/langchain-core/src/utils/tests/__mocks__/json.kitchen-sink.jsonl @@ -0,0 +1,179 @@ +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{} +{"array":[]} +{"array":[""]} +{"array":["h"]} +{"array":["he"]} +{"array":["hel"]} +{"array":["hell"]} +{"array":["hello"]} +{"array":["hello"]} +{"array":["hello"]} +{"array":["hello"]} +{"array":["hello",null]} +{"array":["hello",null]} +{"array":["hello",null]} +{"array":["hello",null]} +{"array":["hello",null]} +{"array":["hello",null]} +{"array":["hello",null,false]} +{"array":["hello",null,false]} +{"array":["hello",null,false]} +{"array":["hello",null,false]} +{"array":["hello",null,false]} +{"array":["hello",null,false]} +{"array":["hello",null,false]} +{"array":["hello",null,false,true]} +{"array":["hello",null,false,true]} +{"array":["hello",null,false,true]} +{"array":["hello",null,false,true]} +{"array":["hello",null,false,true]} +{"array":["hello",null,false,true]} +{"array":["hello",null,false,true,1]} +{"array":["hello",null,false,true,12]} +{"array":["hello",null,false,true,123]} +{"array":["hello",null,false,true,1234]} +{"array":["hello",null,false,true,12345]} +{"array":["hello",null,false,true,123456]} +{"array":["hello",null,false,true,1234567]} +{"array":["hello",null,false,true,12345678]} +{"array":["hello",null,false,true,123456789]} +{"array":["hello",null,false,true,1234567891]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910]} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{}} +{"array":["hello",null,false,true,12345678910],"object":{"string":""}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"s"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"st"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"str"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"stri"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"strin"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string"}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":1}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":123}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":1234}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":123456}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":1234567}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":123456789}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":1234567891}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910}} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":""} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"v"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"ve"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"ver"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very l"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very lo"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very lon"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very long"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very long"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very long s"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very long st"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very long str"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very long stri"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very long strin"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very long string"} +{"array":["hello",null,false,true,12345678910],"object":{"string":"string","null":null,"false":false,"true":true,"number":12345678910},"long":"very long string"} diff --git a/libs/langchain-core/src/utils/tests/json.test.ts b/libs/langchain-core/src/utils/tests/json.test.ts new file mode 100644 index 000000000000..91c9f1bda33f --- /dev/null +++ b/libs/langchain-core/src/utils/tests/json.test.ts @@ -0,0 +1,282 @@ +import { expect, it } from "vitest"; +import * as fs from "node:fs/promises"; +import { strictParsePartialJson as parsePartialJson } from "../json.js"; + +function* generateSegments(rawJson: string) { + for (let i = 1; i < rawJson.length; i += 1) { + yield [rawJson.substring(0, i), i] as const; + } +} + +const expectPartialJson = (item: string) => + expect(parsePartialJson(item), `[${item}]`); + +it("objects", () => { + expectPartialJson("{").toEqual({}); + expectPartialJson("{}").toEqual({}); +}); + +it("array", () => { + expectPartialJson("[]").toEqual([]); + + expectPartialJson("[1").toEqual([1]); + expectPartialJson("[t").toEqual([true]); + + expectPartialJson("[-").toEqual([-0]); + + expectPartialJson("[").toEqual([]); + expectPartialJson("[n").toEqual([null]); + expectPartialJson("[null").toEqual([null]); + expectPartialJson("[null,").toEqual([null]); + expectPartialJson("[null,t").toEqual([null, true]); + expectPartialJson("[null,{").toEqual([null, {}]); + expectPartialJson('[null,{"a":1').toEqual([null, { a: 1 }]); + + expect(() => expectPartialJson("[n,")).toThrow(); + expect(() => expectPartialJson("[null,}")).toThrow(); +}); + +it("strings", () => { + expectPartialJson('"').toBe(""); + expectPartialJson('"hello').toBe("hello"); + expectPartialJson('"hello"').toBe("hello"); + + expectPartialJson('"15\\n\t\r"').toBe("15\n\t\r"); + + expectPartialJson('"15\\u').toBe("15u"); + expectPartialJson('"15\\u00').toBe("15u00"); + expectPartialJson('"15\\u00f').toBe("15u00f"); + expectPartialJson('"15\\u00f8').toBe("15\u00f8"); + expectPartialJson('"15\\u00f8C').toBe("15\u00f8C"); +}); + +it("numbers", () => { + expectPartialJson("1").toBe(1); + expectPartialJson("12").toBe(12); + expectPartialJson("123").toBe(123); + + expectPartialJson("-").toBe(-0); + expectPartialJson("-1").toBe(-1); + expectPartialJson("-12").toBe(-12); + expectPartialJson("-12.").toBe(-12); + expectPartialJson("-12.1").toBe(-12.1); + + expectPartialJson("-1").toBe(-1); + expectPartialJson("-1e").toBe(-1); + + expectPartialJson("-1e1").toBe(-10); + expectPartialJson("-1e10").toBe(-1e10); + + expectPartialJson("-1e+").toBe(-1); + expectPartialJson("-1e+1").toBe(-1e1); + expectPartialJson("-1e+10").toBe(-1e10); + + expectPartialJson("-1e-").toBe(-1); + expectPartialJson("-1e-1").toBe(-1e-1); + expectPartialJson("-1e-10").toBe(-1e-10); +}); + +it("null values", () => { + for (const [item] of generateSegments("null")) { + expectPartialJson(item).toBe(null); + } +}); + +it("boolean values", () => { + for (const [item] of generateSegments("true")) { + expectPartialJson(item).toBe(true); + } + + for (const [item] of generateSegments("false")) { + expectPartialJson(item).toBe(false); + } +}); + +it("whitespace", () => { + expectPartialJson(" \n\t\r").toBe(undefined); + expectPartialJson(" \n\t\r123").toBe(123); + + expectPartialJson("123\n\t\r").toBe(123); +}); + +it("malformed JSON - mismatched brackets in array", () => { + expect(() => parsePartialJson("[}")).toThrow(); + expect(() => parsePartialJson("[1}")).toThrow(); + expect(() => parsePartialJson("[1,2}")).toThrow(); +}); + +it("malformed JSON - mismatched brackets in object", () => { + expect(() => parsePartialJson("{]")).toThrow(); + expect(() => parsePartialJson('{"key": 1]')).toThrow(); + expect(() => parsePartialJson('{"key": 1, "key2": 2]')).toThrow(); +}); + +it("malformed JSON - invalid number formats", () => { + expect.soft(() => parsePartialJson("+1")).toThrow(); + expect.soft(() => parsePartialJson("+123")).toThrow(); + expect.soft(() => parsePartialJson(".5")).toThrow(); + expect.soft(() => parsePartialJson(".123")).toThrow(); + expect.soft(() => parsePartialJson("[+1]")).toThrow(); + expect.soft(() => parsePartialJson('{"num": +1}')).toThrow(); + expect.soft(() => parsePartialJson('{"num": .5}')).toThrow(); +}); + +it("malformed JSON - invalid object key", () => { + expect.soft(() => parsePartialJson("{a: 1}")).toThrow(); + expect.soft(() => parsePartialJson("{1: 1}")).toThrow(); + expect.soft(() => parsePartialJson("{true: 1}")).toThrow(); + expect.soft(() => parsePartialJson('{"key": 1, a: 2}')).toThrow(); +}); + +it("malformed JSON - unexpected closing bracket", () => { + expect.soft(() => parsePartialJson("}")).toThrow(); + expect.soft(() => parsePartialJson("]")).toThrow(); + expect.soft(() => parsePartialJson("}1")).toThrow(); + expect.soft(() => parsePartialJson("]1")).toThrow(); +}); + +it("malformed JSON - invalid characters after valid tokens", () => { + expect.soft(() => parsePartialJson("nullx")).toThrow(); + expect.soft(() => parsePartialJson("truex")).toThrow(); + expect.soft(() => parsePartialJson("falsex")).toThrow(); + expect.soft(() => parsePartialJson("null1")).toThrow(); + expect.soft(() => parsePartialJson("true1")).toThrow(); + expect.soft(() => parsePartialJson("false1")).toThrow(); + expect.soft(() => parsePartialJson("[nullx]")).toThrow(); + expect.soft(() => parsePartialJson('{"key": nullx}')).toThrow(); +}); + +it("malformed JSON - invalid number formats", () => { + // Multiple decimal points + expect.soft(() => parsePartialJson("1.2.3")).toThrow(); + expect.soft(() => parsePartialJson("12.34.56")).toThrow(); + expect.soft(() => parsePartialJson("-1.2.3")).toThrow(); + + // Multiple e/E + expect.soft(() => parsePartialJson("1e2e3")).toThrow(); + expect.soft(() => parsePartialJson("1E2E3")).toThrow(); + expect.soft(() => parsePartialJson("1e2E3")).toThrow(); + + // Invalid characters in numbers + expect.soft(() => parsePartialJson("1a2")).toThrow(); + expect.soft(() => parsePartialJson("1.2a")).toThrow(); + expect.soft(() => parsePartialJson("1e2a")).toThrow(); + expect.soft(() => parsePartialJson("1e+a")).toThrow(); + expect.soft(() => parsePartialJson("1e-a")).toThrow(); + expect.soft(() => parsePartialJson("12abc")).toThrow(); + expect.soft(() => parsePartialJson("-12abc")).toThrow(); + + // Leading zeros (invalid in JSON) + expect.soft(() => parsePartialJson("00")).toThrow(); + expect.soft(() => parsePartialJson("012")).toThrow(); + expect.soft(() => parsePartialJson("-01")).toThrow(); +}); + +it("malformed JSON - nested bracket mismatches", () => { + expect.soft(() => parsePartialJson("[[}]")).toThrow(); + expect.soft(() => parsePartialJson("{{]}}")).toThrow(); + expect.soft(() => parsePartialJson("[{]]")).toThrow(); + expect.soft(() => parsePartialJson('{"a": [}]')).toThrow(); + expect.soft(() => parsePartialJson('{"a": {]}}')).toThrow(); +}); + +it("malformed JSON - missing colons in objects", () => { + expect.soft(() => parsePartialJson('{"key" 1}')).toThrow(); + expect.soft(() => parsePartialJson('{"key"1}')).toThrow(); + expect.soft(() => parsePartialJson('{"key"}')).toThrow(); + expect.soft(() => parsePartialJson('{"a": 1, "b" 2}')).toThrow(); +}); + +it("malformed JSON - invalid characters after comma", () => { + expect.soft(() => parsePartialJson("[1,]")).toThrow(); + expect.soft(() => parsePartialJson("[1,2,]")).toThrow(); + expect.soft(() => parsePartialJson('{"a": 1,}')).toThrow(); + expect.soft(() => parsePartialJson('{"a": 1, "b": 2,}')).toThrow(); + expect.soft(() => parsePartialJson("[1,}")).toThrow(); + expect.soft(() => parsePartialJson('{"a": 1,]')).toThrow(); +}); + +it("malformed JSON - trailing content after valid JSON", () => { + expect.soft(() => parsePartialJson('{"a": 1}extra')).toThrow(); + expect.soft(() => parsePartialJson("[1]extra")).toThrow(); + expect.soft(() => parsePartialJson('"hello"extra')).toThrow(); + expect.soft(() => parsePartialJson("123extra")).toThrow(); + expect.soft(() => parsePartialJson("trueextra")).toThrow(); + expect.soft(() => parsePartialJson("falseextra")).toThrow(); + expect.soft(() => parsePartialJson("nullextra")).toThrow(); +}); + +it("malformed JSON - multiple root values", () => { + expect.soft(() => parsePartialJson("1 2")).toThrow(); + expect.soft(() => parsePartialJson('{"a": 1} {"b": 2}')).toThrow(); + expect.soft(() => parsePartialJson("[1] [2]")).toThrow(); + expect.soft(() => parsePartialJson("true false")).toThrow(); + expect.soft(() => parsePartialJson("null true")).toThrow(); +}); + +it("malformed JSON - invalid partial keywords", () => { + expect.soft(() => parsePartialJson("nulx")).toThrow(); + expect.soft(() => parsePartialJson("trux")).toThrow(); + expect.soft(() => parsePartialJson("falsx")).toThrow(); + expect.soft(() => parsePartialJson("nul1")).toThrow(); + expect.soft(() => parsePartialJson("tru1")).toThrow(); + expect.soft(() => parsePartialJson("fals1")).toThrow(); + expect.soft(() => parsePartialJson("[nulx]")).toThrow(); + expect.soft(() => parsePartialJson('{"key": trux}')).toThrow(); +}); + +it("malformed JSON - invalid object structure", () => { + expect.soft(() => parsePartialJson('{"key":}')).toThrow(); + expect.soft(() => parsePartialJson('{:"value"}')).toThrow(); + expect.soft(() => parsePartialJson('{"key"::"value"}')).toThrow(); + expect.soft(() => parsePartialJson('{"key": "value": "extra"}')).toThrow(); + expect.soft(() => parsePartialJson('{"key" "value"}')).toThrow(); +}); + +it("malformed JSON - invalid array structure", () => { + expect.soft(() => parsePartialJson("[,]")).toThrow(); + expect.soft(() => parsePartialJson("[,1]")).toThrow(); + expect.soft(() => parsePartialJson("[1,,2]")).toThrow(); + expect.soft(() => parsePartialJson("[1 2]")).toThrow(); + expect.soft(() => parsePartialJson("[1:2]")).toThrow(); +}); + +it("malformed JSON - invalid string escapes", () => { + expect.soft(() => parsePartialJson('"\\x"')).toThrow(); + expect.soft(() => parsePartialJson('"\\z"')).toThrow(); + expect.soft(() => parsePartialJson('"\\u123"')).toThrow(); // Incomplete unicode + expect.soft(() => parsePartialJson('"\\u123x"')).toThrow(); // Invalid hex in unicode + expect.soft(() => parsePartialJson('"\\u12G3"')).toThrow(); // Invalid hex in unicode +}); + +it("malformed JSON - comments (not valid in JSON)", () => { + expect.soft(() => parsePartialJson("// comment")).toThrow(); + expect.soft(() => parsePartialJson("/* comment */")).toThrow(); + expect.soft(() => parsePartialJson('{"a": 1 // comment\n}')).toThrow(); + expect.soft(() => parsePartialJson('{"a": 1 /* comment */}')).toThrow(); +}); + +it("malformed JSON - invalid whitespace handling", () => { + // Control characters that aren't valid JSON + expect.soft(() => parsePartialJson("\x00")).toThrow(); + expect.soft(() => parsePartialJson("\x01")).toThrow(); + expect.soft(() => parsePartialJson('{"a": \x00}')).toThrow(); +}); + +it("kitchen sink of partial json parsing", async () => { + const rawJson = `{"array": ["hello", null, false, true, 12345678910], "object": {"string": "string", "null": null, "false": false, "true": true, "number": 12345678910}, "long": "very long string"}`; + const segments = ( + await fs.readFile( + new URL("./__mocks__/json.kitchen-sink.jsonl", import.meta.url), + "utf-8" + ) + ) + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + + expect(segments).toHaveLength(rawJson.length); + for (const [item, i] of generateSegments(rawJson)) { + expectPartialJson(item).toEqual(segments[i]); + } +}); From 3f2c7038da14d751e192aa52dfef94f6cd5934b6 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Thu, 27 Nov 2025 18:06:21 +0100 Subject: [PATCH 2/6] Stop returning `undefined` --- libs/langchain-core/src/messages/ai.ts | 2 +- libs/langchain-core/src/utils/json.ts | 12 +++++------- libs/langchain-core/src/utils/tests/json.test.ts | 2 -- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/libs/langchain-core/src/messages/ai.ts b/libs/langchain-core/src/messages/ai.ts index c187151f8cc9..3e6519672b03 100644 --- a/libs/langchain-core/src/messages/ai.ts +++ b/libs/langchain-core/src/messages/ai.ts @@ -318,7 +318,7 @@ export class AIMessageChunk< for (const chunks of groupedToolCallChunks) { let parsedArgs: Record | null = null; const name = chunks[0]?.name ?? ""; - const joinedArgs = chunks.map((c) => c.args || "").join(""); + const joinedArgs = chunks.map((c) => c.args || "").join("").trim(); const argsStr = joinedArgs.length ? joinedArgs : "{}"; const id = chunks[0]?.id; try { diff --git a/libs/langchain-core/src/utils/json.ts b/libs/langchain-core/src/utils/json.ts index 29d8343031a2..51a08b3891d9 100644 --- a/libs/langchain-core/src/utils/json.ts +++ b/libs/langchain-core/src/utils/json.ts @@ -30,12 +30,9 @@ export function parseJsonMarkdown(s: string, parser = parsePartialJson) { * Recursive descent partial JSON parser. * @param s - The string to parse. * @returns The parsed value. + * @throws Error if the input is a malformed JSON string. */ -export function strictParsePartialJson( - s: string | undefined -): unknown | undefined { - if (typeof s === "undefined") return undefined; - +export function strictParsePartialJson(s: string): unknown { try { return JSON.parse(s); } catch { @@ -43,7 +40,7 @@ export function strictParsePartialJson( } const buffer = s.trim(); - if (buffer.length === 0) return undefined; + if (buffer.length === 0) throw new Error("Unexpected end of JSON input"); let pos = 0; @@ -316,7 +313,8 @@ export function strictParsePartialJson( export function parsePartialJson(s: string): any | null { // Attempt to parse the modified string as JSON. try { - return strictParsePartialJson(s) ?? null; + if (typeof s === "undefined") return null; + return strictParsePartialJson(s); } catch { // If we still can't parse the string as JSON, return null to indicate failure. return null; diff --git a/libs/langchain-core/src/utils/tests/json.test.ts b/libs/langchain-core/src/utils/tests/json.test.ts index 91c9f1bda33f..e246c3a5cc75 100644 --- a/libs/langchain-core/src/utils/tests/json.test.ts +++ b/libs/langchain-core/src/utils/tests/json.test.ts @@ -93,9 +93,7 @@ it("boolean values", () => { }); it("whitespace", () => { - expectPartialJson(" \n\t\r").toBe(undefined); expectPartialJson(" \n\t\r123").toBe(123); - expectPartialJson("123\n\t\r").toBe(123); }); From 833f57834dc3aa64e4cfdd7499f865b2ab41462a Mon Sep 17 00:00:00 2001 From: Hunter Lovell <40191806+hntrl@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:14:20 -0800 Subject: [PATCH 3/6] Create popular-fireants-laugh.md --- .changeset/popular-fireants-laugh.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/popular-fireants-laugh.md diff --git a/.changeset/popular-fireants-laugh.md b/.changeset/popular-fireants-laugh.md new file mode 100644 index 000000000000..b0cfd75a9b14 --- /dev/null +++ b/.changeset/popular-fireants-laugh.md @@ -0,0 +1,5 @@ +--- +"@langchain/core": patch +--- + +allow parsing more partial JSON From e86eebcdcfa3608999c01e1792efb1f17cb50b85 Mon Sep 17 00:00:00 2001 From: Hunter Lovell Date: Mon, 1 Dec 2025 17:18:34 -0800 Subject: [PATCH 4/6] cr --- libs/langchain-core/src/messages/ai.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/libs/langchain-core/src/messages/ai.ts b/libs/langchain-core/src/messages/ai.ts index 3e6519672b03..979d358fd0f5 100644 --- a/libs/langchain-core/src/messages/ai.ts +++ b/libs/langchain-core/src/messages/ai.ts @@ -318,7 +318,10 @@ export class AIMessageChunk< for (const chunks of groupedToolCallChunks) { let parsedArgs: Record | null = null; const name = chunks[0]?.name ?? ""; - const joinedArgs = chunks.map((c) => c.args || "").join("").trim(); + const joinedArgs = chunks + .map((c) => c.args || "") + .join("") + .trim(); const argsStr = joinedArgs.length ? joinedArgs : "{}"; const id = chunks[0]?.id; try { From 993413a606f4254ac51e81e1709f6b21e21fa606 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Wed, 3 Dec 2025 15:33:26 +0100 Subject: [PATCH 5/6] Add more tests for escape characters --- libs/langchain-core/src/utils/json.ts | 7 ++++ .../src/utils/tests/json.test.ts | 34 +++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/libs/langchain-core/src/utils/json.ts b/libs/langchain-core/src/utils/json.ts index 51a08b3891d9..b49e5342ce2b 100644 --- a/libs/langchain-core/src/utils/json.ts +++ b/libs/langchain-core/src/utils/json.ts @@ -73,6 +73,12 @@ export function strictParsePartialJson(s: string): unknown { result += "\\"; } else if (char === '"') { result += '"'; + } else if (char === "b") { + result += "\b"; + } else if (char === "f") { + result += "\f"; + } else if (char === "/") { + result += "/"; } else if (char === "u") { const hex = buffer.substring(pos + 1, pos + 5); if (/^[0-9A-Fa-f]{0,4}$/.test(hex)) { @@ -106,6 +112,7 @@ export function strictParsePartialJson(s: string): unknown { pos += 1; } + if (escaped) result += "\\"; return result; } diff --git a/libs/langchain-core/src/utils/tests/json.test.ts b/libs/langchain-core/src/utils/tests/json.test.ts index e246c3a5cc75..3d99801257ed 100644 --- a/libs/langchain-core/src/utils/tests/json.test.ts +++ b/libs/langchain-core/src/utils/tests/json.test.ts @@ -30,6 +30,10 @@ it("array", () => { expectPartialJson("[null,").toEqual([null]); expectPartialJson("[null,t").toEqual([null, true]); expectPartialJson("[null,{").toEqual([null, {}]); + expectPartialJson('[null,{"').toEqual([null, {}]); + expectPartialJson('[null,{"a').toEqual([null, { a: undefined }]); + expectPartialJson('[null,{"a"').toEqual([null, { a: undefined }]); + expectPartialJson('[null,{"a":').toEqual([null, { a: undefined }]); expectPartialJson('[null,{"a":1').toEqual([null, { a: 1 }]); expect(() => expectPartialJson("[n,")).toThrow(); @@ -41,13 +45,31 @@ it("strings", () => { expectPartialJson('"hello').toBe("hello"); expectPartialJson('"hello"').toBe("hello"); - expectPartialJson('"15\\n\t\r"').toBe("15\n\t\r"); + expectPartialJson(String.raw`"15\n\t\r`).toBe("15\n\t\r"); - expectPartialJson('"15\\u').toBe("15u"); - expectPartialJson('"15\\u00').toBe("15u00"); - expectPartialJson('"15\\u00f').toBe("15u00f"); - expectPartialJson('"15\\u00f8').toBe("15\u00f8"); - expectPartialJson('"15\\u00f8C').toBe("15\u00f8C"); + expectPartialJson(`"15\\u`).toBe("15u"); + expectPartialJson(`"15\\u00`).toBe("15u00"); + expectPartialJson(`"15\\u00f`).toBe("15u00f"); + expectPartialJson(String.raw`"15\u00f8`).toBe("15\u00f8"); + expectPartialJson(String.raw`"15\u00f8C`).toBe("15\u00f8C"); + expectPartialJson(String.raw`"15\u00f8C"`).toBe("15\u00f8C"); + + expectPartialJson(String.raw`"hello${"\\"}`).toBe("hello\\"); + expectPartialJson(String.raw`"hello\"`).toBe('hello"'); + expectPartialJson(String.raw`"hello\""`).toBe('hello"'); + + expectPartialJson(String.raw`"hello\\`).toBe("hello\\"); + + expectPartialJson(String.raw`"\t\n\r\b\f\/`).toBe("\t\n\r\b\f/"); + expectPartialJson(String.raw`"\t\n\r\b\f\/"`).toBe("\t\n\r\b\f/"); + + expectPartialJson(String.raw`"foo\bar`).toBe("foo\bar"); + expectPartialJson(String.raw`"foo\bar"`).toBe("foo\bar"); + + expectPartialJson(String.raw`"\u00f8${"\\"}`).toBe("\u00f8\\"); + + expect(() => expectPartialJson('"hello\\m"')).toThrow(); + expect(() => expectPartialJson('"hello\\x"')).toThrow(); }); it("numbers", () => { From 9d7d500159e8deca2857174d1b130164e4d5397f Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Wed, 3 Dec 2025 15:37:36 +0100 Subject: [PATCH 6/6] Amend --- libs/langchain-core/src/utils/tests/json.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/langchain-core/src/utils/tests/json.test.ts b/libs/langchain-core/src/utils/tests/json.test.ts index 3d99801257ed..b9ab51f4a84d 100644 --- a/libs/langchain-core/src/utils/tests/json.test.ts +++ b/libs/langchain-core/src/utils/tests/json.test.ts @@ -54,12 +54,13 @@ it("strings", () => { expectPartialJson(String.raw`"15\u00f8C`).toBe("15\u00f8C"); expectPartialJson(String.raw`"15\u00f8C"`).toBe("15\u00f8C"); + expectPartialJson(String.raw`"hello\\`).toBe("hello\\"); + expectPartialJson(String.raw`"hello\\"`).toBe("hello\\"); + expectPartialJson(String.raw`"hello${"\\"}`).toBe("hello\\"); expectPartialJson(String.raw`"hello\"`).toBe('hello"'); expectPartialJson(String.raw`"hello\""`).toBe('hello"'); - expectPartialJson(String.raw`"hello\\`).toBe("hello\\"); - expectPartialJson(String.raw`"\t\n\r\b\f\/`).toBe("\t\n\r\b\f/"); expectPartialJson(String.raw`"\t\n\r\b\f\/"`).toBe("\t\n\r\b\f/");