From 988559d10a36ae043420e5e0b8599f9c22205237 Mon Sep 17 00:00:00 2001 From: Divyam Talwar Date: Thu, 24 Sep 2026 00:53:13 +0530 Subject: [PATCH] fix(transcript): skip non-record JSON without losing session usage --- src/notifications/transcript-parser.ts | 4 ++ .../transcript-parser-record-shapes.test.ts | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 tests/shared/transcript-parser-record-shapes.test.ts diff --git a/src/notifications/transcript-parser.ts b/src/notifications/transcript-parser.ts index 53344de47..38b9ed669 100644 --- a/src/notifications/transcript-parser.ts +++ b/src/notifications/transcript-parser.ts @@ -113,6 +113,10 @@ export function parseTranscript( continue; } + // Syntactically valid JSON is not necessarily a transcript record. + // Skip non-objects individually, just like malformed JSON lines. + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) continue; + if (typeof entry.timestamp === "string") endedAt = entry.timestamp; if (typeof entry.sessionId === "string" && entry.sessionId) sessionId = entry.sessionId; diff --git a/tests/shared/transcript-parser-record-shapes.test.ts b/tests/shared/transcript-parser-record-shapes.test.ts new file mode 100644 index 000000000..bde90ba04 --- /dev/null +++ b/tests/shared/transcript-parser-record-shapes.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseTranscript } from "../../src/notifications/transcript-parser.js"; + +let dir: string; +beforeEach(() => { dir = mkdtempSync(join(tmpdir(), "transcript-records-")); }); +afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + +/** Write synthetic JSONL records without executing their illustrative tool commands. */ +function transcript(records: unknown[]): string { + const path = join(dir, "session.jsonl"); + writeFileSync(path, records.map(record => JSON.stringify(record)).join("\n") + "\n"); + return path; +} + +const use = { sessionId: "session", timestamp: "2026-09-01T10:00:00Z", + message: { role: "assistant", content: [{ type: "tool_use", id: "lookup", name: "Bash", + input: { command: "cat ~/.deeplake/memory/index.md" } }] } }; +const result = { timestamp: "2026-09-01T10:00:01Z", + message: { role: "user", content: [{ type: "tool_result", tool_use_id: "lookup", content: "café\n" }] } }; + +describe("transcript non-record JSON recovery", () => { + it.each([0, 1, 2])("retains valid usage with a null record at position %i", position => { + const records: unknown[] = [use, result]; + records.splice(position, 0, null); + expect(parseTranscript(transcript(records), "fallback")).toEqual({ + sessionId: "session", endedAt: result.timestamp, + memorySearchCount: 1, memorySearchBytes: Buffer.byteLength("café\n"), + }); + }); + + it("ignores scalar and array records while preserving the fallback for an empty session", () => { + const now = new Date("2026-09-01T11:00:00Z"); + expect(parseTranscript(transcript([null, false, 17, "text", [], [use]]), "fallback", now)).toEqual({ + sessionId: "fallback", endedAt: now.toISOString(), memorySearchCount: 0, memorySearchBytes: 0, + }); + }); +});