From feba78fc8fa8aad0df88f7090d5594a79f036545 Mon Sep 17 00:00:00 2001
From: miorbnli
Date: Mon, 29 Jun 2026 01:47:24 +0800
Subject: [PATCH] fix(sessions): fail fast on non-serializable JSONL root
values (#97356)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
src/config/sessions/transcript-jsonl.test.ts | 60 ++++++++++++++++++++
src/config/sessions/transcript-jsonl.ts | 13 ++++-
2 files changed, 72 insertions(+), 1 deletion(-)
create mode 100644 src/config/sessions/transcript-jsonl.test.ts
diff --git a/src/config/sessions/transcript-jsonl.test.ts b/src/config/sessions/transcript-jsonl.test.ts
new file mode 100644
index 000000000000..ef2b7b0299b5
--- /dev/null
+++ b/src/config/sessions/transcript-jsonl.test.ts
@@ -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("");
+ });
+});
diff --git a/src/config/sessions/transcript-jsonl.ts b/src/config/sessions/transcript-jsonl.ts
index 39d587b7b78f..b2b2d7227613 100644
--- a/src/config/sessions/transcript-jsonl.ts
+++ b/src/config/sessions/transcript-jsonl.ts
@@ -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 {