From aa38d4d6c6e351f028ab1201b74bb02359d8eda3 Mon Sep 17 00:00:00 2001 From: Javis Date: Fri, 13 Mar 2026 13:57:37 +0800 Subject: [PATCH] fix(core): add truncation recovery to parseSpecStreamLine Addresses #196 When LLM responses are truncated mid-stream, parseSpecStreamLine now attempts to repair the JSON by closing unclosed brackets, braces, and strings before giving up. This improves resilience during streaming scenarios where network issues or model interruptions cause incomplete JSON patches. Recovery handles: - Missing closing braces: {"op":"add"... -> adds } - Missing closing brackets: [1,2,3 -> adds ] - Unclosed strings: "value":"hello -> adds " - Nested structures: handles multiple levels Returns null for truly malformed JSON (mismatched brackets) to avoid false positives. --- packages/core/src/types.test.ts | 83 ++++++++++++++++++++++++++++++ packages/core/src/types.ts | 91 ++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 1 deletion(-) diff --git a/packages/core/src/types.test.ts b/packages/core/src/types.test.ts index d7a90bef..8eb136e9 100644 --- a/packages/core/src/types.test.ts +++ b/packages/core/src/types.test.ts @@ -12,10 +12,93 @@ import { createSpecStreamCompiler, createMixedStreamParser, createJsonRenderTransform, + parseSpecStreamLine, SPEC_DATA_PART_TYPE, } from "./types"; import type { Spec, SpecStreamLine, StreamChunk } from "./types"; +// ============================================================================= +// parseSpecStreamLine - including truncation recovery +// ============================================================================= + +describe("parseSpecStreamLine", () => { + it("parses valid JSON patch operations", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/root","value":"main"}', + ); + expect(result).toEqual({ op: "add", path: "/root", value: "main" }); + }); + + it("returns null for empty input", () => { + expect(parseSpecStreamLine("")).toBeNull(); + expect(parseSpecStreamLine(" ")).toBeNull(); + }); + + it("returns null for non-JSON input", () => { + expect(parseSpecStreamLine("hello world")).toBeNull(); + expect(parseSpecStreamLine("not json")).toBeNull(); + }); + + it("returns null for JSON without op field", () => { + expect(parseSpecStreamLine('{"path":"/root","value":"x"}')).toBeNull(); + }); + + it("returns null for JSON without path field", () => { + expect(parseSpecStreamLine('{"op":"add","value":"x"}')).toBeNull(); + }); + + describe("truncation recovery", () => { + it("repairs truncated JSON missing closing brace", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/root","value":"main"', + ); + expect(result).toEqual({ op: "add", path: "/root", value: "main" }); + }); + + it("repairs truncated JSON missing closing bracket and brace", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/items","value":[1,2,3]', + ); + expect(result).toEqual({ op: "add", path: "/items", value: [1, 2, 3] }); + }); + + it("repairs deeply nested truncated JSON", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/el","value":{"type":"Card","props":{"title":"Hi"},"children":[]', + ); + expect(result).toEqual({ + op: "add", + path: "/el", + value: { type: "Card", props: { title: "Hi" }, children: [] }, + }); + }); + + it("repairs truncated JSON with unclosed string", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/root","value":"main', + ); + expect(result).toEqual({ op: "add", path: "/root", value: "main" }); + }); + + it("repairs truncated array inside value", () => { + const result = parseSpecStreamLine( + '{"op":"add","path":"/arr","value":["a","b"', + ); + expect(result).toEqual({ op: "add", path: "/arr", value: ["a", "b"] }); + }); + + it("returns null for truly malformed JSON (mismatched brackets)", () => { + expect(parseSpecStreamLine('{"op":"add"]')).toBeNull(); + expect(parseSpecStreamLine('{"op":"add","value":[}')).toBeNull(); + }); + + it("returns null for incomplete patch without required fields", () => { + // Even if repaired, missing required fields = null + expect(parseSpecStreamLine('{"op":"add"')).toBeNull(); + }); + }); +}); + describe("getByPath", () => { it("gets nested values with JSON pointer paths", () => { const data = { user: { name: "John", scores: [10, 20, 30] } }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index cbc28082..abaf5806 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -552,13 +552,35 @@ export type SpecStreamLine = JsonPatch; * * SpecStream is json-render's streaming format where each line is a JSON patch * operation that progressively builds up the final spec. + * + * Includes truncation recovery: if JSON.parse fails, attempts to repair + * truncated JSON by closing unclosed brackets/braces before giving up. */ export function parseSpecStreamLine(line: string): SpecStreamLine | null { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith("{")) return null; + // First, try parsing as-is + const parsed = tryParseJsonPatch(trimmed); + if (parsed) return parsed; + + // Attempt truncation recovery: close unclosed brackets/braces + const repaired = attemptJsonRepair(trimmed); + if (repaired && repaired !== trimmed) { + const repairedParsed = tryParseJsonPatch(repaired); + if (repairedParsed) return repairedParsed; + } + + return null; +} + +/** + * Try to parse a string as a JSON patch operation. + * Returns the patch if valid, null otherwise. + */ +function tryParseJsonPatch(str: string): SpecStreamLine | null { try { - const patch = JSON.parse(trimmed) as SpecStreamLine; + const patch = JSON.parse(str) as SpecStreamLine; if (patch.op && patch.path !== undefined) { return patch; } @@ -568,6 +590,73 @@ export function parseSpecStreamLine(line: string): SpecStreamLine | null { } } +/** + * Attempt to repair truncated JSON by closing unclosed brackets and braces. + * Only handles simple truncation cases where the JSON was cut off mid-stream. + * + * @example + * '{"op":"add","path":"/root","value":"main"' -> '{"op":"add","path":"/root","value":"main"}' + * '{"op":"add","path":"/items","value":[1,2' -> '{"op":"add","path":"/items","value":[1,2]}' + */ +function attemptJsonRepair(str: string): string | null { + // Track unclosed brackets/braces (ignoring those inside strings) + const stack: string[] = []; + let inString = false; + let escaped = false; + + for (let i = 0; i < str.length; i++) { + const char = str[i]!; + + if (escaped) { + escaped = false; + continue; + } + + if (char === "\\") { + escaped = true; + continue; + } + + if (char === '"') { + inString = !inString; + continue; + } + + if (inString) continue; + + if (char === "{" || char === "[") { + stack.push(char); + } else if (char === "}") { + if (stack.length === 0 || stack[stack.length - 1] !== "{") { + return null; // Malformed, not just truncated + } + stack.pop(); + } else if (char === "]") { + if (stack.length === 0 || stack[stack.length - 1] !== "[") { + return null; // Malformed, not just truncated + } + stack.pop(); + } + } + + // If nothing is unclosed, the JSON is complete (but invalid for other reasons) + if (stack.length === 0) return null; + + // If we're mid-string, close the string first + let repaired = str; + if (inString) { + repaired += '"'; + } + + // Close unclosed brackets/braces in reverse order + while (stack.length > 0) { + const opener = stack.pop()!; + repaired += opener === "{" ? "}" : "]"; + } + + return repaired; +} + /** * Apply a single RFC 6902 JSON Patch operation to an object. * Mutates the object in place.