From 3fadaee37fcfcf8e1a5c70e630d7b0c2f5df608e Mon Sep 17 00:00:00 2001 From: ben Date: Sat, 4 Jul 2026 01:10:20 +0800 Subject: [PATCH] fix(coding-agent): guard legacy snapcompact resume frames --- packages/coding-agent/CHANGELOG.md | 4 + .../src/session/session-context.ts | 49 ++++++- .../session-manager/build-context.test.ts | 123 ++++++++++++++++++ 3 files changed, 172 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 860266c53..ceee5d68a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed large legacy snapcompact archives being rehydrated into active resumed-session context, avoiding Bun Worker crashes on oversized archived frame payloads ([#4470](https://github.com/can1357/oh-my-pi/issues/4470)). + ## [16.3.4] - 2026-07-03 ### Fixed diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts index 7b354fc72..dc490818e 100644 --- a/packages/coding-agent/src/session/session-context.ts +++ b/packages/coding-agent/src/session/session-context.ts @@ -4,6 +4,50 @@ import * as snapcompact from "@oh-my-pi/snapcompact"; import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "./messages"; import { type CompactionEntry, EPHEMERAL_MODEL_CHANGE_ROLE, type SessionEntry } from "./session-entries"; +// #4470 crash artifacts had legacy frames (no shape metadata) with 17 frames, +// ~306k archive chars, and ~1.5M truncated chars. Current snapcompact frames +// carry shape metadata; only legacy archives with frame payload risk get this +// conservative LLM-payload guard, and transcript rendering remains intact. +const LEGACY_SNAPCOMPACT_FRAME_COUNT_GUARD = 16; +const LEGACY_SNAPCOMPACT_ARCHIVE_TEXT_GUARD = 250_000; +const LEGACY_SNAPCOMPACT_TRUNCATED_CHARS_GUARD = 1_000_000; + +function hasLegacySnapcompactFrames(archive: snapcompact.Archive): boolean { + return archive.frames.some(frame => frame.font === undefined && frame.variant === undefined); +} + +function hasCrashRiskSnapcompactFramePayload(archive: snapcompact.Archive): boolean { + return ( + archive.frames.length >= LEGACY_SNAPCOMPACT_FRAME_COUNT_GUARD || + snapcompact.frameDataBytes(archive.frames) >= snapcompact.FRAME_DATA_BYTES_BUDGET + ); +} + +function hasCrashRiskSnapcompactArchiveSize(archive: snapcompact.Archive): boolean { + return ( + archive.frames.length >= LEGACY_SNAPCOMPACT_FRAME_COUNT_GUARD || + archive.truncatedChars >= LEGACY_SNAPCOMPACT_TRUNCATED_CHARS_GUARD || + (snapcompact.archiveSourceText(archive)?.length ?? 0) >= LEGACY_SNAPCOMPACT_ARCHIVE_TEXT_GUARD + ); +} + +function isCrashRiskLegacySnapcompactArchive(archive: snapcompact.Archive): boolean { + return ( + hasLegacySnapcompactFrames(archive) && + hasCrashRiskSnapcompactFramePayload(archive) && + hasCrashRiskSnapcompactArchiveSize(archive) + ); +} + +function snapcompactHistoryBlockOptions( + archive: snapcompact.Archive, + options: BuildSessionContextOptions | undefined, +): snapcompact.HistoryBlockOptions | undefined { + if (options?.transcript) return undefined; + if (isCrashRiskLegacySnapcompactArchive(archive)) return { maxFrameDataBytes: 0 }; + return { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET }; +} + export interface SessionContext { messages: AgentMessage[]; thinkingLevel?: string; @@ -82,10 +126,7 @@ function snapcompactHistoryBlocksForContext( options: BuildSessionContextOptions | undefined, ) { if (!archive) return undefined; - return snapcompact.historyBlocks( - archive, - options?.transcript ? undefined : { maxFrameDataBytes: snapcompact.FRAME_DATA_BYTES_BUDGET }, - ); + return snapcompact.historyBlocks(archive, snapcompactHistoryBlockOptions(archive, options)); } export function buildSessionContext( diff --git a/packages/coding-agent/test/session-manager/build-context.test.ts b/packages/coding-agent/test/session-manager/build-context.test.ts index 0f85df3fd..4a99b504a 100644 --- a/packages/coding-agent/test/session-manager/build-context.test.ts +++ b/packages/coding-agent/test/session-manager/build-context.test.ts @@ -303,6 +303,129 @@ describe("buildSessionContext", () => { expect(transcriptSummary.blocks?.filter(block => block.type === "image")).toHaveLength(2); }); + it("does not rehydrate legacy oversized snapcompact frames into active LLM context (#4470)", () => { + const framePayload = "A".repeat(100_000); + const archiveText = `Issue #4470 legacy archive source\n${"archived history ".repeat(22_000)}`; + const compacted: CompactionEntry = { + ...compaction("3", "2", "Legacy snapcompact summary", "1"), + preserveData: { + [snapcompact.PRESERVE_KEY]: { + frames: Array.from({ length: 17 }, (_, index) => ({ + data: framePayload, + mimeType: "image/png", + cols: 64, + rows: 40, + chars: 1000 + index, + })), + totalChars: archiveText.length, + truncatedChars: 1_500_000, + text: archiveText, + textHead: "oldest retained snapcompact archive text", + textTail: "newest retained snapcompact archive text", + }, + }, + }; + const entries: SessionEntry[] = [ + msg("1", null, "user", "before compact"), + msg("2", "1", "assistant", "archived response"), + compacted, + msg("4", "3", "user", "after resume"), + ]; + + const ctx = buildSessionContext(entries); + + expect(ctx.messages.map(message => message.role)).toEqual(["compactionSummary", "user", "assistant", "user"]); + const summary = ctx.messages[0]; + if (summary?.role !== "compactionSummary") throw new Error("Expected active compaction summary"); + const blocks = summary.blocks ?? []; + expect(summary.summary).toContain("Legacy snapcompact summary"); + expect(blocks.some(block => block.type === "text" && block.text.includes("oldest retained snapcompact"))).toBe( + true, + ); + expect(blocks.some(block => block.type === "text" && block.text.includes("newest retained snapcompact"))).toBe( + true, + ); + expect(blocks.filter(block => block.type === "image")).toHaveLength(0); + expect(summary.images ?? []).toHaveLength(0); + + const transcript = buildSessionContext(entries, undefined, undefined, { transcript: true }); + const transcriptSummary = transcript.messages[2]; + if (transcriptSummary?.role !== "compactionSummary") throw new Error("Expected transcript compaction summary"); + expect(transcriptSummary.blocks?.filter(block => block.type === "image")).toHaveLength(17); + }); + + it("keeps current oversized snapcompact frame archives in active LLM context", () => { + const framePayload = "A".repeat(100_000); + const archiveText = `Current archive source\n${"archived history ".repeat(22_000)}`; + const compacted: CompactionEntry = { + ...compaction("3", "2", "Current snapcompact summary", "1"), + preserveData: { + [snapcompact.PRESERVE_KEY]: { + frames: Array.from({ length: 17 }, (_, index) => ({ + data: framePayload, + mimeType: "image/png", + cols: 64, + rows: 40, + chars: 1000 + index, + font: "8x13", + variant: "bw", + lineRepeat: 1, + })), + totalChars: archiveText.length, + truncatedChars: 1_500_000, + text: archiveText, + textHead: "current oldest retained text", + textTail: "current newest retained text", + }, + }, + }; + const entries: SessionEntry[] = [ + msg("1", null, "user", "before compact"), + msg("2", "1", "assistant", "archived response"), + compacted, + msg("4", "3", "user", "after resume"), + ]; + + const ctx = buildSessionContext(entries); + const summary = ctx.messages[0]; + if (summary?.role !== "compactionSummary") throw new Error("Expected active compaction summary"); + expect(summary.blocks?.filter(block => block.type === "image")).toHaveLength(17); + }); + + it("keeps small legacy snapcompact frame archives in active LLM context", () => { + const archiveText = `Large legacy text-only counter\n${"archived history ".repeat(22_000)}`; + const compacted: CompactionEntry = { + ...compaction("3", "2", "Small legacy snapcompact summary", "1"), + preserveData: { + [snapcompact.PRESERVE_KEY]: { + frames: Array.from({ length: 2 }, (_, index) => ({ + data: "A".repeat(1000), + mimeType: "image/png", + cols: 64, + rows: 40, + chars: 1000 + index, + })), + totalChars: archiveText.length, + truncatedChars: 1_500_000, + text: archiveText, + textHead: "small legacy oldest retained text", + textTail: "small legacy newest retained text", + }, + }, + }; + const entries: SessionEntry[] = [ + msg("1", null, "user", "before compact"), + msg("2", "1", "assistant", "archived response"), + compacted, + msg("4", "3", "user", "after resume"), + ]; + + const ctx = buildSessionContext(entries); + const summary = ctx.messages[0]; + if (summary?.role !== "compactionSummary") throw new Error("Expected active compaction summary"); + expect(summary.blocks?.filter(block => block.type === "image")).toHaveLength(2); + }); + it("multiple compactions uses latest", () => { const entries: SessionEntry[] = [ msg("1", null, "user", "a"),