/** * The compaction summary call bounds its own input to the context window. * * With no tool pair to anchor an emergency split, an overflow recovery routes * the full conversation straight into `runAssistantDrivenCompaction`. If that * history exceeds the window, the summary call must front-truncate its own * request or it overflows in turn and recovery stalls. Below the window the * request is sent untouched so its prefix stays byte-aligned with the agent's * warm cache (the budget-path cache reuse must not regress). */ import { describe, expect, mock, test } from "bun:test"; mock.module("../persistence/conversation-crud.js", () => ({ setConversationProcessingStartedAt: () => {}, isConversationProcessing: () => false, getMessages: () => [], })); mock.module("../persistence/attachments-store.js", () => ({ getAttachmentMetadataForMessage: () => [], getAttachmentContent: () => null, })); mock.module("../persistence/llm-request-log-store.js", () => ({ recordRequestLog: () => {}, })); import { runAssistantDrivenCompaction } from "../context/compactor.js"; import { estimatePromptTokens } from "../context/token-estimator.js"; import type { Message, Provider } from "../providers/types.js"; const TRUNCATION_MARKER = "summary covers only the visible portion"; function turnTimestamp(turn: number): string { const hour = String(10 + Math.floor(turn / 60)).padStart(2, "0"); const minute = String(turn % 60).padStart(2, "0"); return `2026-05-21 (Thursday) ${hour}:${minute}:00 -05:00 (America/Chicago)`; } function userTurn(turn: number, body: string): Message { return { role: "user", content: [ { type: "text", text: `\ncurrent_time: ${turnTimestamp( turn, )}\n\n[U${turn}] ${body}`, }, ], }; } function assistantTurn(turn: number, body: string): Message { return { role: "assistant", content: [{ type: "text", text: `[A${turn}] ${body}` }], }; } const SUMMARY = "Earlier turns summarized in the assistant's own voice."; function compactionResponse(tailTurn: number, preview: string): string { return ` ${SUMMARY} - Nothing critical pending. `; } function makeCapturingProvider(response: string): { provider: Provider; lastRequest: () => Message[] | null; } { let captured: Message[] | null = null; const provider: Provider = { name: "mock-provider", sendMessage: async (messages: Message[]) => { captured = messages; return { content: [{ type: "text", text: response }], model: "mock-model", usage: { inputTokens: 100, outputTokens: 50 }, stopReason: "end_turn", }; }, }; return { provider, lastRequest: () => captured }; } function estimate(messages: Message[]): number { return estimatePromptTokens(messages, "system", { providerName: "mock-provider", }); } describe("runAssistantDrivenCompaction — summary call self-truncation", () => { test("front-truncates the outbound request when the full history exceeds the context window", async () => { // GIVEN a tool-pair-free history whose estimate exceeds the window, so an // overflow recovery would route it straight into the summary call. const messages: Message[] = []; for (let i = 0; i < 40; i++) { messages.push( userTurn( i, "Heavy user turn body that carries real weight. ".repeat(8), ), ); messages.push( assistantTurn( i, "Heavy assistant reply that also carries weight. ".repeat(8), ), ); } const maxInputTokens = 10_000; // Budget mirrors compactor.compactionPrefixBudget: window minus the // instruction reserve (800) and the 15% output reserve. const prefixBudget = maxInputTokens - 800 - Math.floor(maxInputTokens * 0.15); expect(estimate(messages)).toBeGreaterThan(prefixBudget); const { provider, lastRequest } = makeCapturingProvider( compactionResponse(38, "Heavy user turn body"), ); // WHEN the summary call runs against that oversized history. const result = await runAssistantDrivenCompaction({ conversationId: "conv-test", messages, provider, systemPrompt: "system", compaction: { enabled: true, autoThreshold: 0.7 }, maxInputTokens, force: true, previousEstimatedInputTokens: 90_000, }); expect(result.compacted).toBe(true); // THEN the request actually sent fits within the context window. const sent = lastRequest(); expect(sent).not.toBeNull(); expect(estimate(sent ?? [])).toBeLessThan(maxInputTokens); // AND it opens with a marker noting the dropped leading messages so the // model knows the summary covers only the visible portion. const firstBlock = sent?.[0]?.content[0]; const firstText = firstBlock && "text" in firstBlock ? firstBlock.text : ""; expect(firstText).toContain(TRUNCATION_MARKER); }); test("sends the full history untouched when it already fits below the budget", async () => { // GIVEN a small history whose estimate is well under the window — the // common budget-triggered compaction. const messages: Message[] = []; for (let i = 0; i < 8; i++) { messages.push(userTurn(i, "short user turn body")); messages.push(assistantTurn(i, "short assistant reply body")); } const maxInputTokens = 200_000; const prefixBudget = maxInputTokens - 800 - Math.floor(maxInputTokens * 0.15); expect(estimate(messages)).toBeLessThan(prefixBudget); const { provider, lastRequest } = makeCapturingProvider( compactionResponse(6, "short user turn body"), ); // WHEN the summary call runs against the below-budget history. const result = await runAssistantDrivenCompaction({ conversationId: "conv-test", messages, provider, systemPrompt: "system", compaction: { enabled: true, autoThreshold: 0.7 }, maxInputTokens, force: true, previousEstimatedInputTokens: 90_000, }); expect(result.compacted).toBe(true); // THEN no truncation marker is prepended and the request carries every // history message plus the single instruction, with the leading message // byte-identical to the original — keeping the prefix cache warm. const sent = lastRequest(); expect(sent).not.toBeNull(); expect(sent?.length).toBe(messages.length + 1); const firstBlock = sent?.[0]?.content[0]; const firstText = firstBlock && "text" in firstBlock ? firstBlock.text : ""; expect(firstText).not.toContain(TRUNCATION_MARKER); const originalFirst = messages[0]?.content[0]; expect(firstText).toBe( originalFirst && "text" in originalFirst ? originalFirst.text : "", ); }); });