Files
cline/apps/cli/src/runtime/interactive/compaction.test.ts
T
BeeandClaude Fable 5 26037b17ac feat(core): default to agentic compaction (#12317)
* feat(core): default to agentic compaction

Use agentic compaction when no valid strategy is configured while preserving explicit basic selection. Add a session compaction CLI and package script for testing and comparing compaction strategies.

* createHandlerMock

* fix(core): let the agentic compaction cut land on assistant boundaries

Agentic auto-compaction only accepted typed user messages (turn starts)
as cut boundaries. The canonical host transcript — one typed task
followed by a long assistant tool_use / user tool_result loop — has no
turn start past index 0, so findCutIndex snapped to 0 and
runAgenticCompaction returned undefined: the UI showed "auto-compacting"
then "auto-compaction-skipped" on every turn while the context kept
growing. Re-compaction had the same failure permanently, because the
projected transcript starts with a compaction summary message, which is
excluded from turn starts.

Assistant messages are equally safe boundaries: an assistant's tool_use
keeps its result in the user message that follows it, so a cut there
never orphans half of a tool pair. Typed-user protection is preserved —
when a typed turn exists past index 0 the cut still stays at or before
it, so the latest typed prompt is never folded into the summary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* add compaction fixtures for testing

* basic compaction improvement

* feat: attach metadata to the merged compaction message

* fix(core): address review comments on compact-session script

- add cline provider to the API key env defaults (CLINE_API_KEY)
- accept legacy string-content messages in readMessages
- print usage instead of a stack trace when --provider/--model are missing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(core): preserve basic compaction across restores

## Summary

- keep tool-result message IDs stable across restore/persist round-trips
- preserve concluding assistant responses as real messages during basic compaction
- freeze prior compaction output so later passes only fold newly added history
- accumulate removed-message and usage metadata across repeated compactions
- update the basic compaction fixture and regression coverage

## Problem

Tool-result IDs were re-suffixed every time persisted messages were converted
back into agent messages. Because compaction state hashes the source message
prefix, restoring a session changed that hash and invalidated an otherwise
successful compaction, causing the full transcript to be sent again.

Basic compaction also reprocessed its own output on subsequent passes. This
could stack duplicate system notices, discard assistant conclusions retained by
the previous pass, and replace cumulative compaction statistics with values
from only the latest pass.

## Solution

Only add tool-result suffixes when splitting a mixed message, leaving already
split and single-result message IDs unchanged. Mark non-user compaction
survivors as preserved, carry those messages through future passes verbatim,
and budget older turns' final assistant answers as first-class messages.
Compaction metadata now adds prior removed-message and usage totals to the work
performed by the current pass.

## Validation

- 66 focused codec and compaction tests pass
- @cline/core typecheck and smoke typecheck pass
- Biome checks pass for all changed TypeScript files
- git diff --check passes

* fix unit test

* fix compaction defaults and fallback

* fix basic compaction credential lookup

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 17:41:58 -07:00

268 lines
8.2 KiB
TypeScript

import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
type CoreCompactionContext,
ProviderSettingsManager,
} from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Config } from "../../utils/types";
import {
compactInteractiveMessages,
resolveCompactionProviderConfig,
} from "./compaction";
const createHandlerMock = vi.fn();
// Core defaults to the agentic compaction strategy, which summarizes via a
// real LLM handler. Stub only `createHandlerAsync` so no network call (or API
// key) is needed; every other `@cline/llms` export stays real because
// `@cline/core` re-exports them.
vi.mock("@cline/llms", async (importOriginal) => ({
...(await importOriginal<typeof import("@cline/llms")>()),
createHandlerAsync: (config: unknown) => createHandlerMock(config),
}));
async function* streamChunks(
chunks: Array<Record<string, unknown>>,
): AsyncGenerator<Record<string, unknown>> {
for (const chunk of chunks) {
yield chunk;
}
}
function createConfig(): Config {
return {
providerId: "anthropic",
modelId: "claude-test",
apiKey: "",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
systemPrompt: "system",
mode: "act",
enableTools: true,
enableSpawnAgent: true,
enableAgentTeams: true,
verbose: false,
thinking: false,
outputMode: "text",
sandbox: false,
defaultToolAutoApprove: true,
toolPolicies: {
"*": { autoApprove: true },
},
};
}
const providerSettingsTempDirs: string[] = [];
function createProviderSettingsManager(): ProviderSettingsManager {
const tempDir = mkdtempSync(join(tmpdir(), "cline-cli-compact-"));
providerSettingsTempDirs.push(tempDir);
return new ProviderSettingsManager({
filePath: join(tempDir, "providers.json"),
});
}
afterEach(() => {
createHandlerMock.mockReset();
for (const tempDir of providerSettingsTempDirs.splice(0)) {
rmSync(tempDir, { force: true, recursive: true });
}
});
describe("compactInteractiveMessages", () => {
it("resolves manual compaction provider config from persisted OAuth settings", () => {
const manager = createProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-native",
model: "old-model",
auth: {
accessToken: "stored-access-token",
refreshToken: "stored-refresh-token",
accountId: "acct-1",
},
baseUrl: "https://stored.example.com/v1",
headers: {
"x-stored": "yes",
},
});
const config = createConfig();
config.providerId = "openai-native";
config.modelId = "gpt-test";
const providerConfig = resolveCompactionProviderConfig(config, manager);
expect(providerConfig.providerId).toBe("openai-native");
expect(providerConfig.modelId).toBe("gpt-test");
expect(providerConfig.apiKey).toBe("stored-access-token");
expect(providerConfig.accessToken).toBe("stored-access-token");
expect(providerConfig.refreshToken).toBe("stored-refresh-token");
expect(providerConfig.accountId).toBe("acct-1");
expect(providerConfig.baseUrl).toBe("https://stored.example.com/v1");
expect(providerConfig.headers).toEqual({ "x-stored": "yes" });
});
it("prefers active CLI reasoning effort over persisted provider reasoning settings", () => {
const manager = createProviderSettingsManager();
manager.saveProviderSettings({
provider: "anthropic",
model: "old-model",
reasoning: { enabled: true, effort: "low" },
});
const config = createConfig();
config.reasoningEffort = "high";
const providerConfig = resolveCompactionProviderConfig(config, manager);
expect(providerConfig.reasoningEffort).toBe("high");
});
it("passes the selected model context window to manual compaction", async () => {
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
content: `message ${index} ${longText}`,
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.budget.request.maxInputTokens).toBe(400_000);
return { messages: [messages[0]] };
});
config.knownModels = {
"claude-test": {
id: "claude-test",
maxInputTokens: 400_000,
},
};
config.compaction = { compact };
const result = await compactInteractiveMessages({
config,
providerSettingsManager: createProviderSettingsManager(),
sessionId: "sess-compact",
messages,
});
expect(compact).toHaveBeenCalledTimes(1);
expect(result.compacted).toBe(true);
expect(result.canonicalMessages).toEqual(messages);
expect(result.compactionState?.messages).toEqual([messages[0]]);
});
it("uses 90 percent of legacy contextWindow for manual compaction", async () => {
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
content: `message ${index} ${longText}`,
}));
const config = createConfig();
const compact = vi.fn((context: CoreCompactionContext) => {
expect(context.budget.request.maxInputTokens).toBe(360_000);
return { messages: [messages[0]] };
});
config.knownModels = {
"claude-test": {
id: "claude-test",
contextWindow: 400_000,
},
};
config.compaction = { compact };
const result = await compactInteractiveMessages({
config,
providerSettingsManager: createProviderSettingsManager(),
sessionId: "sess-compact",
messages,
});
expect(compact).toHaveBeenCalledTimes(1);
expect(result.compacted).toBe(true);
expect(result.canonicalMessages).toEqual(messages);
expect(result.compactionState?.messages).toEqual([messages[0]]);
});
it("uses a useful target budget for manual compaction", async () => {
const mockSummary = "## Goal\nMocked agentic compaction summary";
createHandlerMock.mockReturnValue({
createMessage: vi.fn(() =>
streamChunks([
{ type: "text", id: "summary-1", text: mockSummary },
{ type: "done", id: "summary-1", success: true },
]),
),
});
const longText = "x".repeat(16_000);
const messages = Array.from({ length: 10 }, (_, index) => ({
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
content: `message ${index} ${longText}`,
}));
const result = await compactInteractiveMessages({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
sessionId: "sess-compact",
messages,
});
const compactedMessages = result.compactionState?.messages ?? [];
const compactedTextLength = compactedMessages.reduce(
(total, message) =>
total +
(typeof message.content === "string" ? message.content.length : 0),
0,
);
expect(result.compacted).toBe(true);
expect(result.canonicalMessages).toEqual(messages);
expect(compactedMessages.length).toBeGreaterThan(1);
expect(compactedMessages.length).toBeLessThan(messages.length);
expect(compactedTextLength).toBeGreaterThan(1_000);
// The agentic strategy folds older messages into a summary message
// built from the (mocked) summarizer output.
expect(createHandlerMock).toHaveBeenCalledTimes(1);
const [summaryMessage] = compactedMessages;
const summaryText = Array.isArray(summaryMessage?.content)
? summaryMessage.content
.map((block) => ("text" in block ? block.text : ""))
.join("\n")
: String(summaryMessage?.content ?? "");
expect(summaryText).toContain(mockSummary);
});
it("reports compaction when core returns changed messages with the same count", async () => {
const messages = [
{
role: "user" as const,
content: `${" ".repeat(80)}same count but content should be trimmed${" ".repeat(80)}`,
},
];
const config = createConfig();
config.compaction = {
compact: () => ({
messages: [
{
role: "user",
content: "same count but content should be trimmed",
},
],
}),
};
const result = await compactInteractiveMessages({
config,
providerSettingsManager: createProviderSettingsManager(),
sessionId: "sess-compact",
messages,
});
expect(result.compacted).toBe(true);
expect(result.canonicalMessages).toEqual(messages);
expect(result.compactionState?.messages).toHaveLength(messages.length);
expect(result.compactionState?.messages[0]?.content).toBe(
"same count but content should be trimmed",
);
});
});