/** * The compaction summary call's front truncation must never split a * tool_use/tool_result pair, and the outbound request must be repaired * before the provider call. * * The token-budget drop loop advances one message at a time, so without a * boundary check its cut can drop an assistant `tool_use` while keeping its * user `tool_result` in the retained portion. Providers that validate * pairing (OpenAI Responses: "No tool call found for function call output * with call_id ...") reject such a request, and the retry ladder reproduces * the same rejection every pass. The cut must advance to a pair-safe user * boundary, and `buildCompactionRequest` runs the deterministic history * repair so even a malformed input history reaches the provider valid. */ 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 { ContentBlock, Message, Provider } from "../providers/types.js"; const SUMMARY = "Earlier turns summarized in the assistant's own voice."; 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 compactionResponse(tailTurn: number, preview: string): string { return ` ${SUMMARY} - Nothing critical pending. `; } /** * Fake provider enforcing the same pairing contract the OpenAI Responses * serialization is subject to: a `tool_result` may only reference a * `tool_use` emitted earlier in the same request (a `function_call_output` * with no preceding matching `function_call` is a 400). Rejects with the * provider's live error phrasing so a regression reproduces the real * failure mode. */ function makeValidatingProvider(response: string): { provider: Provider; lastRequest: () => Message[] | null; } { let captured: Message[] | null = null; const provider: Provider = { name: "mock-provider", sendMessage: async (messages: Message[]) => { const emittedToolUseIds = new Set(); for (const msg of messages) { for (const block of msg.content) { if (msg.role === "assistant" && block.type === "tool_use") { emittedToolUseIds.add(block.id); } if (block.type === "tool_result") { // guard:allow-tool-result-only: the fake validates client-side pairing only const toolUseId = (block as { tool_use_id: string }).tool_use_id; if (!emittedToolUseIds.has(toolUseId)) { throw new Error( `No tool call found for function call output with call_id ${toolUseId}.`, ); } } } } 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", }); } /** Mirror of the compactor's pair-safe cut predicate for fixture guards. */ function isCleanUserBoundary(message: Message | undefined): boolean { return ( message != null && message.role === "user" && // guard:allow-tool-result-only: mirrors the compactor's boundary predicate !message.content.some((block) => block.type === "tool_result") ); } /** * Replay the token-budget drop loop without the pairing advance, to locate * where the raw budget cut lands for a fixture. */ function naiveDropCount(messages: Message[], budgetTokens: number): number { let dropCount = 0; let estimated = estimate(messages); while (estimated > budgetTokens && dropCount < messages.length - 1) { dropCount++; estimated = estimate(messages.slice(dropCount)); } return dropCount; } function textOfMessage(message: Message | undefined): string { return (message?.content ?? []) .map((block) => ("text" in block ? (block.text as string) : "")) .join("\n"); } function requestText(messages: Message[]): string { return messages.map(textOfMessage).join("\n"); } describe("compaction summary call: pair-safe front truncation", () => { // Rounds of [user text, assistant tool_use (heavy), user tool_result, // assistant text]: the heavy tool_use messages dominate the estimate, so // the raw budget loop settles right after dropping one of them, landing // the cut on its orphaned tool_result (or the assistant text behind it), // never on a clean user boundary. function buildToolHeavyHistory(rounds: number, idPrefix: string): Message[] { const messages: Message[] = []; for (let i = 0; i < rounds; i++) { messages.push(userTurn(i, "please inspect the next data batch")); messages.push({ role: "assistant", content: [ { type: "tool_use", id: `${idPrefix}_${i}`, name: "inspect_batch", input: { payload: `batch ${i} `.repeat(600) }, }, ], }); messages.push({ role: "user", content: [ { type: "tool_result", tool_use_id: `${idPrefix}_${i}`, content: `inspection ${i} complete. `.repeat(8), }, ], }); messages.push({ role: "assistant", content: [ { type: "text", text: `[A${i}] batch ${i} looks consistent.` }, ], }); } return messages; } /** tool_result blocks in `messages` with no tool_use anywhere in `messages`. */ function orphanedResultIds(messages: Message[]): string[] { const toolUseIds = new Set(); for (const msg of messages) { for (const block of msg.content) { if (msg.role === "assistant" && block.type === "tool_use") { toolUseIds.add(block.id); } } } const orphans: string[] = []; for (const msg of messages) { for (const block of msg.content) { if (block.type === "tool_result") { // guard:allow-tool-result-only: counting client-side orphans const toolUseId = (block as { tool_use_id: string }).tool_use_id; if (!toolUseIds.has(toolUseId)) { orphans.push(toolUseId); } } } } return orphans; } // Pairing must be id-format-agnostic, so the fixture runs across both // persisted tool id shapes: Anthropic-style `toolu_` ids and OpenAI // Responses-native `call_` ids. for (const idPrefix of ["toolu", "call"] as const) { test(`advances the cut to a pair-safe boundary and succeeds against a pairing-validating provider (${idPrefix}_ ids)`, async () => { const rounds = 30; const messages = buildToolHeavyHistory(rounds, idPrefix); const maxInputTokens = 20_000; // 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); // Fixture guard: without the pairing advance, the budget cut lands on // an unsafe index and the retained tail carries a tool_result whose // tool_use sits in the dropped prefix (the exact shape the provider // rejects). If token-estimation changes ever make this land safely, // the fixture must be re-tuned or this test proves nothing. const rawCut = naiveDropCount(messages, prefixBudget); expect(rawCut).toBeGreaterThan(0); expect(isCleanUserBoundary(messages[rawCut])).toBe(false); expect(orphanedResultIds(messages.slice(rawCut)).length).toBeGreaterThan( 0, ); const { provider, lastRequest } = makeValidatingProvider( compactionResponse(rounds - 2, "please inspect the next"), ); const result = await runAssistantDrivenCompaction({ conversationId: "conv-test", messages, provider, systemPrompt: "system", compaction: { enabled: true, autoThreshold: 0.7 }, maxInputTokens, force: true, previousEstimatedInputTokens: 90_000, }); // The pairing-validating provider accepted the request and the pass // applied. expect(result.compacted).toBe(true); const sent = lastRequest(); expect(sent).not.toBeNull(); const sentMessages = sent ?? []; // Every tool_result in the outbound request is preceded by its // tool_use. expect(orphanedResultIds(sentMessages)).toEqual([]); // The cut itself was pair-safe: nothing needed the repair pass's // orphan downgrade, and the retained pairs survive intact. const sentText = requestText(sentMessages); expect(sentText).not.toContain("[orphaned"); // The request still fits the window and announces the truncation. expect(estimate(sentMessages)).toBeLessThan(maxInputTokens); expect(sentText).toContain("summary covers only the visible portion"); // Recent valid content is retained verbatim: the last round's user // turn, tool pair, and assistant reply all reach the provider. const lastRound = rounds - 1; expect(sentText).toContain(`[U${lastRound}] please inspect`); expect(sentText).toContain(`[A${lastRound}] batch ${lastRound}`); const sentToolUseIds = new Set(); for (const msg of sentMessages) { for (const block of msg.content) { if (msg.role === "assistant" && block.type === "tool_use") { sentToolUseIds.add(block.id); } } } expect(sentToolUseIds.has(`${idPrefix}_${lastRound}`)).toBe(true); }); } test("repairs a malformed below-budget history before the provider call", async () => { // Orphan tool_result (its tool_use exists nowhere in the history) and a // consecutive same-role user run, below the truncation budget so the // request-build repair is the only transform in play. const messages: Message[] = [ userTurn(0, "start the job"), { role: "user", content: [ { type: "tool_result", tool_use_id: "toolu_missing", content: "stale result payload", }, ], }, { role: "assistant", content: [{ type: "text", text: "[A0] acknowledged." }], }, userTurn(1, "carry on with the job"), { role: "assistant", content: [{ type: "text", text: "[A1] done." }], }, ]; const { provider, lastRequest } = makeValidatingProvider( compactionResponse(1, "carry on with the job"), ); const result = await runAssistantDrivenCompaction({ conversationId: "conv-test", messages, provider, systemPrompt: "system", compaction: { enabled: true, autoThreshold: 0.7 }, maxInputTokens: 200_000, force: true, previousEstimatedInputTokens: 90_000, }); expect(result.compacted).toBe(true); const sentMessages = lastRequest() ?? []; // The orphan was downgraded to text: no tool_result blocks reach the // provider, and the payload is preserved in the degraded form. const hasToolResult = sentMessages.some((msg) => // guard:allow-tool-result-only: asserting the repair removed them msg.content.some((block: ContentBlock) => block.type === "tool_result"), ); expect(hasToolResult).toBe(false); const sentText = requestText(sentMessages); expect(sentText).toContain("stale result payload"); expect(sentText).toContain("orphaned tool_result"); // Same-role runs were merged: roles strictly alternate in the history // portion of the request (everything before the trailing instruction). const historyPortion = sentMessages.slice(0, -1); for (let i = 1; i < historyPortion.length; i++) { expect(historyPortion[i].role).not.toBe(historyPortion[i - 1].role); } // Valid user text survives the repair verbatim. expect(sentText).toContain("[U0] start the job"); expect(sentText).toContain("[U1] carry on with the job"); }); });