Merge PR #4473: fix(session): guard legacy snapcompact frames on resume (@cexll)
This commit is contained in:
@@ -66,6 +66,7 @@
|
||||
- Fixed Windows session tail loss after atomic compaction rewrites by fencing append writers during full-file replacement and gating the atomic publish on a `commitGuard` that the storage backend checks synchronously before rename, so a concurrent `flushSync` (Ctrl+C / session-exit) is not overwritten by the stale body serialized before it ran. Covers post-compaction prompts, tool results, title changes, and exit diagnostics on the current JSONL path ([#4338](https://github.com/can1357/oh-my-pi/issues/4338)).
|
||||
- Preserved isolated branch-mode task output as a patch artifact when `commitToBranch` fails before the task branch can be transferred, and surfaced the captured patch path (plus any nested patches) through the eval `agent()` failure message so callers can recover the work instead of losing it with the isolation worktree ([#4437](https://github.com/can1357/oh-my-pi/issues/4437)).
|
||||
- Fixed task-class subagents dropping unresolved explicit model-role selectors before startup, preventing `modelRoles.task` from silently falling through to an unrelated available provider model ([#4421](https://github.com/can1357/oh-my-pi/issues/4421)).
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user