fix(sessions): fail fast on non-serializable JSONL root values (#97356)

serializeJsonlLine returned JSON.stringify(entry) without guarding the
undefined return case. When the root value is undefined, a function, or a
symbol, JSON.stringify returns undefined, which serializeJsonlEntry's template
literal coerced to the literal string "undefined" and wrote to disk. That is
not valid JSON, so parseJsonlEntries silently skipped the line — a fail-silent
loss of a transcript entry with no error log.

Throw a TypeError instead so the caller fixes the bad value before it reaches
the file. Circular references and BigInt already throw via JSON.stringify and
are unaffected. Null, primitives, and plain objects are unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
miorbnli
2026-06-29 01:47:24 +08:00
committed by GitHub
parent a5379472f7
commit feba78fc8f
2 changed files with 72 additions and 1 deletions
@@ -0,0 +1,60 @@
// Covers JSONL serialization behavior, including fail-fast on non-serializable
// root values that JSON.stringify would otherwise coerce to the literal string
// "undefined" (a silent transcript-data-loss path).
import { describe, expect, it } from "vitest";
import {
serializeJsonlEntry,
serializeJsonlLine,
serializeJsonlLines,
} from "./transcript-jsonl.js";
describe("serializeJsonlLine", () => {
it("throws TypeError when the root value is undefined", () => {
expect(() => serializeJsonlLine(undefined)).toThrow(TypeError);
expect(() => serializeJsonlLine(undefined)).toThrow(/not JSON-serializable/);
});
it("throws TypeError when the root value is a function", () => {
expect(() => serializeJsonlLine(() => 42)).toThrow(TypeError);
});
it("throws TypeError when the root value is a symbol", () => {
expect(() => serializeJsonlLine(Symbol("x"))).toThrow(TypeError);
});
it("serializes null as the JSON literal 'null'", () => {
expect(serializeJsonlLine(null)).toBe("null");
});
it("serializes primitives as their JSON representation", () => {
expect(serializeJsonlLine("hello")).toBe('"hello"');
expect(serializeJsonlLine(42)).toBe("42");
expect(serializeJsonlLine(true)).toBe("true");
});
it("serializes plain objects and arrays (regression guard)", () => {
expect(serializeJsonlLine({ msg: "hello" })).toBe('{"msg":"hello"}');
expect(serializeJsonlLine([1, 2, 3])).toBe("[1,2,3]");
});
});
describe("serializeJsonlEntry", () => {
it("appends a newline terminator for serializable values", () => {
expect(serializeJsonlEntry({ msg: "ok" })).toBe('{"msg":"ok"}\n');
});
it("throws for a non-serializable root value (does not emit 'undefined\\n')", () => {
// Regression guard: the literal string "undefined" must never reach the file.
expect(() => serializeJsonlEntry(undefined)).toThrow(TypeError);
});
});
describe("serializeJsonlLines", () => {
it("joins serialized lines and terminates the batch with a newline", () => {
expect(serializeJsonlLines(['{"a":1}', '{"b":2}'])).toBe('{"a":1}\n{"b":2}\n');
});
it("returns an empty string for an empty batch", () => {
expect(serializeJsonlLines([])).toBe("");
});
});
+12 -1
View File
@@ -14,7 +14,18 @@ export function serializeJsonlEntry(entry: unknown): string {
}
export function serializeJsonlLine(entry: unknown): string {
return JSON.stringify(entry);
const serialized = JSON.stringify(entry);
// JSON.stringify returns undefined when the root value is undefined, a
// function, or a symbol. Without this guard the template literal in
// serializeJsonlEntry coerces it to the literal string "undefined", which is
// not valid JSON and is silently skipped by readers — a fail-silent loss of a
// transcript entry. Fail fast instead so the caller fixes the bad value.
if (serialized === undefined) {
throw new TypeError(
`serializeJsonlLine: entry of type ${typeof entry} is not JSON-serializable (JSON.stringify returned undefined)`,
);
}
return serialized;
}
export function serializeJsonlEntries(entries: readonly unknown[]): string {