fix(agent): migrate snapcompact archive on context compaction
Co-Authored-By: OpenAI GPT-5.5 <noreply@openai.com>
This commit is contained in:
@@ -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<string, unknown> | undefined,
|
||||
): Record<string, unknown> | 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1290,6 +1290,16 @@ export function getPreservedArchive(preserveData: Record<string, unknown> | 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 => ({
|
||||
|
||||
Reference in New Issue
Block a user