From 939a6def41131676c517165e7081c332aac8f69d Mon Sep 17 00:00:00 2001 From: Arnaud Hillen Date: Thu, 30 Jul 2026 00:20:23 +0200 Subject: [PATCH 1/5] perf(agent): skip session JSONL sanitize when file is unchanged Every reconnect re-runs sanitizeSessionJsonl, which reads and JSON-parses every line of the native Claude transcript. For long sessions that file reaches tens of MB, adding seconds to each resume. Remember the file stat of the last clean pass and skip the read when it matches. The SDK only appends, which changes size and mtime, so any real change forces a re-parse; staleness only ever causes extra work, not a skipped heal. Generated-By: PostHog Code Task-Id: 5d24ea17-aec0-4334-884e-c2639867e260 --- .../claude/session/jsonl-hydration.test.ts | 65 +++++++++++++++++++ .../claude/session/jsonl-hydration.ts | 36 +++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts index 9e094a5c8a30..b5d76db5b4d8 100644 --- a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts +++ b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts @@ -1390,6 +1390,71 @@ describe("sanitizeSessionJsonl", () => { expect(await fs.readFile(file, "utf8")).toBe(before); }); + it("skips the parse entirely when the file stat is unchanged since the last clean pass", async () => { + // "hello" moves from the text block to the uuid so both lines have the + // same byte length; with mtime pinned, the stats are indistinguishable. + const clean = { + type: "assistant", + uuid: "a1", + parentUuid: null, + message: { + role: "assistant", + content: [{ type: "text", text: "hello" }], + }, + }; + const dirty = { + type: "assistant", + uuid: "a1hello", + parentUuid: null, + message: { role: "assistant", content: [{ type: "text", text: "" }] }, + }; + expect(JSON.stringify(clean).length).toBe(JSON.stringify(dirty).length); + + const pinned = new Date(1700000000000); + const file = await writeJsonl([clean]); + await fs.utimes(file, pinned, pinned); + expect(await sanitizeSessionJsonl(file)).toBe(false); + + // Would be healed if parsed; the stat-based skip must win instead. + await fs.writeFile(file, `${JSON.stringify(dirty)}\n`); + await fs.utimes(file, pinned, pinned); + expect(await sanitizeSessionJsonl(file)).toBe(false); + const lines = await readJsonl(file); + expect((lines[0].message as { content: unknown[] }).content).toEqual([ + { type: "text", text: "" }, + ]); + }); + + it("re-sanitizes after the file grows past a clean pass", async () => { + const file = await writeJsonl([ + { + type: "assistant", + uuid: "a1", + parentUuid: null, + message: { role: "assistant", content: [{ type: "text", text: "ok" }] }, + }, + ]); + expect(await sanitizeSessionJsonl(file)).toBe(false); + + await fs.appendFile( + file, + `${JSON.stringify({ + type: "assistant", + uuid: "a2", + parentUuid: "a1", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "" }], + }, + })}\n`, + ); + expect(await sanitizeSessionJsonl(file)).toBe(true); + const lines = await readJsonl(file); + expect((lines[1].message as { content: unknown[] }).content).toEqual([ + { type: "text", text: " " }, + ]); + }); + it("neutralizes an oversized image nested in a tool_result", async () => { // A Read on a big image file lands its bytes inside a tool_result; on // resume that block 400s every turn until it is replaced. diff --git a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts index 6f9d5f492099..facbf7dd6d99 100644 --- a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts +++ b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts @@ -601,6 +601,27 @@ interface HydrationLog { warn: (msg: string, data?: unknown) => void; } +// Every reconnect re-runs sanitize, and the read + per-line parse of a large +// transcript costs seconds. Files whose stat matches the last clean pass are +// skipped; the SDK only ever appends, which changes size and mtime. +const sanitizedFileStats = new Map(); +const SANITIZED_STATS_CAP = 256; + +function recordSanitized( + jsonlPath: string, + stat: { mtimeMs: number; size: number }, +): void { + sanitizedFileStats.delete(jsonlPath); + sanitizedFileStats.set(jsonlPath, { + mtimeMs: stat.mtimeMs, + size: stat.size, + }); + for (const oldest of sanitizedFileStats.keys()) { + if (sanitizedFileStats.size <= SANITIZED_STATS_CAP) break; + sanitizedFileStats.delete(oldest); + } +} + // Heals a persisted transcript that would otherwise 400 on every resume: // empty content blocks, missing tool_use.input, and images the API can't // process (unsupported type or over the per-image byte limit). The image case @@ -613,6 +634,14 @@ export async function sanitizeSessionJsonl( let statBefore: { mtimeMs: number; size: number }; try { statBefore = await fs.stat(jsonlPath); + const lastClean = sanitizedFileStats.get(jsonlPath); + if ( + lastClean && + lastClean.mtimeMs === statBefore.mtimeMs && + lastClean.size === statBefore.size + ) { + return false; + } raw = await fs.readFile(jsonlPath, "utf8"); } catch { return false; @@ -651,7 +680,10 @@ export async function sanitizeSessionJsonl( return JSON.stringify(parsed); }); - if (!changed) return false; + if (!changed) { + recordSanitized(jsonlPath, statBefore); + return false; + } const tmpPath = `${jsonlPath}.tmp.${Date.now()}`; let renamed = false; @@ -668,6 +700,8 @@ export async function sanitizeSessionJsonl( } await fs.rename(tmpPath, jsonlPath); renamed = true; + const statAfter = await fs.stat(jsonlPath).catch(() => null); + if (statAfter) recordSanitized(jsonlPath, statAfter); return true; } finally { if (!renamed) { From f322405126f29c3f99104ebc84d9df9f0e78d708 Mon Sep 17 00:00:00 2001 From: Arnaud Hillen Date: Thu, 30 Jul 2026 03:57:22 +0200 Subject: [PATCH 2/5] refactor(agent): use lru-cache for the sanitize stat memo Same swap as the conversation caches in #3976: lru-cache v11 is already a workspace dependency, so the hand-rolled capped Map goes away. Generated-By: PostHog Code Task-Id: 5d24ea17-aec0-4334-884e-c2639867e260 --- products/desktop/packages/agent/package.json | 1 + .../src/adapters/claude/session/jsonl-hydration.ts | 12 +++++------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/products/desktop/packages/agent/package.json b/products/desktop/packages/agent/package.json index e159b71d0e2d..cbadb847e277 100644 --- a/products/desktop/packages/agent/package.json +++ b/products/desktop/packages/agent/package.json @@ -184,6 +184,7 @@ "fflate": "^0.8.2", "hono": "^4.11.7", "jsonwebtoken": "^9.0.2", + "lru-cache": "^11.1.0", "minimatch": "^10.0.3", "@modelcontextprotocol/sdk": "1.29.0", "tar": "^7.5.19", diff --git a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts index facbf7dd6d99..74c1e3b1e19e 100644 --- a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts +++ b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { LRUCache } from "lru-cache"; import { DEFAULT_GATEWAY_MODEL } from "../../../gateway-models"; import type { PostHogAPIClient } from "../../../posthog-api"; import type { StoredEntry } from "../../../types"; @@ -604,22 +605,19 @@ interface HydrationLog { // Every reconnect re-runs sanitize, and the read + per-line parse of a large // transcript costs seconds. Files whose stat matches the last clean pass are // skipped; the SDK only ever appends, which changes size and mtime. -const sanitizedFileStats = new Map(); -const SANITIZED_STATS_CAP = 256; +const sanitizedFileStats = new LRUCache< + string, + { mtimeMs: number; size: number } +>({ max: 256 }); function recordSanitized( jsonlPath: string, stat: { mtimeMs: number; size: number }, ): void { - sanitizedFileStats.delete(jsonlPath); sanitizedFileStats.set(jsonlPath, { mtimeMs: stat.mtimeMs, size: stat.size, }); - for (const oldest of sanitizedFileStats.keys()) { - if (sanitizedFileStats.size <= SANITIZED_STATS_CAP) break; - sanitizedFileStats.delete(oldest); - } } // Heals a persisted transcript that would otherwise 400 on every resume: From bb5fb89ab5b20205473e7c9e121566ce1281bb68 Mon Sep 17 00:00:00 2001 From: Arnaud Hillen Date: Fri, 31 Jul 2026 01:51:36 +0200 Subject: [PATCH 3/5] fix(agent): close the post-rename window in the sanitize stat memo The healed path recorded the stat of the renamed file, taken after the rename, so bytes appended by a concurrent writer in that window would have been certified clean and skipped by every later pass. Record the tmp file's stat instead: rename preserves it, and any post-rename append already mismatches it, forcing a re-parse. Generated-By: PostHog Code Task-Id: 47cdc119-1c24-42ec-ac36-74c43cdd7f4e --- .../claude/session/jsonl-hydration.test.ts | 35 +++++++++++++++++++ .../claude/session/jsonl-hydration.ts | 7 ++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts index b5d76db5b4d8..7e22a35435c6 100644 --- a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts +++ b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts @@ -1455,6 +1455,41 @@ describe("sanitizeSessionJsonl", () => { ]); }); + it("re-sanitizes after the file grows past a healed pass", async () => { + // The heal path memoizes too; a dirty line appended after a heal must + // still be caught on the next pass. + const file = await writeJsonl([ + { + type: "assistant", + uuid: "a1", + parentUuid: null, + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "" }], + }, + }, + ]); + expect(await sanitizeSessionJsonl(file)).toBe(true); + + await fs.appendFile( + file, + `${JSON.stringify({ + type: "assistant", + uuid: "a2", + parentUuid: "a1", + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "" }], + }, + })}\n`, + ); + expect(await sanitizeSessionJsonl(file)).toBe(true); + const lines = await readJsonl(file); + expect((lines[1].message as { content: unknown[] }).content).toEqual([ + { type: "text", text: " " }, + ]); + }); + it("neutralizes an oversized image nested in a tool_result", async () => { // A Read on a big image file lands its bytes inside a tool_result; on // resume that block 400s every turn until it is replaced. diff --git a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts index 74c1e3b1e19e..bf8e0646175a 100644 --- a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts +++ b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts @@ -687,6 +687,10 @@ export async function sanitizeSessionJsonl( let renamed = false; try { await fs.writeFile(tmpPath, sanitized.join("\n")); + // Memoize the tmp file's stat: rename preserves it, so recording it after + // the rename leaves no window where bytes appended by a concurrent writer + // could be certified clean (they would already mismatch this stat). + const statTmp = await fs.stat(tmpPath); // A concurrent writer may still own the file; abort rather than clobber // lines appended since the read. The next resume retries. const statNow = await fs.stat(jsonlPath); @@ -698,8 +702,7 @@ export async function sanitizeSessionJsonl( } await fs.rename(tmpPath, jsonlPath); renamed = true; - const statAfter = await fs.stat(jsonlPath).catch(() => null); - if (statAfter) recordSanitized(jsonlPath, statAfter); + recordSanitized(jsonlPath, statTmp); return true; } finally { if (!renamed) { From 1022908b389f25bde3a6188aa185fe36f25836cb Mon Sep 17 00:00:00 2001 From: Arnaud Hillen Date: Fri, 31 Jul 2026 02:46:39 +0200 Subject: [PATCH 4/5] bench(agent): add a reproducible sanitize skip benchmark Not run in CI (test globs only match *.test.*). Numbers land in the PR description; rerun with pnpm vitest bench in packages/agent. Generated-By: PostHog Code Task-Id: 47cdc119-1c24-42ec-ac36-74c43cdd7f4e --- .../claude/session/jsonl-hydration.bench.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.bench.ts diff --git a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.bench.ts b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.bench.ts new file mode 100644 index 000000000000..a1169bbb60d1 --- /dev/null +++ b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.bench.ts @@ -0,0 +1,61 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, bench, describe } from "vitest"; +import { sanitizeSessionJsonl } from "./jsonl-hydration"; + +// Not run in CI (test globs only match *.test.*). Reproduce with: +// cd packages/agent && pnpm vitest bench src/adapters/claude/session/jsonl-hydration.bench.ts + +const LINES = 50_000; + +function makeLine(i: number): string { + return JSON.stringify({ + type: "assistant", + uuid: `a-${i}`, + parentUuid: i === 0 ? null : `a-${i - 1}`, + message: { + role: "assistant", + content: [ + { + type: "text", + text: `chunk ${i}: ${"tracing the residency grace period through the rehydration path ".repeat(8)}`, + }, + ], + }, + }); +} + +let dir: string; +let warmPath: string; +let grownPath: string; + +beforeAll(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "sanitize-bench-")); + const content = `${Array.from({ length: LINES }, (_, i) => makeLine(i)).join("\n")}\n`; + warmPath = path.join(dir, "warm.jsonl"); + grownPath = path.join(dir, "grown.jsonl"); + await fs.writeFile(warmPath, content); + await fs.writeFile(grownPath, content); + // Prime the stat memo for the unchanged-file case. + await sanitizeSessionJsonl(warmPath); +}); + +afterAll(async () => { + await fs.rm(dir, { recursive: true, force: true }); +}); + +describe(`sanitizeSessionJsonl, ${LINES} lines`, () => { + // Pre-PR behavior on every reconnect, and post-PR behavior whenever the + // file changed: full read plus a JSON.parse per line. The append inside + // the run keeps the memo invalidated; its own cost is microseconds. + bench("file changed since last pass (full read + parse)", async () => { + await fs.appendFile(grownPath, `${makeLine(LINES)}\n`); + await sanitizeSessionJsonl(grownPath); + }); + + // This PR: a reconnect against an unchanged file is one fs.stat. + bench("unchanged file (stat memo hit)", async () => { + await sanitizeSessionJsonl(warmPath); + }); +}); From 77888f467018675cf69b91ce78fff418c6941bcb Mon Sep 17 00:00:00 2001 From: Arnaud Hillen Date: Mon, 3 Aug 2026 18:35:17 +0200 Subject: [PATCH 5/5] chore(agent): add lru-cache to the agent importer in the lockfile Regenerated for the new packages/agent dependency; same resolution the source PR locked (11.2.5). Generated-By: PostHog Code Task-Id: 96e133e1-f570-432f-b8ff-bce4202f5b7f --- products/desktop/pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/products/desktop/pnpm-lock.yaml b/products/desktop/pnpm-lock.yaml index 4e5d676bd062..023a08673fb0 100644 --- a/products/desktop/pnpm-lock.yaml +++ b/products/desktop/pnpm-lock.yaml @@ -849,6 +849,9 @@ importers: jsonwebtoken: specifier: ^9.0.2 version: 9.0.3 + lru-cache: + specifier: ^11.1.0 + version: 11.2.5 minimatch: specifier: ^10.0.3 version: 10.1.2