fix(agent): migrate snapcompact archive on context compaction

Co-Authored-By: OpenAI GPT-5.5 <noreply@openai.com>
This commit is contained in:
OpenAI GPT-5.5
2026-06-26 08:15:17 +00:00
parent ff759ed850
commit 5c4e269900
3 changed files with 289 additions and 7 deletions
+55 -7
View File
@@ -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", () => {
+10
View File
@@ -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 => ({