diff --git a/packages/agent/src/compaction/compaction.ts b/packages/agent/src/compaction/compaction.ts index 31efac440..e58666407 100644 --- a/packages/agent/src/compaction/compaction.ts +++ b/packages/agent/src/compaction/compaction.ts @@ -658,6 +658,35 @@ export interface SummaryOptions { fetch?: FetchImpl; } +function formatPreviousSnapcompactArchive(archiveText: string): string { + return `Previous snapcompact archive source text:\n\n${archiveText}`; +} + +function mergePreviousSummaryWithSnapcompactArchive( + previousSummary: string | undefined, + archiveText: string | undefined, +): string | undefined { + if (!archiveText) return previousSummary; + const archiveSummary = formatPreviousSnapcompactArchive(archiveText); + return previousSummary ? `${previousSummary}\n\n${archiveSummary}` : archiveSummary; +} + +function createSnapcompactArchiveMigrationMessage(archiveText: string): Message { + return { + role: "user", + content: [{ type: "text", text: formatPreviousSnapcompactArchive(archiveText) }], + timestamp: Date.now(), + }; +} + +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, @@ -1101,10 +1130,27 @@ export async function compact( fetch: options?.fetch, }; + const previousSnapcompactArchive = snapcompact.getPreservedArchive(previousPreserveData); + const previousSnapcompactArchiveText = previousSnapcompactArchive + ? snapcompact.archiveSourceText(previousSnapcompactArchive) + : undefined; + const previousSummaryForCompaction = mergePreviousSummaryWithSnapcompactArchive( + previousSummary, + previousSnapcompactArchiveText, + ); + const snapcompactArchiveMigrationMessage = previousSnapcompactArchiveText + ? createSnapcompactArchiveMigrationMessage(previousSnapcompactArchiveText) + : undefined; + let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined); if (settings.remoteEnabled !== false && shouldUseOpenAiRemoteCompaction(model)) { const previousRemoteCompaction = getPreservedOpenAiRemoteCompactionData(previousPreserveData); - const remoteMessages = [...messagesToSummarize, ...turnPrefixMessages, ...recentMessages]; + const remoteMessages: AgentMessage[] = [ + ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []), + ...messagesToSummarize, + ...turnPrefixMessages, + ...recentMessages, + ]; const previousReplacementHistory = previousRemoteCompaction?.provider === model.provider ? previousRemoteCompaction.replacementHistory @@ -1150,7 +1196,7 @@ export async function compact( if (isSplitTurn && turnPrefixMessages.length > 0) { // Generate both summaries in parallel const [historyResult, turnPrefixResult] = await Promise.all([ - messagesToSummarize.length > 0 + messagesToSummarize.length > 0 || previousSummaryForCompaction ? generateSummary( messagesToSummarize, model, @@ -1158,7 +1204,7 @@ export async function compact( apiKey, signal, customInstructions, - previousSummary, + previousSummaryForCompaction, summaryOptions, ) : Promise.resolve("No prior history."), @@ -1175,12 +1221,12 @@ export async function compact( apiKey, signal, customInstructions, - previousSummary, + previousSummaryForCompaction, summaryOptions, ); - } else if (previousSummary) { + } else if (previousSummaryForCompaction) { // No new messages to summarize, preserve previous summary - summary = previousSummary; + summary = previousSummaryForCompaction; } else { // No messages and no previous summary summary = "No prior history."; @@ -1214,13 +1260,15 @@ export async function compact( throw new Error("First kept entry has no ID - session may need migration"); } + const finalPreserveData = previousSnapcompactArchive ? stripSnapcompactPreserveData(preserveData) : preserveData; + return { summary, shortSummary, firstKeptEntryId, tokensBefore, details: { readFiles, modifiedFiles } as CompactionDetails, - preserveData, + preserveData: finalPreserveData, }; } diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index c51000faf..355092322 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -786,6 +786,230 @@ describe("remote compaction setting", () => { expect(result.preserveData).toEqual({ otherState: "keep-me" }); }); + + it("summarizes snapcompact archive text locally and stops carrying frames", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected anthropic/claude-sonnet-4-5 model to exist"); + + const oldUser = createMessageEntry(createUserMessage("Archived turn")); + const oldAssistant = createMessageEntry(createAssistantMessage("Archived answer")); + const previousCompaction = createCompactionEntry("Snapcompact frame summary", oldAssistant.id); + previousCompaction.preserveData = { + otherState: "keep-me", + snapcompact: { + frames: [{ data: "ZmFrZQ==", mimeType: "image/png", cols: 64, rows: 40, chars: 4 }], + totalChars: 31, + truncatedChars: 0, + text: "Archived snapcompact source", + }, + }; + + const entries: SessionEntry[] = [ + oldUser, + oldAssistant, + previousCompaction, + createMessageEntry(createUserMessage("Turn 1")), + createMessageEntry(createAssistantMessage("Answer 1", createMockUsage(0, 100, 4000, 0))), + createMessageEntry(createUserMessage("Turn 2")), + createMessageEntry(createAssistantMessage("Answer 2", createMockUsage(0, 100, 9000, 0))), + ]; + + const preparation = prepareCompaction(entries, { + ...DEFAULT_COMPACTION_SETTINGS, + keepRecentTokens: 1000, + remoteEnabled: true, + }); + if (!preparation) throw new Error("Expected compaction preparation"); + + const completeSimpleSpy = vi + .spyOn(ai, "completeSimple") + .mockResolvedValue(createAssistantMessage("History summary")); + + const result = await compact(preparation, model, "test-api-key"); + const promptText = completeSimpleSpy.mock.calls + .map(call => { + const context = call[1] as { messages?: Array<{ content?: Array<{ text?: string }> }> }; + return context.messages?.[0]?.content?.[0]?.text ?? ""; + }) + .join("\n"); + + expect(promptText).toContain("Previous snapcompact archive source text:"); + expect(promptText).toContain("Archived snapcompact source"); + expect(result.preserveData).toEqual({ otherState: "keep-me" }); + }); + + it("keeps snapcompact archive text when only split-turn prefix is summarized", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected anthropic/claude-sonnet-4-5 model to exist"); + + const oldUser = createMessageEntry(createUserMessage("Archived turn")); + const oldAssistant = createMessageEntry(createAssistantMessage("Archived answer")); + const previousCompaction = createCompactionEntry("Split snapcompact frame summary", oldAssistant.id); + previousCompaction.preserveData = { + otherState: "keep-me", + snapcompact: { + frames: [{ data: "ZmFrZQ==", mimeType: "image/png", cols: 64, rows: 40, chars: 4 }], + totalChars: 34, + truncatedChars: 0, + text: "Archived split snapcompact source", + }, + }; + + const entries: SessionEntry[] = [ + oldUser, + oldAssistant, + previousCompaction, + createMessageEntry(createUserMessage("Turn after archive")), + createMessageEntry(createAssistantMessage("Prefix answer")), + createMessageEntry(createAssistantMessage("Kept answer")), + ]; + + const preparation = prepareCompaction(entries, { + ...DEFAULT_COMPACTION_SETTINGS, + keepRecentTokens: 1, + remoteEnabled: true, + }); + if (!preparation) throw new Error("Expected compaction preparation"); + expect(preparation.isSplitTurn).toBe(true); + expect(preparation.messagesToSummarize).toHaveLength(0); + expect(preparation.turnPrefixMessages.length).toBeGreaterThan(0); + + const completeSimpleSpy = vi + .spyOn(ai, "completeSimple") + .mockResolvedValueOnce(createAssistantMessage("Archived history summary")) + .mockResolvedValueOnce(createAssistantMessage("Turn prefix summary")) + .mockResolvedValueOnce(createAssistantMessage("Short summary")); + + const result = await compact(preparation, model, "test-api-key"); + const promptText = completeSimpleSpy.mock.calls + .map(call => { + const context = call[1] as { messages?: Array<{ content?: Array<{ text?: string }> }> }; + return context.messages?.[0]?.content?.[0]?.text ?? ""; + }) + .join("\n"); + + expect(promptText).toContain("Archived split snapcompact source"); + expect(result.summary).toContain("Archived history summary"); + expect(result.summary).toContain("Turn prefix summary"); + expect(result.summary).not.toContain("Archived split snapcompact source"); + expect(result.summary).not.toContain("No prior history."); + expect(result.preserveData).toEqual({ otherState: "keep-me" }); + }); + + it("strips legacy frame-only snapcompact archives during local compaction", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) throw new Error("Expected anthropic/claude-sonnet-4-5 model to exist"); + + const oldUser = createMessageEntry(createUserMessage("Archived turn")); + const oldAssistant = createMessageEntry(createAssistantMessage("Archived answer")); + const previousCompaction = createCompactionEntry("Legacy snapcompact frame summary", oldAssistant.id); + previousCompaction.preserveData = { + otherState: "keep-me", + snapcompact: { + frames: [{ data: "ZmFrZQ==", mimeType: "image/png", cols: 64, rows: 40, chars: 4 }], + totalChars: 4, + truncatedChars: 0, + }, + }; + + const entries: SessionEntry[] = [ + oldUser, + oldAssistant, + previousCompaction, + createMessageEntry(createUserMessage("Turn 1")), + createMessageEntry(createAssistantMessage("Answer 1", createMockUsage(0, 100, 4000, 0))), + createMessageEntry(createUserMessage("Turn 2")), + createMessageEntry(createAssistantMessage("Answer 2", createMockUsage(0, 100, 9000, 0))), + ]; + + const preparation = prepareCompaction(entries, { + ...DEFAULT_COMPACTION_SETTINGS, + keepRecentTokens: 1000, + remoteEnabled: true, + }); + if (!preparation) throw new Error("Expected compaction preparation"); + + vi.spyOn(ai, "completeSimple").mockResolvedValue(createAssistantMessage("History summary")); + + const result = await compact(preparation, model, "test-api-key"); + + expect(result.preserveData).toEqual({ otherState: "keep-me" }); + }); + + it("sends snapcompact archive text to OpenAI remote compaction and strips frames", async () => { + const model = getBundledModel("openai", "gpt-5.1"); + if (!model) throw new Error("Expected openai/gpt-5.1 model to exist"); + + const oldUser = createMessageEntry(createUserMessage("Archived turn")); + const oldAssistant = createMessageEntry(createAssistantMessage("Archived answer")); + const previousCompaction = createCompactionEntry("Snapcompact frame summary", oldAssistant.id); + previousCompaction.preserveData = { + otherState: "keep-me", + snapcompact: { + frames: [{ data: "ZmFrZQ==", mimeType: "image/png", cols: 64, rows: 40, chars: 4 }], + totalChars: 38, + truncatedChars: 0, + text: "Archived remote snapcompact source", + }, + }; + + const entries: SessionEntry[] = [ + oldUser, + oldAssistant, + previousCompaction, + createMessageEntry(createUserMessage("Turn 1")), + createMessageEntry(createOpenAiAssistantMessage("Answer 1", model, createMockUsage(0, 100, 4000, 0))), + createMessageEntry(createUserMessage("Turn 2")), + createMessageEntry(createOpenAiAssistantMessage("Answer 2", model, createMockUsage(0, 100, 9000, 0))), + ]; + + const preparation = prepareCompaction(entries, { + ...DEFAULT_COMPACTION_SETTINGS, + keepRecentTokens: 1000, + remoteEnabled: true, + }); + if (!preparation) throw new Error("Expected compaction preparation"); + + const remoteOutput = [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Compacted retained user" }] }, + { type: "compaction", encrypted_content: "new_encrypted" }, + ]; + const fetchHandler = vi.fn( + async (_input, _init) => + new Response(JSON.stringify({ output: remoteOutput }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const fetchSpy = mockFetch(fetchHandler); + vi.spyOn(ai, "completeSimple").mockResolvedValue(createAssistantMessage("History summary")); + + const result = await compact(preparation, model, "test-api-key", undefined, undefined, { fetch: fetchSpy }); + const requestBody = JSON.parse(String(fetchHandler.mock.calls[0]?.[1]?.body)) as { + input: Array<{ type?: string; role?: string; content?: Array<{ type?: string; text?: string }> }>; + }; + const archiveMessage = requestBody.input.find( + item => + item.type === "message" && + item.role === "user" && + item.content?.some( + block => + block.type === "input_text" && + typeof block.text === "string" && + block.text.includes("Archived remote snapcompact source"), + ), + ); + + expect(archiveMessage).toBeDefined(); + expect(result.preserveData).toEqual({ + otherState: "keep-me", + openaiRemoteCompaction: { + provider: "openai", + replacementHistory: remoteOutput, + compactionItem: { type: "compaction", encrypted_content: "new_encrypted" }, + }, + }); + }); }); describe("findCutPoint", () => { diff --git a/packages/snapcompact/src/snapcompact.ts b/packages/snapcompact/src/snapcompact.ts index f0aaca52c..4c49df334 100644 --- a/packages/snapcompact/src/snapcompact.ts +++ b/packages/snapcompact/src/snapcompact.ts @@ -1290,6 +1290,16 @@ export function getPreservedArchive(preserveData: Record | unde }; } +/** Extract persisted archive source text as plain text for LLM summarization. */ +export function archiveSourceText(archive: Archive): string | undefined { + const text = + archive.text ?? + [archive.textHead, archive.textTail] + .filter((part): part is string => typeof part === "string" && part.length > 0) + .join(NEWLINE_GLYPH); + return text.length > 0 ? toPlainText(text) : undefined; +} + /** Convert archive frames into LLM image blocks (oldest first). */ export function images(archive: Archive): ImageContent[] { return archive.frames.map(frame => ({