diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 02a632e25..21768be49 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -680,14 +680,6 @@ function createSnapcompactArchiveMigrationMessage(archiveText: string): Message }; } -function stripSnapcompactPreserveData( - preserveData: Record | undefined, -): Record | undefined { - if (!preserveData || !(snapcompact.PRESERVE_KEY in preserveData)) return preserveData; - const { [snapcompact.PRESERVE_KEY]: _removed, ...rest } = preserveData; - return Object.keys(rest).length > 0 ? rest : undefined; -} - export async function generateSummary( currentMessages: AgentMessage[], model: Model, @@ -1261,7 +1253,11 @@ export async function compact( throw new Error("First kept entry has no ID - session may need migration"); } - const finalPreserveData = previousSnapcompactArchive ? stripSnapcompactPreserveData(preserveData) : preserveData; + // This LLM-summary path migrated any prior snapcompact frames into the summary + // text above; strip the now-stale frame archive from preserveData so it cannot + // re-attach to the rebuilt context. Only the legacy-frame case needs stripping — + // when there was no previous archive, preserveData carries no frames to drop. + const finalPreserveData = previousSnapcompactArchive ? snapcompact.stripPreservedArchive(preserveData) : preserveData; return { summary, diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index cc2e75a94..6badee866 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -1117,20 +1117,12 @@ function toRestoredQueuedMessage(message: AgentMessage): RestoredQueuedMessage { return { text: queueChipText(message), images: queuedImageContent(message) }; } -function stripSnapcompactPreserveData( - preserveData: Record | undefined, -): Record | undefined { - if (!preserveData || !(snapcompact.PRESERVE_KEY in preserveData)) return preserveData; - const { [snapcompact.PRESERVE_KEY]: _removed, ...rest } = preserveData; - return Object.keys(rest).length > 0 ? rest : undefined; -} - function mergeLlmCompactionPreserveData( hookPreserveData: Record | undefined, resultPreserveData: Record | undefined, ): Record | undefined { const preserveData = { ...(hookPreserveData ?? {}), ...(resultPreserveData ?? {}) }; - return stripSnapcompactPreserveData(Object.keys(preserveData).length > 0 ? preserveData : undefined); + return snapcompact.stripPreservedArchive(Object.keys(preserveData).length > 0 ? preserveData : undefined); } export class AgentSession { diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts index 4c49df334..ba84dd266 100644 --- a/packages/snapcompact/src/snapcompact.ts +++ b/packages/snapcompact/src/snapcompact.ts @@ -1290,6 +1290,19 @@ export function getPreservedArchive(preserveData: Record | unde }; } +/** Drop the persisted frame archive ({@link PRESERVE_KEY}) from `preserveData`, + * returning the remaining state — or `undefined` when nothing else remains, so + * an empty `{}` is never persisted. Callers strip the archive once its frames + * have been migrated into a new compaction's text, preventing the stale frames + * from leaking back into the rebuilt context. */ +export function stripPreservedArchive( + preserveData: Record | undefined, +): Record | undefined { + if (!preserveData || !(PRESERVE_KEY in preserveData)) return preserveData; + const { [PRESERVE_KEY]: _removed, ...rest } = preserveData; + return Object.keys(rest).length > 0 ? rest : undefined; +} + /** Extract persisted archive source text as plain text for LLM summarization. */ export function archiveSourceText(archive: Archive): string | undefined { const text = diff --git a/packages/snapcompact/test/snapcompact.test.ts b/packages/snapcompact/test/snapcompact.test.ts index 957d64e0b..7f20a41cf 100644 --- a/packages/snapcompact/test/snapcompact.test.ts +++ b/packages/snapcompact/test/snapcompact.test.ts @@ -926,6 +926,25 @@ describe("archive helpers", () => { expect(snapcompact.getPreservedArchive({ [snapcompact.PRESERVE_KEY]: archive })).toEqual(archive); }); + it("stripPreservedArchive drops the frame archive and collapses to undefined when empty", () => { + expect(snapcompact.stripPreservedArchive(undefined)).toBeUndefined(); + // No archive key: pass through unchanged. + expect(snapcompact.stripPreservedArchive({ other: "keep-me" })).toEqual({ other: "keep-me" }); + // Archive key alongside unrelated state: strip only the archive. + expect( + snapcompact.stripPreservedArchive({ + other: "keep-me", + [snapcompact.PRESERVE_KEY]: { frames: [], totalChars: 0, truncatedChars: 0 }, + }), + ).toEqual({ other: "keep-me" }); + // Archive key was the only state: collapse to undefined, never persist `{}`. + expect( + snapcompact.stripPreservedArchive({ + [snapcompact.PRESERVE_KEY]: { frames: [], totalChars: 0, truncatedChars: 0 }, + }), + ).toBeUndefined(); + }); + it("historyBlocks orders text head, imaged middle, then text tail", () => { const archive: snapcompact.Archive = { frames: [{ data: btoa("middle"), mimeType: "image/png", cols: 8, rows: 8, chars: 4 }],