mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
* 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>
268 lines
8.2 KiB
TypeScript
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",
|
|
);
|
|
});
|
|
});
|