/**
* Persisted-count accounting when a synthetic context-summary message heads
* the history.
*
* The summary head (minted by `createContextSummaryMessage` on reload/fork
* inheritance, or by a prior compaction pass in the same process) has no DB
* row, so `compactedPersistedMessages` must exclude it — otherwise every
* compaction over an already-summarized history advances the persisted
* `contextCompactedMessageCount` one row too far and silently drops the
* first kept message on the next reload.
*
* Runs the real compactor arithmetic through `ContextWindowManager` with a
* canned provider response — only the provider call is seeded.
*/
import { describe, expect, mock, test } from "bun:test";
mock.module("../daemon/conversation-registry.js", () => ({
findConversationOrSubagent: () => ({
systemPrompt: "you are a test assistant",
}),
}));
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 { repairHistory } from "../agent/history-repair/history-repair.js";
import {
ContextWindowManager,
createContextSummaryMessage,
} from "../plugins/defaults/compaction/window-manager.js";
import type { Message, Provider } from "../providers/types.js";
function turnTimestamp(turn: number): string {
const minute = String(turn % 60).padStart(2, "0");
return `2026-05-21 (Thursday) 10:${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}` }],
};
}
/** Fixed-boundary responses carry no `` — the caller owns the cut. */
const FIXED_RESPONSE = `
Alice and Bob's earlier discussion, remembered in my own voice.
- Nothing critical pending.
`;
function responseWithTailStart(tailTurn: number, preview: string): string {
return `
Alice and Bob's earlier discussion, remembered in my own voice.
- Nothing critical pending.
`;
}
/** Provider that answers each call with the next queued response. */
function makeProvider(responses: string[]): Provider {
const queue = [...responses];
return {
name: "mock-provider",
sendMessage: async () => {
const response = queue.shift();
if (response === undefined) {
throw new Error("Provider called more times than responses queued");
}
return {
content: [{ type: "text", text: response }],
model: "mock-model",
usage: { inputTokens: 100, outputTokens: 50 },
stopReason: "end_turn",
};
},
};
}
function buildManager(provider: Provider): ContextWindowManager {
return new ContextWindowManager({
provider,
config: {
enabled: true,
maxInputTokens: 200_000,
targetBudgetRatio: 0.3,
compactThreshold: 0.8,
summaryBudgetRatio: 0.05,
overflowRecovery: {
enabled: true,
safetyMarginRatio: 0.05,
maxAttempts: 3,
interactiveLatestTurnCompression: "summarize",
nonInteractiveLatestTurnCompression: "summarize",
},
},
conversationId: "conv-test",
});
}
/**
* The in-memory view of a conversation with 10 persisted rows whose first 3
* are already compacted: a rehydrated summary head followed by rows 3..9.
* History index = 1 + (row - 3); row 7 lives at index 5.
*/
function historyWithSummaryHead(): Message[] {
return [
createContextSummaryMessage("Prior summary covering rows 0-2."),
userTurn(3, "Alice asks about onboarding"),
assistantTurn(4, "assistant replies"),
userTurn(5, "Alice follows up"),
assistantTurn(6, "assistant replies again"),
userTurn(7, "Alice changes topic"),
assistantTurn(8, "assistant answers the new topic"),
userTurn(9, "Alice wraps up"),
];
}
describe("compactedPersistedMessages excludes the synthetic summary head", () => {
test("fixed boundary over a rehydrated summary head", async () => {
// GIVEN 10 persisted rows with contextCompactedMessageCount 3 and a
// summary head, and the user picking row 7 ("summarize up to here") —
// history index 5
const messages = historyWithSummaryHead();
const manager = buildManager(makeProvider([FIXED_RESPONSE]));
// WHEN the fixed-boundary pass runs
const result = await manager.maybeCompact(messages, undefined, {
fixedTailStartIndex: 5,
});
// THEN the head is folded into the summary but not counted as a DB row:
// the persisted count advances 3 → 3 + 4 = 7 (rows 3..6), never 8
expect(result.compacted).toBe(true);
expect(result.compactedMessages).toBe(5);
expect(result.compactedPersistedMessages).toBe(4);
expect(result.preservedTailMessages).toBe(3);
});
test("model-chosen tail over a rehydrated summary head", async () => {
// GIVEN the same history, compacting via the model-chosen ``
// pointing at row 7 (history index 5)
const messages = historyWithSummaryHead();
const manager = buildManager(
makeProvider([responseWithTailStart(7, "Alice changes topic")]),
);
// WHEN a forced auto-path pass runs
const result = await manager.maybeCompact(messages, undefined, {
force: true,
});
// THEN the shared arithmetic excludes the head on this path too
expect(result.compacted).toBe(true);
expect(result.compactedMessages).toBe(5);
expect(result.compactedPersistedMessages).toBe(4);
});
test("second in-process pass over a compaction-minted summary head", async () => {
// GIVEN a history already compacted once in this process — its head is
// the summary message the compactor itself minted, not a reload artifact
const manager = buildManager(
makeProvider([FIXED_RESPONSE, FIXED_RESPONSE]),
);
const first = await manager.maybeCompact(
historyWithSummaryHead(),
undefined,
{ fixedTailStartIndex: 5 },
);
expect(first.compacted).toBe(true);
// [minted summary, row 7, row 8, row 9]
expect(first.messages.length).toBe(4);
// WHEN a second fixed-boundary pass cuts at row 9 (history index 3, a
// clean user boundary the tool-pairing walk leaves in place)
const second = await manager.maybeCompact(first.messages, undefined, {
fixedTailStartIndex: 3,
});
// THEN only rows 7-8 count as compacted-persisted — the minted head does not
expect(second.compacted).toBe(true);
expect(second.compactedMessages).toBe(3);
expect(second.compactedPersistedMessages).toBe(2);
});
test("repaired history — the structural marker catches a rebuilt rehydrated head", async () => {
// GIVEN the same rehydrated history after the per-turn history-repair
// pass, which copies content into fresh message wrappers — WeakSet
// identity is lost and only the `` marker remains
const { messages } = repairHistory(historyWithSummaryHead());
expect(messages.length).toBe(8);
const manager = buildManager(makeProvider([FIXED_RESPONSE]));
// WHEN a fixed-boundary pass runs over the repaired array
const result = await manager.maybeCompact(messages, undefined, {
fixedTailStartIndex: 5,
});
// THEN the head is still excluded from the persisted count
expect(result.compacted).toBe(true);
expect(result.compactedMessages).toBe(5);
expect(result.compactedPersistedMessages).toBe(4);
});
test("repaired history — a compactor-minted head is excluded on the next pass", async () => {
// GIVEN a history compacted once in this process, then run through
// history repair (the per-turn default) before the next compaction —
// the compactor-minted head must carry the structural marker or this
// pass counts it as a persisted row
const manager = buildManager(
makeProvider([FIXED_RESPONSE, FIXED_RESPONSE]),
);
const first = await manager.maybeCompact(
historyWithSummaryHead(),
undefined,
{ fixedTailStartIndex: 5 },
);
expect(first.compacted).toBe(true);
const { messages: repaired } = repairHistory(first.messages);
// [minted summary, row 7, row 8, row 9] — alternating roles, no merges
expect(repaired.length).toBe(4);
// WHEN a second fixed-boundary pass cuts at row 9 (history index 3)
const second = await manager.maybeCompact(repaired, undefined, {
fixedTailStartIndex: 3,
});
// THEN only rows 7-8 count as compacted-persisted
expect(second.compacted).toBe(true);
expect(second.compactedMessages).toBe(3);
expect(second.compactedPersistedMessages).toBe(2);
});
test("fork-inherited prefix is not double-counted with the summary head", async () => {
// GIVEN a forked conversation whose seeded non-persisted prefix (2)
// already includes its inherited summary head
const messages: Message[] = [
createContextSummaryMessage("Inherited summary from the parent."),
assistantTurn(0, "inherited assistant context with no DB row"),
userTurn(1, "Alice's first persisted message"),
assistantTurn(2, "assistant replies"),
userTurn(3, "Alice follows up"),
assistantTurn(4, "assistant replies again"),
];
const manager = buildManager(makeProvider([FIXED_RESPONSE]));
manager.seedNonPersistedPrefix(2);
// WHEN a fixed-boundary pass compacts past the whole inherited prefix
const result = await manager.maybeCompact(messages, undefined, {
fixedTailStartIndex: 4,
});
// THEN the prefix is subtracted once (the seed subsumes its own head):
// compactable = [summary, inherited, row, row] → 2 persisted rows
expect(result.compacted).toBe(true);
expect(result.compactedMessages).toBe(4);
expect(result.compactedPersistedMessages).toBe(2);
});
test("partially consumed fork prefix — a later pass counts minted head plus remaining seed", async () => {
// GIVEN a fork whose seeded 2-message prefix was only partially consumed:
// the first pass folds just the inherited summary head, leaving one
// seeded message behind a freshly minted head
const messages: Message[] = [
createContextSummaryMessage("Inherited summary from the parent."),
userTurn(0, "inherited user context with no DB row"),
userTurn(1, "Alice's first persisted message"),
assistantTurn(2, "assistant replies"),
userTurn(3, "Alice follows up"),
assistantTurn(4, "assistant replies again"),
];
const manager = buildManager(
makeProvider([FIXED_RESPONSE, FIXED_RESPONSE]),
);
manager.seedNonPersistedPrefix(2);
// Cut at the inherited user message (index 1) — a user boundary the
// tool-pairing back-walk leaves in place
const first = await manager.maybeCompact(messages, undefined, {
fixedTailStartIndex: 1,
});
expect(first.compacted).toBe(true);
expect(first.compactedMessages).toBe(1);
expect(first.compactedPersistedMessages).toBe(0);
// [minted summary, inherited user message, rows 1-4]
expect(first.messages.length).toBe(6);
// WHEN a second pass compacts past both the minted head and the
// remaining seeded message (cut at row 3 — history index 4)
const second = await manager.maybeCompact(first.messages, undefined, {
fixedTailStartIndex: 4,
});
// THEN the non-persisted lead is minted head + remaining seed = 2, so
// exactly rows 1-2 count as persisted — a max() of the two spans would
// under-count the lead and drop a kept row on reload
expect(second.compacted).toBe(true);
expect(second.compactedMessages).toBe(4);
expect(second.compactedPersistedMessages).toBe(2);
});
test("a pass that compacts only the minted head leaves the remaining seed intact", async () => {
// GIVEN a fork in the disjoint regime — [minted head, remaining seed,
// rows] — with exactly one seeded message left
const messages: Message[] = [
createContextSummaryMessage("Inherited summary from the parent."),
userTurn(0, "inherited user context with no DB row"),
userTurn(1, "Alice's first persisted message"),
assistantTurn(2, "assistant replies"),
userTurn(3, "Alice follows up"),
assistantTurn(4, "assistant replies again"),
];
const manager = buildManager(
makeProvider([FIXED_RESPONSE, FIXED_RESPONSE, FIXED_RESPONSE]),
);
manager.seedNonPersistedPrefix(2);
// First pass consumes the seed's inherited summary head, entering the
// disjoint regime with 1 seeded message behind a freshly minted head
const first = await manager.maybeCompact(messages, undefined, {
fixedTailStartIndex: 1,
});
expect(first.compacted).toBe(true);
expect(manager.nonPersistedPrefixCount).toBe(1);
// WHEN a pass compacts ONLY the minted head (cut at the seeded user
// message, history index 1) — no seed message is folded away
const second = await manager.maybeCompact(first.messages, undefined, {
fixedTailStartIndex: 1,
});
// THEN the head position is not charged against the seed
expect(second.compacted).toBe(true);
expect(second.compactedMessages).toBe(1);
expect(second.compactedPersistedMessages).toBe(0);
expect(manager.nonPersistedPrefixCount).toBe(1);
// AND the next pass still counts minted head + surviving seed as the
// non-persisted lead: compacting [head, seed, rows 1-2] (cut at row 3,
// history index 4) yields exactly 2 persisted rows — an over-consumed
// seed would report 3 and slice a kept row out on reload
const third = await manager.maybeCompact(second.messages, undefined, {
fixedTailStartIndex: 4,
});
expect(third.compacted).toBe(true);
expect(third.compactedMessages).toBe(4);
expect(third.compactedPersistedMessages).toBe(2);
expect(manager.nonPersistedPrefixCount).toBe(0);
});
test("emergency compaction over a rehydrated summary head", async () => {
// GIVEN a mid-turn overflow history led by a rehydrated summary head,
// with the last tool pair anchoring the emergency split at index 2
const messages: Message[] = [
createContextSummaryMessage("Prior summary covering rows 0-2."),
userTurn(3, "Alice asks for a command run"),
{
role: "assistant",
content: [{ type: "tool_use", id: "X", name: "Bash", input: {} }],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "X", content: "ok" }],
},
];
// The emergency prompt requests no `` — the canned response
// mirrors a prompt-following model and must still parse.
const manager = buildManager(makeProvider([FIXED_RESPONSE]));
// WHEN emergency compaction runs
const result = await manager.emergencyCompact(messages, {
previousEstimatedInputTokens: 190_000,
});
// THEN the compacted prefix [head, row 3] counts one persisted row
expect(result.compacted).toBe(true);
expect(result.compactedMessages).toBe(2);
expect(result.compactedPersistedMessages).toBe(1);
});
});