Compare commits

...

28 Commits

Author SHA1 Message Date
Robin Newhouse 141f294c82 Preserve compaction status notice reasons 2026-06-09 15:35:00 -07:00
Robin Newhouse 30b652ec11 Tighten compaction budget telemetry types 2026-06-09 15:35:00 -07:00
Robin Newhouse c267ef4d6e Emit compaction budget emergency telemetry 2026-06-09 15:35:00 -07:00
Robin Newhouse b64923d650 Align basic sanitization image expectation 2026-06-09 15:32:48 -07:00
Robin Newhouse 801462bb2c Clarify basic projection budget logging 2026-06-09 15:32:48 -07:00
Robin Newhouse a5b98c974c Budget basic compaction projections 2026-06-09 15:32:48 -07:00
Robin Newhouse 4bd4b3cf6b Align agentic compaction test tool result 2026-06-09 15:32:44 -07:00
Robin Newhouse 031d6dc269 Harden agentic summary budget fallback 2026-06-09 15:32:44 -07:00
Robin Newhouse ce61d741ee Budget agentic compaction summary input 2026-06-09 15:32:44 -07:00
Robin Newhouse bfc522989d Clean up budget projection fixture indentation 2026-06-09 15:32:39 -07:00
Robin Newhouse b47de6eff6 Align budget projection test tool results 2026-06-09 15:32:39 -07:00
Robin Newhouse 420b203e96 Recompute protected tail after thinking pruning 2026-06-09 15:32:39 -07:00
Robin Newhouse f4041977ad Drop provider-native blocks during budget projection 2026-06-09 15:32:39 -07:00
Robin Newhouse 2d99576ad8 Fix budget projection truncation accounting 2026-06-09 15:32:39 -07:00
Robin Newhouse 6c46c61801 Add pure compaction budget projection engine 2026-06-09 15:32:39 -07:00
Robin Newhouse 964a14b428 Tighten budget projection contract types 2026-06-09 15:32:32 -07:00
Robin Newhouse 8234422493 Add compaction budget projection contract 2026-06-09 15:32:32 -07:00
Robin Newhouse 8ab6650965 Align rebased manual compaction test shape 2026-06-09 15:32:09 -07:00
Robin Newhouse cae2db3345 Address sidecar async IO review feedback 2026-06-09 15:32:09 -07:00
Robin Newhouse 00c7c2373c Validate compaction sidecar updates 2026-06-09 15:32:09 -07:00
Robin Newhouse f24e8f8f02 Address sidecar Greptile feedback 2026-06-09 15:32:09 -07:00
Robin Newhouse 9dfe5513a2 Harden compaction sidecar boundaries 2026-06-09 15:32:09 -07:00
Robin Newhouse c1c47a94fe Address compaction sidecar review feedback 2026-06-09 15:32:09 -07:00
Robin Newhouse 2b20623f08 Trim compaction PR test surface 2026-06-09 15:32:08 -07:00
Robin Newhouse 7751d75b5c Address compaction review-team findings 2026-06-09 15:32:08 -07:00
Robin Newhouse 4f3f8e9f53 Address compaction review feedback 2026-06-09 15:32:08 -07:00
Robin Newhouse e3ab67d34b Handle manual compaction with compaction disabled 2026-06-09 15:32:08 -07:00
Robin Newhouse 5576e873f8 Preserve canonical session history during compaction 2026-06-09 15:32:08 -07:00
46 changed files with 3816 additions and 178 deletions
@@ -126,7 +126,8 @@ describe("compactInteractiveMessages", () => {
expect(compact).toHaveBeenCalledTimes(1);
expect(result.compacted).toBe(true);
expect(result.messages).toEqual([messages[0]]);
expect(result.canonicalMessages).toEqual(messages);
expect(result.compactionState?.messages).toEqual([messages[0]]);
});
it("falls back to legacy contextWindow for manual compaction", async () => {
@@ -157,7 +158,8 @@ describe("compactInteractiveMessages", () => {
expect(compact).toHaveBeenCalledTimes(1);
expect(result.compacted).toBe(true);
expect(result.messages).toEqual([messages[0]]);
expect(result.canonicalMessages).toEqual(messages);
expect(result.compactionState?.messages).toEqual([messages[0]]);
});
it("uses a useful target budget for manual compaction", async () => {
@@ -174,7 +176,8 @@ describe("compactInteractiveMessages", () => {
messages,
});
const compactedTextLength = result.messages.reduce(
const compactedMessages = result.compactionState?.messages ?? [];
const compactedTextLength = compactedMessages.reduce(
(total, message) =>
total +
(typeof message.content === "string" ? message.content.length : 0),
@@ -182,8 +185,9 @@ describe("compactInteractiveMessages", () => {
);
expect(result.compacted).toBe(true);
expect(result.messages.length).toBeGreaterThan(1);
expect(result.messages.length).toBeLessThan(messages.length);
expect(result.canonicalMessages).toEqual(messages);
expect(compactedMessages.length).toBeGreaterThan(1);
expect(compactedMessages.length).toBeLessThan(messages.length);
expect(compactedTextLength).toBeGreaterThan(1_000);
});
@@ -214,8 +218,9 @@ describe("compactInteractiveMessages", () => {
});
expect(result.compacted).toBe(true);
expect(result.messages).toHaveLength(messages.length);
expect(result.messages[0]?.content).toBe(
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",
);
});
+25 -6
View File
@@ -1,9 +1,11 @@
import {
createContextCompactionPrepareTurn,
createSessionCompactionState,
type ProviderConfig,
type ProviderSettings,
type ProviderSettingsManager,
type ReasoningSettings,
type SessionCompactionState,
toProviderConfig,
} from "@cline/core";
import type { Message } from "@cline/shared";
@@ -52,7 +54,12 @@ export async function compactInteractiveMessages(input: {
providerSettingsManager: ProviderSettingsManager;
sessionId: string;
messages: Message[];
}): Promise<{ compacted: boolean; messages: Message[] }> {
abortSignal?: AbortSignal;
}): Promise<{
compacted: boolean;
canonicalMessages: Message[];
compactionState?: SessionCompactionState;
}> {
const modelInfo = input.config.knownModels?.[input.config.modelId];
const maxInputTokens =
input.config.compaction?.maxInputTokens ??
@@ -81,8 +88,11 @@ export async function compactInteractiveMessages(input: {
{ mode: "manual" },
);
if (!compact) {
return { compacted: false, messages: input.messages };
return { compacted: false, canonicalMessages: input.messages };
}
// Manual compaction intentionally summarizes the full canonical transcript
// instead of reusing a prior sidecar summary, which avoids summary-of-summary
// drift across repeated `/compact` calls.
const result = await compact({
agentId: "cli",
conversationId: input.sessionId,
@@ -90,7 +100,7 @@ export async function compactInteractiveMessages(input: {
iteration: 0,
messages: input.messages,
apiMessages: input.messages,
abortSignal: new AbortController().signal,
abortSignal: input.abortSignal ?? new AbortController().signal,
systemPrompt: "",
tools: [],
model: {
@@ -103,8 +113,17 @@ export async function compactInteractiveMessages(input: {
},
},
});
if (!result) {
return { compacted: false, messages: input.messages };
if (!result?.messages) {
return { compacted: false, canonicalMessages: input.messages };
}
return { compacted: true, messages: result.messages };
return {
compacted: true,
canonicalMessages: input.messages,
compactionState: createSessionCompactionState({
sourceMessages: input.messages,
compactedMessages: result.messages,
conversationId: input.sessionId,
systemPrompt: result.systemPrompt,
}),
};
}
@@ -1,89 +1,124 @@
import type {
AgentEvent,
ProviderSettingsManager,
TeamEvent,
ToolApprovalRequest,
ToolApprovalResult,
import {
createSessionCompactionState,
type ProviderSettingsManager,
type SessionManifest,
SessionNotFoundError,
SessionSource,
} from "@cline/core";
import { SessionNotFoundError } from "@cline/core";
import type { AgentTool, Message } from "@cline/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatCommandState } from "../../utils/chat-commands";
import type { Config } from "../../utils/types";
const {
mockCreateCliCore,
mockCreateRuntimeHooks,
mockLoadInteractiveResumeMessages,
mockSetActiveCliSession,
} = vi.hoisted(() => ({
mockCreateCliCore: vi.fn(),
mockCreateRuntimeHooks: vi.fn(),
mockLoadInteractiveResumeMessages: vi.fn(),
mockSetActiveCliSession: vi.fn(),
}));
const createCliCoreMock = vi.hoisted(() => vi.fn());
const compactInteractiveMessagesMock = vi.hoisted(() => vi.fn());
const createRuntimeHooksMock = vi.hoisted(() => vi.fn());
const setActiveCliSessionMock = vi.hoisted(() => vi.fn());
const loadInteractiveResumeMessagesMock = vi.hoisted(() => vi.fn());
const subscribeToAgentEventsMock = vi.hoisted(() => vi.fn());
const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
vi.mock("../../session/session", () => ({
createCliCore: mockCreateCliCore,
}));
vi.mock("../../utils/hooks", () => ({
createRuntimeHooks: mockCreateRuntimeHooks,
}));
vi.mock("../../utils/output", () => ({
setActiveCliSession: mockSetActiveCliSession,
}));
vi.mock("../../utils/resume", () => ({
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
createCliCore: createCliCoreMock,
}));
vi.mock("../../utils/approval", () => ({
submitAndExitInTerminal: vi.fn(),
submitAndExitInTerminal: submitAndExitInTerminalMock,
}));
vi.mock("../../utils/hooks", () => ({
createRuntimeHooks: createRuntimeHooksMock,
}));
vi.mock("../../utils/output", () => ({
setActiveCliSession: setActiveCliSessionMock,
}));
vi.mock("../../utils/resume", () => ({
loadInteractiveResumeMessages: loadInteractiveResumeMessagesMock,
}));
vi.mock("../active-runtime", () => ({
markAbortInProgress: vi.fn(),
markAbortInProgress: markAbortInProgressMock,
}));
vi.mock("../session-events", () => ({
subscribeToAgentEvents: vi.fn(() => vi.fn()),
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
subscribeToAgentEvents: subscribeToAgentEventsMock,
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
}));
import { createInteractiveSessionRuntime } from "./session-runtime";
vi.mock("./compaction", () => ({
compactInteractiveMessages: compactInteractiveMessagesMock,
}));
function makeConfig(): Config {
vi.mock("./exit-summary", () => ({
createInteractiveExitSummary: createInteractiveExitSummaryMock,
}));
function createConfig(): Config {
return {
providerId: "anthropic",
modelId: "claude-test",
apiKey: "",
providerId: "cline",
modelId: "openai/gpt-5.3-codex",
verbose: false,
sandbox: false,
thinking: false,
outputMode: "text",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
systemPrompt: "system",
mode: "act",
systemPrompt: "",
enableTools: true,
enableSpawnAgent: true,
enableAgentTeams: false,
defaultToolAutoApprove: false,
toolPolicies: {},
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
enableAgentTeams: true,
verbose: false,
thinking: false,
outputMode: "text",
sandbox: false,
defaultToolAutoApprove: true,
toolPolicies: {
"*": { autoApprove: true },
},
};
}
function makeChatCommandState(config: Config): ChatCommandState {
function createChatCommandState(): ChatCommandState {
return {
enableTools: config.enableTools,
autoApproveTools: config.defaultToolAutoApprove,
cwd: config.cwd,
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
};
}
function createProviderSettingsManager(): ProviderSettingsManager {
return {
getProviderSettings: vi.fn().mockReturnValue(undefined),
} as unknown as ProviderSettingsManager;
}
function createManifest(sessionId: string): SessionManifest {
return {
version: 1,
session_id: sessionId,
source: SessionSource.CLI,
pid: 1,
started_at: "2026-01-01T00:00:00.000Z",
status: "running",
interactive: true,
provider: "anthropic",
model: "claude-test",
cwd: "/tmp/project",
workspace_root: "/tmp/project",
enable_tools: true,
enable_spawn: true,
enable_teams: true,
};
}
async function importRuntime() {
return await import("./session-runtime");
}
function makeSwitchToActModeTool(): AgentTool {
return {
name: "switch_to_act_mode",
@@ -95,14 +130,14 @@ function makeSwitchToActModeTool(): AgentTool {
function makeManager() {
let startCount = 0;
const start = vi.fn(async (_input?: unknown) => {
const start = vi.fn(async () => {
startCount += 1;
const sessionId = `session-${startCount}`;
return {
sessionId,
manifest: {
session_id: sessionId,
},
manifest: createManifest(sessionId),
manifestPath: `/tmp/${sessionId}.json`,
messagesPath: `/tmp/${sessionId}.messages.json`,
};
});
return {
@@ -114,6 +149,8 @@ function makeManager() {
dispose: vi.fn(),
get: vi.fn(),
readMessages: vi.fn(async (): Promise<Message[]> => []),
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
updateSessionCompactionState: vi.fn(),
readTranscript: vi.fn(),
ingestHookEvent: vi.fn(),
subscribe: vi.fn(),
@@ -133,7 +170,7 @@ function makeTurnResult() {
toolCalls: [],
iterations: 1,
finishReason: "completed" as const,
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
model: { id: "claude-test", provider: "anthropic" },
startedAt: new Date("2026-01-01T00:00:00.000Z"),
endedAt: new Date("2026-01-01T00:00:00.100Z"),
durationMs: 100,
@@ -150,43 +187,322 @@ function deferred<T>() {
return { promise, resolve, reject };
}
function makeRuntime(
async function makeRuntime(
manager: ReturnType<typeof makeManager>,
options: { resumeSessionId?: string } = {},
) {
mockCreateCliCore.mockResolvedValue(manager);
const config = makeConfig();
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
return createInteractiveSessionRuntime({
config,
providerSettingsManager: {} as ProviderSettingsManager,
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
resumeSessionId: options.resumeSessionId,
chatCommandState: makeChatCommandState(config),
requestToolApproval: async (
_request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => ({ approved: true }),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: makeSwitchToActModeTool(),
onAgentEvent: (_event: AgentEvent) => {},
onTeamEvent: (_event: TeamEvent) => {},
onPendingPrompts: () => {},
onPendingPromptSubmitted: () => {},
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
}
describe("createInteractiveSessionRuntime", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateRuntimeHooks.mockReturnValue({
createCliCoreMock.mockReset();
compactInteractiveMessagesMock.mockReset();
createRuntimeHooksMock.mockReset();
setActiveCliSessionMock.mockReset();
loadInteractiveResumeMessagesMock.mockReset();
subscribeToAgentEventsMock.mockReset();
subscribeToPendingPromptEventsMock.mockReset();
markAbortInProgressMock.mockReset();
submitAndExitInTerminalMock.mockReset();
createInteractiveExitSummaryMock.mockReset();
createRuntimeHooksMock.mockReturnValue({
hooks: undefined,
shutdown: vi.fn(async () => {}),
shutdown: vi.fn().mockResolvedValue(undefined),
});
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
subscribeToAgentEventsMock.mockReturnValue(() => {});
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
});
it("manual compact updates the active session sidecar without restarting", async () => {
const sessionId = "sess-active";
const messages = [
{ id: "u1", role: "user" as const, content: "hello" },
{ id: "a1", role: "assistant" as const, content: "world" },
];
const compactionState = createSessionCompactionState({
sourceMessages: messages,
compactedMessages: [
{ id: "summary", role: "user" as const, content: "summary" },
],
updatedAt: "2026-01-01T00:00:00.000Z",
});
const manager = {
start: vi.fn().mockResolvedValue({
sessionId,
manifest: createManifest(sessionId),
manifestPath: "/tmp/session.json",
messagesPath: "/tmp/session.messages.json",
}),
readMessages: vi.fn().mockResolvedValue(messages),
updateSessionCompactionState: vi
.fn()
.mockResolvedValue({ updated: true }),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
compactInteractiveMessagesMock.mockResolvedValue({
compacted: true,
canonicalMessages: messages,
compactionState,
});
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
const result = await runtime.compactCurrentSession();
expect(result).toEqual({
messagesBefore: messages.length,
messagesAfter: messages.length,
workingContextMessagesAfter: compactionState.messages.length,
compacted: true,
});
expect(manager.start).toHaveBeenCalledTimes(1);
expect(manager.stop).not.toHaveBeenCalled();
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
expect(compactInteractiveMessagesMock).toHaveBeenCalledWith({
config: expect.objectContaining({
providerId: "anthropic",
modelId: "claude-test",
}),
providerSettingsManager: expect.objectContaining({
getProviderSettings: expect.any(Function),
}),
sessionId,
messages,
abortSignal: expect.any(AbortSignal),
});
expect(manager.updateSessionCompactionState).toHaveBeenCalledWith(
sessionId,
compactionState,
);
expect(runtime.getActiveSessionId()).toBe(sessionId);
});
it("rejects manual compact while the active session is running", async () => {
const sessionId = "sess-running";
const messages = [{ role: "user" as const, content: "hello" }];
const manager = {
start: vi.fn().mockResolvedValue({
sessionId,
manifest: createManifest(sessionId),
manifestPath: "/tmp/session.json",
messagesPath: "/tmp/session.messages.json",
}),
readMessages: vi.fn().mockResolvedValue(messages),
updateSessionCompactionState: vi
.fn()
.mockResolvedValue({ updated: true }),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue({
sessionId,
status: "running",
}),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
await expect(runtime.compactCurrentSession()).rejects.toThrow(
"Cannot compact while the current turn is running",
);
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
});
it("carries compacted working context across mode-switch restarts", async () => {
const firstSessionId = "sess-mode-before";
const secondSessionId = "sess-mode-after";
const prefixMessage = {
id: "u1",
role: "user" as const,
content: "large original",
};
const tailMessage = {
id: "u2",
role: "user" as const,
content: "new canonical tail",
};
const messages = [prefixMessage, tailMessage];
const summaryMessage = {
id: "summary",
role: "user" as const,
content: "summary",
};
const compactionState = createSessionCompactionState({
sourceMessages: [prefixMessage],
compactedMessages: [summaryMessage],
conversationId: firstSessionId,
systemPrompt: "compacted system",
updatedAt: "2026-01-01T00:00:00.000Z",
});
const manager = {
start: vi
.fn()
.mockResolvedValueOnce({
sessionId: firstSessionId,
manifest: createManifest(firstSessionId),
manifestPath: "/tmp/session-before.json",
messagesPath: "/tmp/session-before.messages.json",
})
.mockResolvedValueOnce({
sessionId: secondSessionId,
manifest: createManifest(secondSessionId),
manifestPath: "/tmp/session-after.json",
messagesPath: "/tmp/session-after.messages.json",
}),
readMessages: vi.fn().mockResolvedValue(messages),
readSessionCompactionState: vi.fn().mockResolvedValue(compactionState),
updateSessionCompactionState: vi
.fn()
.mockResolvedValue({ updated: true }),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
await runtime.applyMode("plan");
expect(manager.readMessages).toHaveBeenCalledWith(firstSessionId);
expect(manager.readSessionCompactionState).toHaveBeenCalledWith(
firstSessionId,
);
expect(manager.stop).toHaveBeenCalledWith(firstSessionId);
const restartInput = manager.start.mock.calls[1]?.[0];
expect(restartInput).toMatchObject({
initialMessages: messages,
});
expect(restartInput).not.toHaveProperty("initialCompactionState");
expect(manager.updateSessionCompactionState).toHaveBeenCalledWith(
secondSessionId,
expect.objectContaining({
conversation_id: secondSessionId,
source_message_count: messages.length,
messages: [summaryMessage, tailMessage],
system_prompt: "compacted system",
}),
);
expect(runtime.getActiveSessionId()).toBe(secondSessionId);
});
it("defers creating the replacement session after a new-session reset", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager);
let startCount = 0;
const manager = {
start: vi.fn().mockImplementation(async () => {
startCount += 1;
const sessionId = `session-${startCount}`;
return {
sessionId,
manifest: createManifest(sessionId),
manifestPath: `/tmp/${sessionId}.json`,
messagesPath: `/tmp/${sessionId}.messages.json`,
};
}),
readMessages: vi.fn().mockResolvedValue([]),
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
updateSessionCompactionState: vi.fn(),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
expect(manager.start).toHaveBeenCalledOnce();
@@ -197,7 +513,7 @@ describe("createInteractiveSessionRuntime", () => {
expect(manager.stop).toHaveBeenCalledWith("session-1");
expect(manager.start).toHaveBeenCalledOnce();
expect(runtime.getActiveSessionId()).toBe("");
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
await runtime.ensureReady();
@@ -206,14 +522,50 @@ describe("createInteractiveSessionRuntime", () => {
});
it("starts fresh after resetting an initially resumed session", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager, {
let startCount = 0;
const manager = {
start: vi.fn().mockImplementation(async () => {
startCount += 1;
const sessionId = `session-${startCount}`;
return {
sessionId,
manifest: createManifest(sessionId),
manifestPath: `/tmp/${sessionId}.json`,
messagesPath: `/tmp/${sessionId}.messages.json`,
};
}),
readMessages: vi.fn().mockResolvedValue([]),
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
updateSessionCompactionState: vi.fn(),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
resumeSessionId: "resumed-session",
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
1,
manager,
"resumed-session",
@@ -221,16 +573,14 @@ describe("createInteractiveSessionRuntime", () => {
expect(manager.start).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
config: expect.objectContaining({
sessionId: "resumed-session",
}),
config: expect.objectContaining({ sessionId: "resumed-session" }),
}),
);
await runtime.resetForNewSession();
await runtime.ensureReady();
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
2,
manager,
undefined,
@@ -247,8 +597,45 @@ describe("createInteractiveSessionRuntime", () => {
});
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager);
let startCount = 0;
const manager = {
start: vi.fn().mockImplementation(async () => {
startCount += 1;
const sessionId = `session-${startCount}`;
return {
sessionId,
manifest: createManifest(sessionId),
manifestPath: `/tmp/${sessionId}.json`,
messagesPath: `/tmp/${sessionId}.messages.json`,
};
}),
readMessages: vi.fn().mockResolvedValue([]),
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
updateSessionCompactionState: vi.fn(),
stop: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn().mockResolvedValue(undefined),
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
list: vi.fn(),
delete: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
await runtime.ensureReady();
await runtime.restartEmpty();
@@ -270,7 +657,7 @@ describe("createInteractiveSessionRuntime", () => {
manager.send
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
.mockResolvedValueOnce(makeTurnResult());
const runtime = makeRuntime(manager);
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
const result = await runtime.sendCurrentTurn({
@@ -307,7 +694,7 @@ describe("createInteractiveSessionRuntime", () => {
manager.get.mockResolvedValue(undefined);
manager.getAccumulatedUsage.mockResolvedValue(undefined);
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
const runtime = makeRuntime(manager);
const runtime = await makeRuntime(manager);
await runtime.ensureReady();
const sendPromise = runtime
@@ -1,10 +1,13 @@
import {
type AgentEvent,
type CheckpointEntry,
createSessionCompactionState,
isSessionNotFoundError,
type PendingPromptMutationResult,
type ProviderSettingsManager,
projectSessionCompactionState,
readSessionCheckpointHistory,
type SessionCompactionState,
SessionSource,
type TeamEvent,
type ToolApprovalRequest,
@@ -79,6 +82,7 @@ export function createInteractiveSessionRuntime(input: {
// A reset can happen while an earlier manager.start() is still in flight.
// Bump this before resets and restarts so stale starts cannot become active.
let sessionStartGeneration = 0;
let manualCompactionAbortController: AbortController | undefined;
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
@@ -164,6 +168,7 @@ export function createInteractiveSessionRuntime(input: {
const startFreshSession = async (
initial: Message[] = [],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
): Promise<void> => {
const generation = sessionStartGeneration;
const manager = await ensureSessionManager();
@@ -173,6 +178,7 @@ export function createInteractiveSessionRuntime(input: {
toolPolicies: input.config.toolPolicies,
interactive: true,
initialMessages: initial,
...(initialCompactionState ? { initialCompactionState } : {}),
...(sessionMetadata ? { sessionMetadata } : {}),
localRuntime: {
onTeamRestored: () => {},
@@ -281,6 +287,17 @@ export function createInteractiveSessionRuntime(input: {
return await missingSessionRecoveryPromise;
};
const readCurrentCompactionState = async (): Promise<
SessionCompactionState | undefined
> => {
if (!sessionManager || !activeSessionId) {
return undefined;
}
return await sessionManager
.readSessionCompactionState(activeSessionId)
.catch(() => undefined);
};
const stopCurrentSession = async (): Promise<void> => {
const sessionId = activeSessionId;
if (sessionManager && sessionId) {
@@ -318,6 +335,7 @@ export function createInteractiveSessionRuntime(input: {
const restartWithMessages = async (
messages: Message[],
sessionMetadata?: Record<string, unknown>,
initialCompactionState?: SessionCompactionState,
): Promise<void> => {
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
@@ -325,12 +343,37 @@ export function createInteractiveSessionRuntime(input: {
startupError = undefined;
await stopCurrentSession();
clearActiveSession();
await startFreshSession(messages, sessionMetadata);
await startFreshSession(messages, sessionMetadata, initialCompactionState);
};
const restartWithCurrentMessages = async (): Promise<void> => {
const messages = await readCurrentMessages();
const [messages, compactionState] = await Promise.all([
readCurrentMessages(),
readCurrentCompactionState(),
]);
const projectedMessages = compactionState
? projectSessionCompactionState(compactionState, messages)
: undefined;
await restartWithMessages(messages);
if (!projectedMessages || !sessionManager || !activeSessionId) {
return;
}
const reanchoredCompactionState = createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
conversationId: activeSessionId,
systemPrompt: compactionState?.system_prompt,
});
const updated = await sessionManager.updateSessionCompactionState(
activeSessionId,
reanchoredCompactionState,
);
if (!updated.updated) {
input.config.logger?.log?.(
"Skipped re-anchoring session compaction state after restart",
{ sessionId: activeSessionId },
);
}
};
const restartEmpty = async (): Promise<void> => {
@@ -444,6 +487,12 @@ export function createInteractiveSessionRuntime(input: {
if (messages.length === 0) {
throw new Error("Cannot fork an empty session.");
}
const compactionState = await manager
.readSessionCompactionState(forkedFromSessionId)
.catch(() => undefined);
const projectedMessages = compactionState
? projectSessionCompactionState(compactionState, messages)
: undefined;
await manager.stop(forkedFromSessionId);
const forkMetadata = buildForkSessionMetadata({
forkedFromSessionId,
@@ -452,6 +501,24 @@ export function createInteractiveSessionRuntime(input: {
messages,
});
await startFreshSession(messages, forkMetadata);
if (projectedMessages && activeSessionId) {
const reanchoredCompactionState = createSessionCompactionState({
sourceMessages: messages,
compactedMessages: projectedMessages,
conversationId: activeSessionId,
systemPrompt: compactionState?.system_prompt,
});
const updated = await manager.updateSessionCompactionState(
activeSessionId,
reanchoredCompactionState,
);
if (!updated.updated) {
input.config.logger?.log?.(
"Skipped re-anchoring session compaction state after fork",
{ sessionId: activeSessionId },
);
}
}
return { forkedFromSessionId, newSessionId: activeSessionId };
};
@@ -473,22 +540,41 @@ export function createInteractiveSessionRuntime(input: {
const compactCurrentSession = async (): Promise<{
messagesBefore: number;
messagesAfter: number;
workingContextMessagesAfter?: number;
compacted: boolean;
}> => {
if (!sessionManager) {
const manager = sessionManager;
const sourceSessionId = activeSessionId;
if (!manager || !sourceSessionId) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
}
const messages = await readCurrentMessages();
const messages = (await manager.readMessages(sourceSessionId)) ?? [];
const messagesBefore = messages.length;
if (messagesBefore === 0) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
}
const result = await compactInteractiveMessages({
config: input.config,
providerSettingsManager: input.providerSettingsManager,
sessionId: activeSessionId,
messages,
});
const sessionRecord = await manager.get(sourceSessionId);
if (sessionRecord?.status === "running") {
throw new Error(
"Cannot compact while the current turn is running. Wait for it to finish or abort it first.",
);
}
let result: Awaited<ReturnType<typeof compactInteractiveMessages>>;
const abortController = new AbortController();
manualCompactionAbortController = abortController;
try {
result = await compactInteractiveMessages({
config: input.config,
providerSettingsManager: input.providerSettingsManager,
sessionId: sourceSessionId,
messages,
abortSignal: abortController.signal,
});
} finally {
if (manualCompactionAbortController === abortController) {
manualCompactionAbortController = undefined;
}
}
if (!result.compacted) {
return {
messagesBefore,
@@ -496,10 +582,24 @@ export function createInteractiveSessionRuntime(input: {
compacted: false,
};
}
await restartWithMessages(result.messages);
if (!result.compactionState) {
return {
messagesBefore,
messagesAfter: messagesBefore,
compacted: false,
};
}
const updated = await manager.updateSessionCompactionState(
sourceSessionId,
result.compactionState,
);
if (!updated.updated) {
throw new Error("Compaction could not be saved. Try again.");
}
return {
messagesBefore,
messagesAfter: result.messages.length,
messagesAfter: result.canonicalMessages.length,
workingContextMessagesAfter: result.compactionState?.messages.length,
compacted: true,
};
};
@@ -586,6 +686,9 @@ export function createInteractiveSessionRuntime(input: {
}
abortRequested = true;
markAbortInProgress();
manualCompactionAbortController?.abort(
new Error("Interactive runtime abort requested"),
);
sessionManager
.abort(activeSessionId, new Error("Interactive runtime abort requested"))
.catch(() => {});
@@ -161,7 +161,7 @@ describe("formatCompactionStatus", () => {
messagesAfter: 300,
compacted: true,
}),
).toBe("Compacted context; message count stayed at 300.");
).toBe("Compacted context; message count stayed at 300 messages.");
});
it("reports empty sessions separately", () => {
+1
View File
@@ -79,6 +79,7 @@ export interface ResumedSessionResult {
export interface InteractiveCompactionResult {
messagesBefore: number;
messagesAfter: number;
workingContextMessagesAfter?: number;
compacted: boolean;
}
+10 -3
View File
@@ -1,5 +1,9 @@
import type { InteractiveCompactionResult } from "../types";
function formatMessageCount(count: number): string {
return `${count} ${count === 1 ? "message" : "messages"}`;
}
export function formatCompactionStatus(
result: InteractiveCompactionResult,
): string {
@@ -9,8 +13,11 @@ export function formatCompactionStatus(
if (!result.compacted) {
return "No compaction needed.";
}
if (result.messagesBefore === result.messagesAfter) {
return `Compacted context; message count stayed at ${result.messagesAfter}.`;
if (typeof result.workingContextMessagesAfter === "number") {
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; canonical history remains ${formatMessageCount(result.messagesAfter)}.`;
}
return `Compacted ${result.messagesBefore} messages to ${result.messagesAfter}.`;
if (result.messagesBefore === result.messagesAfter) {
return `Compacted context; message count stayed at ${formatMessageCount(result.messagesAfter)}.`;
}
return `Compacted ${formatMessageCount(result.messagesBefore)} to ${formatMessageCount(result.messagesAfter)}.`;
}
+37 -1
View File
@@ -1,9 +1,45 @@
import type { AgentEvent, TeamEvent } from "@cline/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { handleEvent, handleTeamEvent } from "./events";
import {
handleEvent,
handleTeamEvent,
resolveStatusNoticeLabel,
} from "./events";
import { setCurrentOutputMode } from "./output";
import type { Config } from "./types";
describe("resolveStatusNoticeLabel", () => {
it("maps compaction status reasons to stable labels", () => {
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "auto-compacting",
reason: "auto_compaction",
} as AgentEvent),
).toBe("auto-compacting");
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "manual",
reason: "manual_compaction",
} as AgentEvent),
).toBe("compacting");
expect(
resolveStatusNoticeLabel({
type: "notice",
noticeType: "status",
displayRole: "status",
message: "compaction-budget-adjusted",
reason: "compaction_budget_emergency",
} as AgentEvent),
).toBe("context budget adjusted");
});
});
describe("handleEvent text formatting", () => {
let output = "";
+7 -2
View File
@@ -27,8 +27,13 @@ export function resolveStatusNoticeLabel(
if (event.type !== "notice" || event.displayRole !== "status") {
return undefined;
}
if (event.reason === "auto_compaction") {
return "auto-compacting";
switch (event.reason) {
case "auto_compaction":
return "auto-compacting";
case "manual_compaction":
return "compacting";
case "compaction_budget_emergency":
return "context budget adjusted";
}
return event.message.trim() || undefined;
}
+6 -1
View File
@@ -323,15 +323,20 @@ Context compaction is owned by `core`.
- `@cline/agents` owns the generic turn-preparation seam:
- run normal lifecycle hooks
- allow hosts to rewrite message history or system prompt before the provider call
- allow hosts to project message history or system prompt before the provider call
- keep its canonical runtime transcript append-only when a projection is returned
- `@cline/core` owns compaction policy:
- inject a prepare-turn pipeline for root sessions
- choose between built-in strategies through a registry map
- persist the latest compacted working context as a session compaction artifact
- keep compaction logic out of the low-level agent message builder
Design implications:
- compaction is a context-pipeline concern owned by `core`
- canonical session history lives in the session messages artifact at full fidelity; compaction state lives separately in `${sessionId}.compaction.json`
- resume loads the canonical transcript for history/debugging and, when present, reuses the latest compaction state only after validating a hash of the canonical prefix covered by that state; valid state is projected by appending canonical messages written after the compaction boundary
- sessions that were already persisted with compacted messages before this model are best-effort only because the omitted original transcript is not recoverable from the compacted artifact
- `agents` stays focused on the stateless loop and provider/tool orchestration
- delegated/subagent flows should inherit compaction behavior through core session config, not through a separate agent-level compaction hook surface
@@ -902,7 +902,7 @@ describe("AgentRuntime", () => {
expect(model.requests).toHaveLength(0);
});
it("runs prepareTurn before beforeModel and persists rewritten messages", async () => {
it("runs prepareTurn before beforeModel without overwriting canonical messages", async () => {
const compactedMessage: AgentMessage = {
id: "msg_compacted",
role: "user",
@@ -955,7 +955,10 @@ describe("AgentRuntime", () => {
expect(prepareTurn).toHaveBeenCalledTimes(1);
expect(beforeModel).toHaveBeenCalledTimes(1);
expect(notices).toEqual(["auto-compacting"]);
expect(result.messages[0]).toEqual(compactedMessage);
expect(result.messages[0]).toMatchObject({
role: "user",
content: [{ type: "text", text: "large context" }],
});
expect(result.messages).toHaveLength(2);
expect(model.requests).toHaveLength(1);
});
+13 -7
View File
@@ -753,8 +753,15 @@ export class AgentRuntime {
};
if (this.state.iteration > 1) {
if (await this.consumePendingUserMessage()) {
request = { ...request, messages: cloneMessages(this.state.messages) };
const pendingUserMessage = await this.consumePendingUserMessage();
if (pendingUserMessage) {
request = {
...request,
messages: [
...request.messages,
...cloneMessages([pendingUserMessage]),
],
};
}
}
@@ -1007,7 +1014,6 @@ export class AgentRuntime {
let next = request;
if (result.messages) {
const preparedMessages = cloneMessages(result.messages);
this.state.messages = preparedMessages;
next = { ...next, messages: cloneMessages(preparedMessages) };
}
if (result.systemPrompt !== undefined) {
@@ -1016,14 +1022,14 @@ export class AgentRuntime {
return next;
}
private async consumePendingUserMessage(): Promise<boolean> {
private async consumePendingUserMessage(): Promise<AgentMessage | undefined> {
const consumePendingUserMessage = this.config.consumePendingUserMessage;
if (!consumePendingUserMessage) {
return false;
return undefined;
}
const pending = (await consumePendingUserMessage())?.trim();
if (!pending) {
return false;
return undefined;
}
const message = createMessage("user", [{ type: "text", text: pending }]);
this.state.messages.push(message);
@@ -1032,7 +1038,7 @@ export class AgentRuntime {
snapshot: this.snapshot(),
message,
});
return true;
return message;
}
private async updateUsage(usage: Partial<AgentUsage>): Promise<void> {
+12
View File
@@ -464,6 +464,18 @@ export class ClineCore {
*/
update: RuntimeHost["updateSession"] = (...args) =>
this.host.updateSession(...args);
/**
* Stores the compacted working-context state for an existing session.
*/
updateSessionCompactionState: RuntimeHost["updateSessionCompactionState"] = (
...args
) => this.host.updateSessionCompactionState(...args);
/**
* Reads the compacted working-context sidecar for a session, if one exists.
*/
readSessionCompactionState: RuntimeHost["readSessionCompactionState"] = (
...args
) => this.host.readSessionCompactionState(...args);
/**
* Reads message history for a session.
*
@@ -6,6 +6,10 @@ import type {
CoreCompactionSummarizerConfig,
} from "../../types/config";
import type { ProviderConfig } from "../../types/provider-settings";
import {
buildBudgetProjection,
type BudgetProjectionResult,
} from "./budget-projection";
import {
buildSummaryMessage,
buildSummaryRequest,
@@ -20,6 +24,43 @@ import {
serializeConversation,
} from "./compaction-shared";
const MIN_AGENTIC_SUMMARY_INPUT_TOKENS = 1_024;
function resolveProviderMaxInputTokens(
providerConfig: ProviderConfig,
): number | undefined {
const explicit = providerConfig.maxInputTokens;
if (typeof explicit === "number" && Number.isFinite(explicit)) {
return explicit;
}
const modelInfoLimit =
providerConfig.modelInfo?.maxInputTokens ??
providerConfig.modelInfo?.contextWindow;
if (typeof modelInfoLimit === "number" && Number.isFinite(modelInfoLimit)) {
return modelInfoLimit;
}
const knownModelInfo = providerConfig.knownModels?.[providerConfig.modelId];
const knownModelLimit =
knownModelInfo?.maxInputTokens ?? knownModelInfo?.contextWindow;
if (typeof knownModelLimit === "number" && Number.isFinite(knownModelLimit)) {
return knownModelLimit;
}
return undefined;
}
export function buildAgenticSummaryInputBudget(options: {
messages: CoreCompactionContext["messages"];
targetTokens: number;
estimateMessageTokens: EstimateMessageTokens;
}): BudgetProjectionResult {
return buildBudgetProjection({
messages: options.messages,
targetTokens: Math.max(1, options.targetTokens),
policyIntent: "agentic_summary",
estimateMessageTokens: options.estimateMessageTokens,
});
}
async function generateSummary(options: {
providerConfig: ProviderConfig;
request: string;
@@ -93,7 +134,78 @@ export async function runAgenticCompaction(options: {
}
const fileOps = extractFileOps(messagesToSummarize);
const conversationText = serializeConversation(newMessagesToFold);
const summarizerProviderConfig = resolveSummarizerConfig({
activeProviderConfig: options.providerConfig,
summarizer: options.summarizer,
});
const resolvedSummarizerInputLimit = resolveProviderMaxInputTokens(
summarizerProviderConfig,
);
const canUseActiveContextLimit = options.summarizer === undefined;
const activeCompactionInputLimit = Math.max(
options.context.maxInputTokens,
options.context.triggerTokens,
MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
);
if (
resolvedSummarizerInputLimit === undefined &&
!canUseActiveContextLimit
) {
options.logger?.log(
"Agentic compaction summarizer has no known input limit; using conservative summary budget",
{
severity: "warn",
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
fallbackInputLimit: MIN_AGENTIC_SUMMARY_INPUT_TOKENS,
},
);
}
const summarizerInputLimit =
resolvedSummarizerInputLimit ??
(canUseActiveContextLimit
? activeCompactionInputLimit
: MIN_AGENTIC_SUMMARY_INPUT_TOKENS);
const summaryRequestOverheadTokens = estimateTokens(
buildSummaryRequest({
previousSummary,
conversationText: "",
fileOps,
}).length,
);
const availableSummaryInputTokens =
summarizerInputLimit - summaryRequestOverheadTokens;
if (availableSummaryInputTokens <= 0) {
options.logger?.debug("Skipped agentic compaction: summarizer budget exhausted", {
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
summarizerInputLimit,
summaryRequestOverheadTokens,
});
return undefined;
}
const summaryInputBudget = buildAgenticSummaryInputBudget({
messages: newMessagesToFold,
targetTokens: availableSummaryInputTokens,
estimateMessageTokens: options.estimateMessageTokens,
});
if (summaryInputBudget.status === "failed") {
options.logger?.log(
"Skipped agentic compaction: summary input budget failed",
{
severity: "warn",
budgetWarnings: summaryInputBudget.warnings.map(
(warning) => warning.code,
),
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
targetTokens: availableSummaryInputTokens,
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
},
);
return undefined;
}
const conversationText = serializeConversation(summaryInputBudget.messages);
const summaryRequest = buildSummaryRequest({
previousSummary,
conversationText,
@@ -108,14 +220,20 @@ export async function runAgenticCompaction(options: {
summaryRequestChars: summaryRequest.length,
summaryRequestEstimatedTokens: estimateTokens(summaryRequest.length),
newMessagesJsonChars: safeJsonSize(newMessagesToFold),
summaryInputEstimatedTokens: summaryInputBudget.estimatedTokens,
summaryInputActions: summaryInputBudget.actions.length,
summaryInputWarnings: summaryInputBudget.warnings.map(
(warning) => warning.code,
),
summaryRequestOverheadTokens,
summarizerProviderId: summarizerProviderConfig.providerId,
summarizerModelId: summarizerProviderConfig.modelId,
summarizerInputLimit,
maxInputTokens: options.context.maxInputTokens,
triggerTokens: options.context.triggerTokens,
});
const rawSummary = await generateSummary({
providerConfig: resolveSummarizerConfig({
activeProviderConfig: options.providerConfig,
summarizer: options.summarizer,
}),
providerConfig: summarizerProviderConfig,
request: summaryRequest,
logger: options.logger,
});
@@ -149,5 +267,13 @@ export async function runAgenticCompaction(options: {
tokensAfter,
maxInputTokens: options.context.maxInputTokens,
});
return { messages: resultMessages };
return {
messages: resultMessages,
budget: {
policyIntent: "agentic_summary",
actionCount: summaryInputBudget.actions.length,
warningCount: summaryInputBudget.warnings.length,
liveTailHandling: summaryInputBudget.liveTailHandling,
},
};
}
@@ -3,6 +3,7 @@ import type {
CoreCompactionContext,
CoreCompactionResult,
} from "../../types/config";
import { buildBudgetProjection } from "./budget-projection";
import {
type EstimateMessageTokens,
findFirstUserMessageIndex,
@@ -390,7 +391,26 @@ export function runBasicCompaction(options: {
...candidates.map((candidate) => candidate.message),
...protectedTail,
];
if (!haveMessagesChanged(options.context.messages, nextMessages)) {
const budgeted = buildBudgetProjection({
messages: nextMessages,
targetTokens,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: options.estimateMessageTokens,
});
// This final projection owns the hard output budget. Unlike the earlier
// basic candidate passes, it may drop the original first-user message when
// preserving the latest typed prompt and coherent tool closures requires it.
if (budgeted.status === "failed") {
options.logger?.debug("Basic compaction returned best-effort projection", {
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
projectedTokens: budgeted.estimatedTokens,
targetTokens,
maxInputTokens: options.context.maxInputTokens,
});
}
const resultMessages = budgeted.messages;
if (!haveMessagesChanged(options.context.messages, resultMessages)) {
return undefined;
}
@@ -402,18 +422,29 @@ export function runBasicCompaction(options: {
options.estimateMessageTokens,
);
const afterTokens = getTotalTokens(
nextMessages,
resultMessages,
options.estimateMessageTokens,
);
options.logger?.debug("Performed basic compaction", {
messagesBefore: options.context.messages.length,
messagesAfter: nextMessages.length,
messagesRemoved: options.context.messages.length - nextMessages.length,
messagesAfter: resultMessages.length,
messagesRemoved: options.context.messages.length - resultMessages.length,
tokensBefore: beforeTokens,
tokensAfter: afterTokens,
budgetStatus: budgeted.status,
budgetActions: budgeted.actions.length,
budgetWarnings: budgeted.warnings.map((warning) => warning.code),
targetTokens,
maxInputTokens: options.context.maxInputTokens,
});
return { messages: nextMessages };
return {
messages: resultMessages,
budget: {
policyIntent: "basic_compaction_projection",
actionCount: budgeted.actions.length,
warningCount: budgeted.warnings.length,
liveTailHandling: budgeted.liveTailHandling,
},
};
}
@@ -0,0 +1,17 @@
export {
buildBudgetProjection,
findLatestTypedUserMessageIndex,
} from "./project";
export type {
BlockBudgetClass,
BudgetAction,
BudgetActionKind,
BudgetActionReason,
BudgetPath,
BudgetPolicyIntent,
BudgetProjectionOptions,
BudgetProjectionResult,
BudgetProjectionWarning,
ContentBlockBudgetClassification,
LiveTailHandling,
} from "./types";
@@ -0,0 +1,398 @@
import type { MessageWithMetadata } from "@cline/shared";
import { describe, expect, it } from "vitest";
import {
buildBudgetProjection,
findLatestTypedUserMessageIndex,
} from "./project";
const estimateChars = (message: MessageWithMetadata) =>
JSON.stringify(message).length;
describe("buildBudgetProjection", () => {
it("fails explicitly for impossible budgets", () => {
const result = buildBudgetProjection({
messages: [{ role: "user", content: "keep me" }],
targetTokens: 0,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
expect(result.status).toBe("failed");
expect(result.messages).toHaveLength(1);
expect(result.warnings[0]?.code).toBe("budget_impossible");
});
it("drops unsafe image and redacted thinking blocks instead of truncating them", () => {
const result = buildBudgetProjection({
messages: [
{
role: "assistant",
content: [
{ type: "text", text: "old context" },
{
type: "redacted_thinking",
data: "x".repeat(500),
},
],
},
{
role: "assistant",
content: [
{
type: "image",
data: "y".repeat(500),
mediaType: "image/png",
},
],
},
{ role: "user", content: "latest task" },
],
targetTokens: 150,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).not.toContain("redacted_thinking");
expect(serialized).not.toContain("image/png");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_block",
reason: "unsafe_to_truncate",
}),
]),
);
expect(result.liveTailHandling).toBe("included_degraded");
});
it("keeps unsafe blocks when input is already under budget", () => {
const result = buildBudgetProjection({
messages: [
{
role: "user",
content: [
{ type: "text", text: "look at this" },
{
type: "image",
data: "small-image",
mediaType: "image/png",
},
],
},
],
targetTokens: 1_000,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
expect(result.status).toBe("ok");
expect(result.actions).toEqual([]);
expect(result.liveTailHandling).toBe("included_verbatim");
expect(JSON.stringify(result.messages)).toContain("small-image");
});
it("preserves unsafe blocks in the latest typed user message", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task " + "x".repeat(500) },
{
role: "user",
content: [
{ type: "text", text: "what is in this image?" },
{
type: "image",
data: "live-image",
mediaType: "image/png",
},
],
},
],
targetTokens: 120,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: estimateChars,
});
expect(JSON.stringify(result.messages)).toContain("live-image");
expect(result.actions).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "dropped_block" }),
]),
);
});
it("protects latest typed user after thinking-only messages are pruned", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task" },
{
role: "assistant",
content: [{ type: "thinking", thinking: "discard me" }],
},
{
role: "user",
content: [
{ type: "text", text: "what is in this image?" },
{
type: "image",
data: "live-image",
mediaType: "image/png",
},
],
},
],
targetTokens: 1_000,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("live-image");
expect(serialized).not.toContain("discard me");
});
it("keeps tool-use and tool-result pairs coherent when dropping history", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "original task" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool_1",
name: "read_files",
input: { file_paths: ["/tmp/a.ts"] },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "read_files",
content: "x".repeat(1000),
},
],
},
{ role: "user", content: "latest task" },
],
targetTokens: 140,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).not.toContain("tool_1");
expect(serialized).toContain("latest task");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({ reason: "tool_pair_boundary" }),
]),
);
});
it("records budget action paths against original message indexes", () => {
const result = buildBudgetProjection({
messages: [
{
role: "assistant",
content: [{ type: "image", data: "x", mediaType: "image/png" }],
},
{ role: "user", content: "old task " + "x".repeat(500) },
{ role: "assistant", content: "old answer " + "y".repeat(500) },
{ role: "user", content: "latest task" },
],
targetTokens: 80,
policyIntent: "basic_compaction_projection",
estimateMessageTokens: estimateChars,
});
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_message",
path: expect.objectContaining({ messageIndex: 1 }),
}),
expect.objectContaining({
kind: "dropped_message",
path: expect.objectContaining({ messageIndex: 2 }),
}),
]),
);
});
it("detects the latest typed user message when tool results follow it", () => {
const messages: MessageWithMetadata[] = [
{ role: "user", content: "old task" },
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "tool_use", id: "tool_1", name: "read", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "read",
content: "result",
},
],
},
];
expect(findLatestTypedUserMessageIndex(messages)).toBe(1);
});
it("preserves the latest typed prompt under pressure", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "old task " + "x".repeat(500) },
{ role: "user", content: "latest typed prompt" },
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_1",
name: "read",
content: "result " + "y".repeat(500),
},
],
},
],
targetTokens: 120,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
expect(JSON.stringify(result.messages)).toContain("latest typed prompt");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({ reason: "protected_live_tail" }),
]),
);
});
it("does not preserve later text or file blocks after tool-result budget is exhausted", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "tool_use", id: "tool_live", name: "read", input: {} },
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_live",
name: "read",
content: [
{ type: "text", text: "a".repeat(200) },
{ type: "file", path: "/tmp/huge.txt", content: "b".repeat(1_000) },
],
},
],
},
],
targetTokens: 260,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("latest typed prompt");
expect(serialized).not.toContain("b".repeat(100));
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "truncated_text",
reason: "over_budget",
}),
]),
);
});
it("drops thinking blocks instead of mutating provider-native reasoning", () => {
const result = buildBudgetProjection({
messages: [
{ role: "user", content: "latest typed prompt" },
{
role: "assistant",
content: [
{ type: "text", text: "a".repeat(1_000) },
{ type: "thinking", thinking: "b".repeat(1_000) },
],
},
],
targetTokens: 190,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const assistant = result.messages.find(
(message) => message.role === "assistant",
);
expect(JSON.stringify(assistant)).not.toContain("b".repeat(100));
expect(JSON.stringify(assistant)).not.toContain("\"thinking\"");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_block",
reason: "unsafe_to_truncate",
}),
]),
);
});
it("drops nested unsafe tool-result blocks outside the protected tail", () => {
const result = buildBudgetProjection({
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool_old",
name: "read",
content: [
{ type: "text", text: "old output" },
{
type: "image",
data: "old-image-data",
mediaType: "image/png",
},
],
},
],
},
{ role: "user", content: "latest typed prompt" },
],
targetTokens: 1_000,
policyIntent: "agentic_summary",
estimateMessageTokens: estimateChars,
});
const serialized = JSON.stringify(result.messages);
expect(serialized).toContain("old output");
expect(serialized).not.toContain("old-image-data");
expect(result.actions).toEqual(
expect.arrayContaining([
expect.objectContaining({
kind: "dropped_block",
reason: "unsafe_to_truncate",
}),
]),
);
});
});
@@ -0,0 +1,598 @@
import type {
ContentBlock,
MessageWithMetadata,
ToolResultContent,
} from "@cline/shared";
import type {
BudgetAction,
BudgetProjectionOptions,
BudgetProjectionResult,
BudgetProjectionWarning,
BudgetPolicyIntent,
} from "./types";
type EstimateMessageTokens = (message: MessageWithMetadata) => number;
interface ProjectionPolicy {
protectLatestTypedUser: boolean;
protectLiveTailFromDrop: boolean;
dropUnsafeOutsideLiveTail: boolean;
dropThinkingBlocks: boolean;
}
function resolveProjectionPolicy(
intent: BudgetPolicyIntent,
): ProjectionPolicy {
switch (intent) {
case "agentic_summary":
case "basic_compaction_projection":
return {
protectLatestTypedUser: true,
protectLiveTailFromDrop: true,
dropUnsafeOutsideLiveTail: true,
dropThinkingBlocks: true,
};
case "normal_provider_request":
return {
protectLatestTypedUser: true,
protectLiveTailFromDrop: true,
dropUnsafeOutsideLiveTail: false,
dropThinkingBlocks: false,
};
}
}
function cloneMessages(messages: MessageWithMetadata[]): MessageWithMetadata[] {
return messages.map((message) => ({
...message,
content: Array.isArray(message.content)
? message.content.map((block) => ({ ...block }) as ContentBlock)
: message.content,
...(message.metadata ? { metadata: { ...message.metadata } } : {}),
}));
}
function safeJsonSize(value: unknown): number {
try {
return JSON.stringify(value).length;
} catch {
return String(value).length;
}
}
function totalTokens(
messages: MessageWithMetadata[],
estimateMessageTokens: EstimateMessageTokens,
): number {
return messages.reduce(
(total, message) => total + estimateMessageTokens(message),
0,
);
}
function isToolResultOnlyUserMessage(message: MessageWithMetadata): boolean {
return (
message.role === "user" &&
Array.isArray(message.content) &&
message.content.length > 0 &&
message.content.every((block) => block.type === "tool_result")
);
}
export function findLatestTypedUserMessageIndex(
messages: MessageWithMetadata[],
): number {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role === "user" && !isToolResultOnlyUserMessage(message)) {
return index;
}
}
return -1;
}
function collectToolIds(message: MessageWithMetadata): Set<string> {
const ids = new Set<string>();
if (!Array.isArray(message.content)) {
return ids;
}
for (const block of message.content) {
if (block.type === "tool_use") {
ids.add(block.id);
} else if (block.type === "tool_result") {
ids.add(block.tool_use_id);
}
}
return ids;
}
function buildToolPairIndex(
messages: MessageWithMetadata[],
): Map<string, Set<number>> {
const index = new Map<string, Set<number>>();
for (let messageIndex = 0; messageIndex < messages.length; messageIndex += 1) {
for (const id of collectToolIds(messages[messageIndex])) {
const existing = index.get(id);
if (existing) {
existing.add(messageIndex);
} else {
index.set(id, new Set([messageIndex]));
}
}
}
return index;
}
function collectMessageClosure(
messages: MessageWithMetadata[],
startIndex: number,
): Set<number> {
const pairIndex = buildToolPairIndex(messages);
const removal = new Set<number>();
const queue = [startIndex];
while (queue.length > 0) {
const index = queue.shift();
if (index === undefined || removal.has(index)) {
continue;
}
removal.add(index);
for (const id of collectToolIds(messages[index])) {
for (const linked of pairIndex.get(id) ?? []) {
if (!removal.has(linked)) {
queue.push(linked);
}
}
}
}
return removal;
}
function isUnsafeBlock(block: ContentBlock): boolean {
return block.type === "image" || block.type === "redacted_thinking";
}
function isNestedUnsafeToolResultBlock(
block: Extract<ToolResultContent["content"], unknown[]>[number],
): boolean {
return block.type === "image";
}
function shouldDropWholeBlock(
block: ContentBlock,
policy: ProjectionPolicy,
isProtected: boolean,
): boolean {
if (policy.dropThinkingBlocks && block.type === "thinking") {
return true;
}
return policy.dropUnsafeOutsideLiveTail && !isProtected && isUnsafeBlock(block);
}
function pruneEmptyMessages(
messages: MessageWithMetadata[],
originalIndexes: number[],
actions: BudgetAction[],
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
const next: MessageWithMetadata[] = [];
const nextOriginalIndexes: number[] = [];
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index];
if (Array.isArray(message.content) && message.content.length === 0) {
actions.push({
kind: "dropped_message",
path: { messageIndex: originalIndexes[index] },
reason: "over_budget",
originalSize: safeJsonSize(message),
finalSize: 0,
});
continue;
}
next.push(message);
nextOriginalIndexes.push(originalIndexes[index]);
}
return { messages: next, originalIndexes: nextOriginalIndexes };
}
function dropUnsafeBlocks(
messages: MessageWithMetadata[],
originalIndexes: number[],
actions: BudgetAction[],
protectedStartIndex: number,
policy: ProjectionPolicy,
): MessageWithMetadata[] {
return messages.map((message, messageIndex) => {
if (!Array.isArray(message.content)) {
return message;
}
let changed = false;
const protectedBlock =
protectedStartIndex >= 0 && messageIndex >= protectedStartIndex;
const content = message.content.flatMap((block, blockIndex) => {
if (shouldDropWholeBlock(block, policy, protectedBlock)) {
changed = true;
actions.push({
kind: "dropped_block",
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
reason: "unsafe_to_truncate",
originalSize: safeJsonSize(block),
finalSize: 0,
});
return [];
}
if (block.type === "tool_result" && Array.isArray(block.content)) {
const nestedContent = block.content.filter((nestedBlock) => {
if (
policy.dropUnsafeOutsideLiveTail &&
!protectedBlock &&
isNestedUnsafeToolResultBlock(nestedBlock)
) {
return false;
}
return true;
});
if (nestedContent.length !== block.content.length) {
changed = true;
const nextBlock = { ...block, content: nestedContent };
actions.push({
kind: "dropped_block",
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
reason: "unsafe_to_truncate",
originalSize: safeJsonSize(block),
finalSize: safeJsonSize(nextBlock),
});
return [nextBlock];
}
}
if (!isUnsafeBlock(block)) {
return [block];
}
if (protectedBlock) {
return [block];
}
return [block];
});
return changed ? { ...message, content } : message;
});
}
function dropThinkingBlocks(
messages: MessageWithMetadata[],
originalIndexes: number[],
actions: BudgetAction[],
): MessageWithMetadata[] {
return messages.map((message, messageIndex) => {
if (!Array.isArray(message.content)) {
return message;
}
let changed = false;
const content = message.content.filter((block, blockIndex) => {
if (block.type !== "thinking") {
return true;
}
changed = true;
actions.push({
kind: "dropped_block",
path: { messageIndex: originalIndexes[messageIndex], blockIndex },
reason: "unsafe_to_truncate",
originalSize: safeJsonSize(block),
finalSize: 0,
});
return false;
});
return changed ? { ...message, content } : message;
});
}
function truncateText(text: string, maxChars: number): string {
if (maxChars <= 0) {
return "";
}
if (text.length <= maxChars) {
return text;
}
if (maxChars <= 16) {
return text.slice(0, Math.max(1, maxChars));
}
const estimateMarker = `\n...[truncated ${text.length - maxChars} chars]`;
const keep = Math.max(1, maxChars - estimateMarker.length);
const marker = `\n...[truncated ${text.length - keep} chars]`;
return `${text.slice(0, keep)}${marker}`;
}
function truncateToolResultContent(
content: ToolResultContent["content"],
maxChars: number,
): ToolResultContent["content"] {
if (typeof content === "string") {
return truncateText(content, maxChars);
}
let remaining = maxChars;
return content.map((block) => {
if (remaining <= 0) {
if (block.type === "text") {
return { ...block, text: "" };
}
if (block.type === "file") {
return { ...block, content: "" };
}
return block;
}
if (block.type === "text") {
const text = truncateText(block.text, remaining);
remaining -= text.length;
return { ...block, text };
}
if (block.type === "file") {
const content = truncateText(block.content, remaining);
remaining -= content.length;
return { ...block, content };
}
return block;
});
}
function toolResultTextLength(content: ToolResultContent["content"]): number {
if (typeof content === "string") {
return content.length;
}
return content.reduce((total, block) => {
if (block.type === "text") {
return total + block.text.length;
}
if (block.type === "file") {
return total + block.content.length;
}
return total;
}, 0);
}
function truncateMessageText(
message: MessageWithMetadata,
maxChars: number,
): MessageWithMetadata {
if (typeof message.content === "string") {
return { ...message, content: truncateText(message.content, maxChars) };
}
let remaining = maxChars;
return {
...message,
content: message.content.map((block) => {
if (remaining <= 0) {
if (block.type === "text") {
return { ...block, text: "" };
}
if (block.type === "file") {
return { ...block, content: "" };
}
return block;
}
if (block.type === "text") {
const text = truncateText(block.text, remaining);
remaining -= text.length;
return { ...block, text };
}
if (block.type === "file") {
const content = truncateText(block.content, remaining);
remaining -= content.length;
return { ...block, content };
}
if (block.type === "tool_result") {
const content = truncateToolResultContent(block.content, remaining);
remaining -= toolResultTextLength(content);
return { ...block, content };
}
return block;
}),
};
}
function hasTruncatableText(message: MessageWithMetadata): boolean {
if (typeof message.content === "string") {
return message.content.length > 0;
}
return message.content.some(
(block) =>
block.type === "text" ||
block.type === "file" ||
block.type === "tool_result",
);
}
function removeMessagesAt(
messages: MessageWithMetadata[],
originalIndexes: number[],
removal: Set<number>,
): { messages: MessageWithMetadata[]; originalIndexes: number[] } {
return {
messages: messages.filter((_, index) => !removal.has(index)),
originalIndexes: originalIndexes.filter((_, index) => !removal.has(index)),
};
}
function closureTouchesProtectedTail(
closure: Set<number>,
protectedStartIndex: number,
): boolean {
if (protectedStartIndex < 0) {
return false;
}
for (const removalIndex of closure) {
if (removalIndex >= protectedStartIndex) {
return true;
}
}
return false;
}
export function buildBudgetProjection(
options: BudgetProjectionOptions,
): BudgetProjectionResult {
const actions: BudgetAction[] = [];
const warnings: BudgetProjectionWarning[] = [];
const policy = resolveProjectionPolicy(options.policyIntent);
if (options.targetTokens <= 0) {
return {
status: "failed",
messages: cloneMessages(options.messages),
actions,
liveTailHandling: "preserved_out_of_band",
estimatedTokens: totalTokens(
options.messages,
options.estimateMessageTokens,
),
warnings: [
{
code: "budget_impossible",
message: "Target budget must be greater than zero.",
},
],
};
}
let messages = cloneMessages(options.messages);
let originalIndexes = messages.map((_, index) => index);
if (policy.dropThinkingBlocks) {
const prunedThinking = pruneEmptyMessages(
dropThinkingBlocks(messages, originalIndexes, actions),
originalIndexes,
actions,
);
messages = prunedThinking.messages;
originalIndexes = prunedThinking.originalIndexes;
}
const protectedStartIndex = policy.protectLatestTypedUser
? findLatestTypedUserMessageIndex(messages)
: -1;
if (policy.dropUnsafeOutsideLiveTail) {
const prunedUnsafe = pruneEmptyMessages(
dropUnsafeBlocks(
messages,
originalIndexes,
actions,
protectedStartIndex,
policy,
),
originalIndexes,
actions,
);
messages = prunedUnsafe.messages;
originalIndexes = prunedUnsafe.originalIndexes;
}
let estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
if (estimatedTokens <= options.targetTokens) {
return {
status: "ok",
messages,
actions,
liveTailHandling:
actions.length > 0 ? "included_degraded" : "included_verbatim",
estimatedTokens,
warnings,
};
}
for (
let index = messages.length - 1;
index >= 0 && estimatedTokens > options.targetTokens;
index -= 1
) {
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
if (index === latestTypedUserIndex) {
continue;
}
if (!hasTruncatableText(messages[index])) {
continue;
}
const originalSize = safeJsonSize(messages[index]);
const charsPerToken = Math.max(
1,
originalSize /
Math.max(1, options.estimateMessageTokens(messages[index])),
);
const targetChars = Math.max(
16,
Math.floor(
(options.targetTokens * charsPerToken) /
Math.max(1, messages.length),
),
);
messages[index] = truncateMessageText(messages[index], targetChars);
actions.push({
kind: "truncated_text",
path: { messageIndex: originalIndexes[index] },
reason: "over_budget",
originalSize,
finalSize: safeJsonSize(messages[index]),
});
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
}
for (
let index = 0;
index < messages.length && estimatedTokens > options.targetTokens;
) {
const latestTypedUserIndex = findLatestTypedUserMessageIndex(messages);
const protectedStartIndex = policy.protectLiveTailFromDrop
? latestTypedUserIndex
: -1;
if (index === latestTypedUserIndex) {
actions.push({
kind: "preserved",
path: { messageIndex: originalIndexes[index] },
reason: "protected_live_tail",
originalSize: safeJsonSize(messages[index]),
finalSize: safeJsonSize(messages[index]),
});
index += 1;
continue;
}
const closure = collectMessageClosure(messages, index);
if (closureTouchesProtectedTail(closure, protectedStartIndex)) {
index += 1;
continue;
}
for (const removalIndex of closure) {
actions.push({
kind: "dropped_message",
path: { messageIndex: originalIndexes[removalIndex] },
reason:
closure.size > 1 || collectToolIds(messages[removalIndex]).size > 0
? "tool_pair_boundary"
: "over_budget",
originalSize: safeJsonSize(messages[removalIndex]),
finalSize: 0,
});
}
const removed = removeMessagesAt(messages, originalIndexes, closure);
messages = removed.messages;
originalIndexes = removed.originalIndexes;
estimatedTokens = totalTokens(messages, options.estimateMessageTokens);
}
if (estimatedTokens > options.targetTokens) {
warnings.push({
code: "budget_unachievable_with_protections",
message:
"Projection could not reach budget without violating protected content.",
});
return {
status: "failed",
messages,
actions,
liveTailHandling: "included_degraded",
estimatedTokens,
warnings,
};
}
return {
status: "ok",
messages,
actions,
liveTailHandling:
actions.length > 0 ? "included_degraded" : "included_verbatim",
estimatedTokens,
warnings,
};
}
@@ -0,0 +1,98 @@
import type { ContentBlock, MessageWithMetadata } from "@cline/shared";
export type BudgetPolicyIntent =
| "agentic_summary"
| "basic_compaction_projection"
| "normal_provider_request";
export type BudgetActionKind =
| "truncated_text"
| "dropped_block"
| "dropped_message"
| "preserved";
export type BudgetActionReason =
| "over_budget"
| "unsafe_to_truncate"
| "tool_pair_boundary"
| "protected_live_tail";
export type LiveTailHandling =
| "included_verbatim"
| "included_degraded"
| "summarized_as_context"
| "omitted_with_warning"
| "preserved_out_of_band";
export type BlockBudgetClass =
| "text"
| "thinking"
| "tool_use"
| "tool_result"
| "unsafe_binary"
| "unsafe_encrypted"
| "opaque";
export interface BudgetPath {
messageIndex: number;
blockIndex?: number;
}
interface BaseBudgetAction {
path: BudgetPath;
originalSize: number;
finalSize: number;
}
export type BudgetMutationAction =
| (BaseBudgetAction & {
kind: "truncated_text";
reason: Extract<BudgetActionReason, "over_budget">;
})
| (BaseBudgetAction & {
kind: "dropped_block" | "dropped_message";
reason: Exclude<BudgetActionReason, "protected_live_tail">;
});
export interface BudgetPreservedAction extends BaseBudgetAction {
kind: "preserved";
reason: Extract<
BudgetActionReason,
"protected_live_tail" | "tool_pair_boundary"
>;
}
export type BudgetAction = BudgetMutationAction | BudgetPreservedAction;
export type BudgetProjectionWarningCode =
| "budget_impossible"
| "budget_unachievable_with_protections";
export interface BudgetProjectionWarning {
code: BudgetProjectionWarningCode;
message: string;
path?: BudgetPath;
}
export interface BudgetProjectionOptions {
messages: MessageWithMetadata[];
targetTokens: number;
policyIntent: BudgetPolicyIntent;
estimateMessageTokens: (message: MessageWithMetadata) => number;
}
export interface BudgetProjectionResult {
status: "ok" | "failed";
messages: MessageWithMetadata[];
actions: BudgetAction[];
liveTailHandling: LiveTailHandling;
estimatedTokens: number;
warnings: BudgetProjectionWarning[];
}
export interface ContentBlockBudgetClassification {
block: ContentBlock;
budgetClass: BlockBudgetClass;
canStringTruncate: boolean;
canDropWholeBlock: boolean;
}
@@ -480,6 +480,7 @@ export function resolveSummarizerConfig(options: {
apiKey: summarizer.apiKey ?? baseProviderConfig?.apiKey,
baseUrl: summarizer.baseUrl ?? baseProviderConfig?.baseUrl,
headers: summarizer.headers ?? baseProviderConfig?.headers,
modelInfo: summarizer.modelInfo ?? baseProviderConfig?.modelInfo,
knownModels: summarizer.knownModels ?? baseProviderConfig?.knownModels,
maxOutputTokens:
summarizer.maxOutputTokens ?? DEFAULT_SUMMARY_MAX_OUTPUT_TOKENS,
@@ -1,10 +1,12 @@
import type * as LlmsProviders from "@cline/llms";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CoreCompactionContext } from "../../types/config";
import { buildAgenticSummaryInputBudget } from "./agentic-compaction";
import { runBasicCompaction } from "./basic-compaction";
import { createContextCompactionPrepareTurn } from "./compaction";
import {
createTokenEstimator,
estimateTokens,
resolveSummarizerConfig,
serializeMessage,
TOOL_RESULT_CHAR_LIMIT,
@@ -359,13 +361,28 @@ describe("createContextCompactionPrepareTurn", () => {
const compacted = runForcedBasicCompaction(messages, 1);
expect(compacted).toEqual([
{ role: "user", content: "Old request" },
{ role: "user", content: "Read the latest file" },
assistantToolUseMessage("tool-a"),
toolResultMessage("tool-a", "latest result"),
]);
});
it("budgets the complete basic compaction output including the latest turn", () => {
const messages: LlmsProviders.Message[] = [
{ role: "user", content: "original task" },
{ role: "assistant", content: "old assistant " + "x".repeat(10_000) },
{ role: "user", content: "latest typed prompt" },
assistantToolUseMessage("tool-live"),
toolResultMessage("tool-live", "live result " + "y".repeat(10_000)),
];
const compacted = runForcedBasicCompaction(messages, 700);
expect(totalJsonTokens(compacted)).toBeLessThanOrEqual(700);
expect(JSON.stringify(compacted)).toContain("latest typed prompt");
expectNoOrphanedToolPairs(compacted);
});
it("does not compact a single typed user message", () => {
const messages: LlmsProviders.Message[] = [
{ role: "user", content: "Only current request" },
@@ -396,6 +413,23 @@ describe("createContextCompactionPrepareTurn", () => {
expect(anthropicConfig.maxOutputTokens).toBe(1_024);
});
it("preserves summarizer modelInfo without a nested providerConfig", () => {
const resolved = resolveSummarizerConfig({
activeProviderConfig: {
providerId: "anthropic",
modelId: "primary-model",
modelInfo: { id: "primary-model", maxInputTokens: 100_000 },
} as LlmsProviders.ProviderConfig,
summarizer: {
providerId: "openai",
modelId: "small-summary",
modelInfo: { id: "small-summary", maxInputTokens: 600 },
},
});
expect(resolved.modelInfo?.maxInputTokens).toBe(600);
});
it("summarizes older messages and keeps recent messages", async () => {
const emitStatusNotice = vi.fn();
createHandlerMock.mockReturnValue({
@@ -648,6 +682,43 @@ describe("createContextCompactionPrepareTurn", () => {
expect(summarizerPrompt.length).toBeLessThan(longToolOutput.length);
});
it("budgets agentic summary input before serialization", () => {
const result = buildAgenticSummaryInputBudget({
messages: [
{ role: "user", content: "Run a large command" },
{
role: "assistant",
content: [
{
type: "tool_use",
id: "tool-large",
name: "execute_command",
input: { command: "print-large-output" },
},
],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "tool-large",
name: "execute_command",
content: "x".repeat(50_000),
},
],
},
{ role: "user", content: "Latest typed prompt" },
],
targetTokens: 400,
estimateMessageTokens: estimateJsonTokens,
});
expect(result.estimatedTokens).toBeLessThanOrEqual(400);
expect(JSON.stringify(result.messages)).toContain("Latest typed prompt");
expect(result.actions.length).toBeGreaterThan(0);
});
it("never lands the agentic cut in the middle of a tool pair", async () => {
// Repro for the "No tool call found for function call output" provider
// error: findCutIndex used to walk back by token budget and could land
@@ -822,6 +893,79 @@ describe("createContextCompactionPrepareTurn", () => {
);
});
it("budgets agentic summary input against the configured summarizer context window", async () => {
let summaryRequest = "";
createHandlerMock.mockReturnValue({
createMessage: vi.fn((_system: string, messages: LlmsProviders.Message[]) => {
summaryRequest = String(messages[0]?.content ?? "");
return streamChunks([
{ type: "text", id: "summary-small", text: "## Goal\nSummarized" },
{ type: "done", id: "summary-small", success: true },
]);
}),
});
const summarizerLimit = 600;
const oversizedAssistant = "assistant details ".repeat(5_000);
const prepareTurn = createContextCompactionPrepareTurn({
providerId: "anthropic",
modelId: "primary-model",
providerConfig: {
providerId: "anthropic",
modelId: "primary-model",
modelInfo: { id: "primary-model", maxInputTokens: 10_000 },
} as LlmsProviders.ProviderConfig,
compaction: {
enabled: true,
strategy: "agentic",
preserveRecentTokens: 1,
reserveTokens: 5,
summarizer: {
providerId: "openai",
modelId: "small-summary",
modelInfo: {
id: "small-summary",
maxInputTokens: summarizerLimit,
},
},
},
logger: undefined,
});
await prepareTurn?.({
agentId: "agent-1",
conversationId: "conv-1",
parentAgentId: null,
iteration: 1,
abortSignal: new AbortController().signal,
systemPrompt: "You are helpful.",
tools: [],
messages: [
{ role: "user", content: "Old request" },
{ role: "assistant", content: oversizedAssistant },
{ role: "user", content: "Latest turn" },
{ role: "assistant", content: "Latest answer" },
],
apiMessages: [
{ role: "user", content: "Old request" },
{ role: "assistant", content: oversizedAssistant },
{ role: "user", content: "Latest turn" },
{ role: "assistant", content: "Latest answer" },
],
model: {
id: "primary-model",
provider: "anthropic",
info: { id: "primary-model", maxInputTokens: 10_000 },
},
});
expect(createHandlerMock).toHaveBeenCalledTimes(1);
expect(estimateTokens(summaryRequest.length)).toBeLessThanOrEqual(
summarizerLimit,
);
expect(summaryRequest).not.toContain(oversizedAssistant);
});
it("uses basic compaction without calling the summarizer", async () => {
const emitStatusNotice = vi.fn();
const prepareTurn = createContextCompactionPrepareTurn({
@@ -1311,7 +1455,7 @@ describe("createContextCompactionPrepareTurn", () => {
expect(result?.messages.length).toBeLessThan(4);
});
it("preserves user image blocks during basic compaction sanitization", () => {
it("drops old user image blocks during basic compaction sanitization", () => {
const messages: LlmsProviders.Message[] = [
{
role: "user",
@@ -1347,7 +1491,6 @@ describe("createContextCompactionPrepareTurn", () => {
expect(result?.messages).toBeDefined();
expect(result?.messages[0]?.content).toEqual([
{ type: "text", text: "Older user turn" },
{ type: "image", data: "abc", mediaType: "image/png" },
]);
expect(result?.messages.at(-1)).toEqual({
role: "user",
@@ -1,8 +1,14 @@
import {
captureCompactionBudgetEmergency,
captureCompactionExecuted,
captureCompactionSkipped,
type TelemetryCompactionStrategy,
} from "../../services/telemetry/core-events";
import {
createSessionCompactionState,
projectSessionCompactionState,
type SessionCompactionState,
} from "../../session/models/session-compaction";
import type {
CoreCompactionConfig,
CoreCompactionContext,
@@ -43,6 +49,10 @@ export interface ContextPipelinePrepareTurnResult {
systemPrompt?: string;
}
export type ContextPipelinePrepareTurn = (
context: ContextPipelinePrepareTurnInput,
) => Promise<ContextPipelinePrepareTurnResult | undefined>;
type EstimateMessageTokens = ReturnType<typeof createTokenEstimator>;
type BuiltinCompactionStrategyOptions = {
@@ -408,6 +418,31 @@ export function createContextCompactionPrepareTurn(
modelId: config.modelId,
...telemetryIdentity,
});
if (
result.budget &&
(result.budget.actionCount > 0 || result.budget.warningCount > 0)
) {
captureCompactionBudgetEmergency(config.telemetry, {
ulid: telemetryUlid,
strategy: telemetryStrategy,
mode,
policyIntent: result.budget.policyIntent,
actionCount: result.budget.actionCount,
warningCount: result.budget.warningCount,
liveTailHandling: result.budget.liveTailHandling,
provider: config.providerId,
modelId: config.modelId,
...telemetryIdentity,
});
context.emitStatusNotice?.("compaction-budget-adjusted", {
kind: "compaction_budget_emergency",
reason: "compaction_budget_emergency",
iteration: context.iteration,
policyIntent: result.budget.policyIntent,
actionCount: result.budget.actionCount,
warningCount: result.budget.warningCount,
});
}
} else {
captureCompactionSkipped(config.telemetry, {
ulid: telemetryUlid,
@@ -428,3 +463,67 @@ export function createContextCompactionPrepareTurn(
return result;
};
}
export function createCompactionStateAwarePrepareTurn(input: {
compact?: ContextPipelinePrepareTurn;
getState?: () => SessionCompactionState | undefined;
saveState?: (state: SessionCompactionState) => void | Promise<void>;
clearState?: () => void | Promise<void>;
}): ContextPipelinePrepareTurn {
return async (context) => {
const existingState = input.getState?.();
const projectedMessages = existingState
? projectSessionCompactionState(existingState, context.messages)
: undefined;
if (existingState && projectedMessages) {
// Re-compaction intentionally starts from the compacted projection plus
// canonical tail. This keeps automatic turns bounded without rebuilding a
// full-transcript summary every turn; manual `/compact` is the path for a
// fresh summary from canonical history.
const result = input.compact
? await input.compact({
...context,
messages: projectedMessages,
apiMessages: projectedMessages,
})
: undefined;
if (result?.messages) {
const systemPrompt = result.systemPrompt ?? existingState.system_prompt;
const nextState = createSessionCompactionState({
sourceMessages: context.messages,
compactedMessages: result.messages,
conversationId: context.conversationId,
systemPrompt,
});
await input.saveState?.(nextState);
return {
...result,
...(systemPrompt !== undefined ? { systemPrompt } : {}),
};
}
return {
messages: projectedMessages,
...(result?.systemPrompt !== undefined
? { systemPrompt: result.systemPrompt }
: existingState.system_prompt !== undefined
? { systemPrompt: existingState.system_prompt }
: {}),
};
}
if (existingState) {
await input.clearState?.();
}
const result = input.compact ? await input.compact(context) : undefined;
if (result?.messages) {
const nextState = createSessionCompactionState({
sourceMessages: context.messages,
compactedMessages: result.messages,
conversationId: context.conversationId,
systemPrompt: result.systemPrompt,
});
await input.saveState?.(nextState);
}
return result;
};
}
@@ -41,6 +41,10 @@ import type {
} from "../../runtime/host/runtime-host";
import { isSessionNotFoundError } from "../../runtime/host/runtime-host";
import { RuntimeHostEventBus } from "../../runtime/host/runtime-host-support";
import {
parseSessionCompactionState,
type SessionCompactionState,
} from "../../session/models/session-compaction";
import {
type SessionManifest,
SessionManifestSchema,
@@ -821,6 +825,9 @@ export class HubRuntimeHost implements RuntimeHost {
input.toolPolicies as Record<string, unknown> | undefined,
),
initialMessages: input.initialMessages,
...(input.initialCompactionState
? { initialCompactionState: input.initialCompactionState }
: {}),
});
this.registerPlannedSession(
plannedSessionId,
@@ -958,6 +965,12 @@ export class HubRuntimeHost implements RuntimeHost {
| Record<string, unknown>
| undefined,
),
...(startConfig.initialCompactionState
? {
initialCompactionState:
startConfig.initialCompactionState,
}
: {}),
}
: {}),
},
@@ -1277,6 +1290,38 @@ export class HubRuntimeHost implements RuntimeHost {
return { updated: reply.ok };
}
async updateSessionCompactionState(
sessionId: string,
state: SessionCompactionState,
): Promise<{ updated: boolean }> {
const target = sessionId.trim();
if (!target) return { updated: false };
const reply = await this.client.command(
"session.compaction.update",
{ sessionId: target, state },
target,
);
return {
updated: reply.ok && reply.payload?.updated === true,
};
}
async readSessionCompactionState(
sessionId: string,
): Promise<SessionCompactionState | undefined> {
const target = sessionId.trim();
if (!target) return undefined;
const reply = await this.client.command(
"session.compaction.get",
{ sessionId: target },
target,
);
if (!reply.ok) {
throw new Error(hubReplyErrorMessage(reply, "session.compaction.get"));
}
return parseSessionCompactionState(reply.payload?.state);
}
async readSessionMessages(
sessionId: string,
): Promise<import("@cline/llms").Message[]> {
@@ -5,6 +5,7 @@ import {
type StartSessionInput,
type StartSessionResult,
} from "../../runtime/host/runtime-host";
import { createSessionCompactionState } from "../../session/models/session-compaction";
import { createLocalHubScheduleRuntimeHandlers } from "../daemon/runtime-handlers";
import { HubServerTransport } from "../server";
import {
@@ -797,6 +798,200 @@ describe("HubServerTransport boundaries", () => {
expect(ctx.pendingCapabilityRequests.has("capreq-1")).toBe(true);
});
it("does not let session metadata updates overwrite server-owned compaction owner", async () => {
const updateSession = vi.fn().mockResolvedValue({ updated: true });
const transport = createTransport({
sessionHost: {
updateSession,
},
});
await transport.handleCommand({
version: "v1",
requestId: "req-update",
command: "session.update",
clientId: "attacker-client",
sessionId: "session-1",
payload: {
metadata: {
hubCapabilityOwnerClientId: "attacker-client",
title: "safe title",
},
},
});
expect(updateSession).toHaveBeenCalledWith("session-1", {
metadata: { title: "safe title" },
});
});
it("authorizes compaction sidecar access from server session state, not mutable metadata", async () => {
const readSessionCompactionState = vi.fn();
const transport = createTransport({
sessionHost: {
getSession: vi.fn().mockResolvedValue({
sessionId: "session-1",
status: "completed",
startedAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
workspaceRoot: "/tmp/project",
cwd: "/tmp/project",
metadata: { hubCapabilityOwnerClientId: "attacker-client" },
}),
readSessionCompactionState,
},
});
const ctx = getContext(transport);
ensureSessionState(ctx, "session-1", "owner-client", "creator");
const reply = await transport.handleCommand({
version: "v1",
requestId: "req-compact",
command: "session.compaction.get",
clientId: "attacker-client",
sessionId: "session-1",
});
expect(reply).toMatchObject({
ok: false,
error: { code: "session_wrong_client" },
});
expect(readSessionCompactionState).not.toHaveBeenCalled();
});
it("returns compaction sidecar state to the server-owned session client", async () => {
const state = createSessionCompactionState({
sourceMessages: [{ role: "user", content: "source" }],
compactedMessages: [{ role: "user", content: "summary" }],
conversationId: "session-1",
});
const readSessionCompactionState = vi.fn().mockResolvedValue(state);
const transport = createTransport({
sessionHost: { readSessionCompactionState },
});
const ctx = getContext(transport);
ensureSessionState(ctx, "session-1", "owner-client", "creator");
const reply = await transport.handleCommand({
version: "v1",
requestId: "req-compact-get",
command: "session.compaction.get",
clientId: "owner-client",
sessionId: "session-1",
});
expect(reply).toMatchObject({
ok: true,
payload: { sessionId: "session-1", state },
});
expect(readSessionCompactionState).toHaveBeenCalledWith("session-1");
});
it("rejects invalid compaction sidecar updates before calling the session host", async () => {
const updateSessionCompactionState = vi.fn();
const transport = createTransport({
sessionHost: { updateSessionCompactionState },
});
const ctx = getContext(transport);
ensureSessionState(ctx, "session-1", "owner-client", "creator");
const reply = await transport.handleCommand({
version: "v1",
requestId: "req-compact-update-invalid",
command: "session.compaction.update",
clientId: "owner-client",
sessionId: "session-1",
payload: { state: { version: 1, messages: "bad" } },
});
expect(reply).toMatchObject({
ok: false,
error: { code: "invalid_compaction_state" },
});
expect(updateSessionCompactionState).not.toHaveBeenCalled();
});
it("publishes session updates after successful compaction sidecar updates", async () => {
const state = createSessionCompactionState({
sourceMessages: [{ role: "user", content: "source" }],
compactedMessages: [{ role: "user", content: "summary" }],
conversationId: "session-1",
});
const updateSessionCompactionState = vi
.fn()
.mockResolvedValue({ updated: true });
const transport = createTransport({
sessionHost: { updateSessionCompactionState },
});
const ctx = getContext(transport);
const events: HubEventEnvelope[] = [];
ensureSessionState(ctx, "session-1", "owner-client", "creator");
transport.subscribe("owner-client", (event) => events.push(event));
const reply = await transport.handleCommand({
version: "v1",
requestId: "req-compact-update",
command: "session.compaction.update",
clientId: "owner-client",
sessionId: "session-1",
payload: { state },
});
expect(reply).toMatchObject({
ok: true,
payload: { updated: true },
});
expect(updateSessionCompactionState).toHaveBeenCalledWith(
"session-1",
state,
);
expect(events).toEqual(
expect.arrayContaining([
expect.objectContaining({
event: "session.updated",
sessionId: "session-1",
}),
]),
);
});
it("does not publish session updates when compaction sidecar update is stale", async () => {
const state = createSessionCompactionState({
sourceMessages: [{ role: "user", content: "source" }],
compactedMessages: [{ role: "user", content: "summary" }],
conversationId: "session-1",
});
const updateSessionCompactionState = vi
.fn()
.mockResolvedValue({ updated: false });
const transport = createTransport({
sessionHost: { updateSessionCompactionState },
});
const ctx = getContext(transport);
const events: HubEventEnvelope[] = [];
ensureSessionState(ctx, "session-1", "owner-client", "creator");
transport.subscribe("owner-client", (event) => events.push(event));
const reply = await transport.handleCommand({
version: "v1",
requestId: "req-compact-stale",
command: "session.compaction.update",
clientId: "owner-client",
sessionId: "session-1",
payload: { state },
});
expect(reply).toMatchObject({
ok: false,
payload: { updated: false },
});
expect(events).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ event: "session.updated" }),
]),
);
});
it("cancels pending capability requests when a run is aborted", async () => {
const abort = vi.fn().mockResolvedValue(undefined);
const transport = createTransport({
@@ -6,6 +6,7 @@ import type {
} from "@cline/shared";
import { createSessionId, parseRuntimeConfigExtensions } from "@cline/shared";
import type { RuntimeSessionConfig } from "../../../runtime/host/runtime-host";
import { parseSessionCompactionState } from "../../../session/models/session-compaction";
import {
SessionVersioningError,
SessionVersioningService,
@@ -38,10 +39,49 @@ function setCapabilityOwner(
}
function getCapabilityOwnerClientId(
metadata: Record<string, unknown> | undefined,
ctx: HubTransportContext,
sessionId: string,
): string | undefined {
const owner = metadata?.[CAPABILITY_OWNER_METADATA_KEY];
return typeof owner === "string" && owner.trim() ? owner.trim() : undefined;
// Compaction sidecar access is intentionally tied to live hub session
// ownership, not mutable persisted metadata. Sessions restored after the
// hub forgets their live owner must be recreated or backfilled before using
// these owner-scoped sidecar endpoints.
return ctx.sessionState.get(sessionId)?.createdByClientId;
}
function stripServerOwnedSessionMetadata(
metadata: Record<string, JsonValue | undefined> | undefined,
): Record<string, JsonValue | undefined> | undefined {
if (!metadata || !(CAPABILITY_OWNER_METADATA_KEY in metadata)) {
return metadata;
}
const sanitized = { ...metadata };
delete sanitized[CAPABILITY_OWNER_METADATA_KEY];
return sanitized;
}
function authorizeSessionCompactionAccess(input: {
sessionId: string;
ctx: HubTransportContext;
clientId: string;
envelope: HubCommandEnvelope;
}): HubReplyEnvelope | undefined {
const ownerClientId = getCapabilityOwnerClientId(input.ctx, input.sessionId);
if (!ownerClientId) {
return errorReply(
input.envelope,
"session_wrong_client",
`Session ${input.sessionId} has no authorized owner`,
);
}
if (ownerClientId !== input.clientId) {
return errorReply(
input.envelope,
"session_wrong_client",
`Session ${input.sessionId} is owned by ${ownerClientId}`,
);
}
return undefined;
}
export async function handleSessionCreate(
@@ -77,6 +117,9 @@ export async function handleSessionCreate(
payload.runtimeOptions && typeof payload.runtimeOptions === "object"
? (payload.runtimeOptions as Record<string, unknown>)
: {};
const initialCompactionState = parseSessionCompactionState(
payload.initialCompactionState,
);
if (typeof sessionConfig?.mode === "string") {
metadata.mode = sessionConfig.mode;
} else if (typeof runtimeOptions.mode === "string") {
@@ -124,9 +167,7 @@ export async function handleSessionCreate(
cwd: typeof payload.cwd === "string" ? payload.cwd : undefined,
contributionCount: clientContributions.length,
});
if (clientContributions.length > 0) {
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
}
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
const requestedSessionId =
typeof sessionConfig?.sessionId === "string"
? sessionConfig.sessionId.trim()
@@ -175,6 +216,7 @@ export async function handleSessionCreate(
initialMessages: Array.isArray(payload.initialMessages)
? (payload.initialMessages as never[])
: undefined,
initialCompactionState,
localRuntime: {
modelCatalogDefaults: {
loadLatestOnInit: true,
@@ -352,6 +394,9 @@ export async function handleSessionRestore(
payload.runtimeOptions && typeof payload.runtimeOptions === "object"
? (payload.runtimeOptions as Record<string, unknown>)
: {};
const initialCompactionState = parseSessionCompactionState(
payload.initialCompactionState,
);
const metadata =
payload.metadata && typeof payload.metadata === "object"
? JSON.parse(JSON.stringify(payload.metadata))
@@ -380,9 +425,7 @@ export async function handleSessionRestore(
const clientContributions = parseHubClientContributions(
runtimeOptions.clientContributions,
);
if (clientContributions.length > 0) {
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
}
setCapabilityOwner(metadata as Record<string, unknown>, clientId);
const requestedSessionId =
typeof sessionConfig?.sessionId === "string"
? sessionConfig.sessionId.trim()
@@ -439,6 +482,7 @@ export async function handleSessionRestore(
restoredCheckpointRunCount: checkpointRunCount,
},
initialMessages: context.initialMessages,
initialCompactionState,
localRuntime: {
modelCatalogDefaults: {
loadLatestOnInit: true,
@@ -618,13 +662,7 @@ export async function handleSessionDetach(
);
}
const clientId = envelope.clientId?.trim() || "hub-client";
const [existingSession] = await Promise.all([
readHubSessionRecord(ctx, sessionId),
]);
const ownerClientId =
getCapabilityOwnerClientId(
existingSession?.metadata as Record<string, unknown> | undefined,
) ?? clientId;
const ownerClientId = getCapabilityOwnerClientId(ctx, sessionId) ?? clientId;
const state = ctx.sessionState.get(sessionId);
if (state) {
state.participants.delete(clientId);
@@ -702,6 +740,40 @@ export async function handleSessionMessages(
return okReply(envelope, { sessionId, messages });
}
export async function handleSessionCompactionGet(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
): Promise<HubReplyEnvelope> {
const sessionId = extractSessionId(envelope);
if (!sessionId) {
return errorReply(
envelope,
"invalid_session_id",
"session.compaction.get requires a session id",
);
}
const session = await readHubSessionRecord(ctx, sessionId);
if (!session) {
return errorReply(
envelope,
"session_not_found",
`Unknown session: ${sessionId}`,
);
}
const clientId = envelope.clientId?.trim() || "hub-client";
const unauthorized = authorizeSessionCompactionAccess({
sessionId,
ctx,
clientId,
envelope,
});
if (unauthorized) {
return unauthorized;
}
const state = await ctx.sessionHost.readSessionCompactionState(sessionId);
return okReply(envelope, { sessionId, state });
}
export async function handleSessionList(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
@@ -722,7 +794,9 @@ export async function handleSessionUpdate(
envelope: HubCommandEnvelope,
): Promise<HubReplyEnvelope> {
const sessionId = extractSessionId(envelope);
const metadata = asPlainRecord(envelope.payload?.metadata);
const metadata = stripServerOwnedSessionMetadata(
asPlainRecord(envelope.payload?.metadata),
);
const updated = await ctx.sessionHost.updateSession(sessionId, { metadata });
const [session, snapshot] = await Promise.all([
readHubSessionRecord(ctx, sessionId),
@@ -749,6 +823,82 @@ export async function handleSessionUpdate(
};
}
export async function handleSessionCompactionUpdate(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
): Promise<HubReplyEnvelope> {
const sessionId = extractSessionId(envelope);
if (!sessionId) {
return errorReply(
envelope,
"invalid_session_id",
"session.compaction.update requires a session id",
);
}
const clientId = envelope.clientId?.trim() || "hub-client";
const session = await readHubSessionRecord(ctx, sessionId);
if (!session) {
return errorReply(
envelope,
"session_not_found",
`Unknown session: ${sessionId}`,
);
}
const unauthorized = authorizeSessionCompactionAccess({
sessionId,
ctx,
clientId,
envelope,
});
if (unauthorized) {
return unauthorized;
}
const payload =
envelope.payload && typeof envelope.payload === "object"
? envelope.payload
: {};
const state = parseSessionCompactionState(payload.state);
if (!state) {
return errorReply(
envelope,
"invalid_compaction_state",
"session.compaction.update requires a valid compaction state",
);
}
const updated = await ctx.sessionHost.updateSessionCompactionState(
sessionId,
state,
);
const [updatedSession, snapshot] = updated.updated
? await Promise.all([
readHubSessionRecord(ctx, sessionId),
readCoreSessionSnapshot(ctx, sessionId),
])
: [session, undefined];
if (updated.updated) {
ctx.publish(
ctx.buildEvent(
"session.updated",
{
session: updatedSession ?? session,
...(snapshot ? { snapshot } : {}),
},
sessionId,
),
);
}
return {
version: envelope.version,
requestId: envelope.requestId,
ok: updated.updated,
payload: {
updated: updated.updated,
session: updatedSession ?? session,
...(snapshot ? { snapshot } : {}),
},
};
}
export async function handleSessionDelete(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
@@ -56,6 +56,8 @@ import {
import { projectSessionEvent } from "./handlers/session-event-projector";
import {
handleSessionAttach,
handleSessionCompactionGet,
handleSessionCompactionUpdate,
handleSessionCreate,
handleSessionDelete,
handleSessionDetach,
@@ -360,10 +362,14 @@ export class HubServerTransport implements NativeHubTransport {
return await handleSessionGet(this.ctx, envelope);
case "session.messages":
return await handleSessionMessages(this.ctx, envelope);
case "session.compaction.get":
return await handleSessionCompactionGet(this.ctx, envelope);
case "session.list":
return await handleSessionList(this.ctx, envelope);
case "session.update":
return await handleSessionUpdate(this.ctx, envelope);
case "session.compaction.update":
return await handleSessionCompactionUpdate(this.ctx, envelope);
case "session.pending_prompts":
return await handleSessionPendingPrompts(this.ctx, envelope);
case "session.update_pending_prompt":
+10 -1
View File
@@ -641,7 +641,10 @@ export async function loadOpenTelemetryAdapter() {
return import("./services/telemetry/index.js");
}
export { Agent, createAgentRuntime } from "@cline/agents";
export { createContextCompactionPrepareTurn } from "./extensions/context/compaction";
export {
createCompactionStateAwarePrepareTurn,
createContextCompactionPrepareTurn,
} from "./extensions/context/compaction";
export {
ALL_DEFAULT_TOOL_NAMES,
type AskQuestionExecutor,
@@ -752,6 +755,12 @@ export {
TelemetryService,
type TelemetryServiceOptions,
} from "./services/telemetry/TelemetryService";
export {
createSessionCompactionState,
parseSessionCompactionState,
projectSessionCompactionState,
type SessionCompactionState,
} from "./session/models/session-compaction";
// Compatibility barrel (legacy imports).
export type { RuntimeEnvironment } from "./types";
export type { SessionStatus } from "./types/common";
@@ -14,7 +14,10 @@ import {
} from "@cline/shared";
import { setHomeDirIfUnset } from "@cline/shared/storage";
import { isOAuthProvider } from "../../auth/provider-auth-registry";
import { createContextCompactionPrepareTurn } from "../../extensions/context/compaction";
import {
createCompactionStateAwarePrepareTurn,
createContextCompactionPrepareTurn,
} from "../../extensions/context/compaction";
import type { ToolExecutors } from "../../extensions/tools";
import { DefaultToolNames } from "../../extensions/tools";
import type { TeamEvent } from "../../extensions/tools/team";
@@ -47,6 +50,10 @@ import {
sumUsageTotals,
} from "../../services/usage";
import { enrichPromptWithMentions } from "../../services/workspace";
import {
projectSessionCompactionState,
type SessionCompactionState,
} from "../../session/models/session-compaction";
import {
type SessionManifest,
SessionManifestSchema,
@@ -170,6 +177,19 @@ function maxAccumulatedUsage(
};
}
function isIncomingCompactionStateStale(
incoming: SessionCompactionState,
current: SessionCompactionState | undefined,
): boolean {
if (!current) {
return false;
}
if (incoming.source_message_count !== current.source_message_count) {
return incoming.source_message_count < current.source_message_count;
}
return incoming.updated_at < current.updated_at;
}
export interface LocalRuntimeHostOptions {
distinctId?: string;
sessionService: SessionBackend;
@@ -318,6 +338,7 @@ export class LocalRuntimeHost implements RuntimeHost {
messages_path: messagesPath,
});
let resumedArtifacts: RootSessionArtifacts | undefined;
let resumedCompactionState: SessionCompactionState | undefined;
const isReadOnlyResumeStart =
requestedSessionId.length > 0 &&
initialMessages.length > 0 &&
@@ -332,8 +353,14 @@ export class LocalRuntimeHost implements RuntimeHost {
resumedArtifacts = {
manifestPath,
messagesPath: existingManifest.messages_path || messagesPath,
compactionPath: existingManifest.compaction_path,
manifest: existingManifest,
};
resumedCompactionState =
await this.invokeOptionalValue<SessionCompactionState>(
"readSessionCompactionState",
sessionId,
);
}
}
const initialAggregateUsage = await this.seedAggregateUsageFromArtifacts({
@@ -420,6 +447,80 @@ export class LocalRuntimeHost implements RuntimeHost {
const tools = [...runtime.tools, ...(configWithProvider.extraTools ?? [])];
const extensions = runtime.extensions ?? bootstrap.extensions;
const explicitInitialCompactionState = startInput.initialCompactionState;
let activeSessionRef: ActiveSession | undefined;
const compact = createContextCompactionPrepareTurn(configWithProvider);
const initialCompactionState =
explicitInitialCompactionState ??
(compact ? resumedCompactionState : undefined);
const prepareTurn = createCompactionStateAwarePrepareTurn({
compact,
getState: () => activeSessionRef?.compactionState,
saveState: async (state) => {
const activeSession = activeSessionRef;
if (!activeSession) return;
const stateForSession = {
...state,
conversation_id: activeSession.sessionId,
};
try {
const result = await this.persistActiveSessionCompactionState(
activeSession,
stateForSession,
);
if (!result.updated) {
configWithProvider.logger?.debug?.(
"Skipped stale session compaction state",
{
sessionId: activeSession.sessionId,
sourceMessageCount: stateForSession.source_message_count,
},
);
}
} catch (error) {
configWithProvider.logger?.error?.(
"Failed to persist session compaction state",
{ sessionId: activeSession.sessionId, error },
);
captureSdkError(configWithProvider.telemetry, {
component: "core",
operation: "session.persist_compaction_state",
severity: "warn",
handled: true,
error,
context: {
sessionId: activeSession.sessionId,
providerId: configWithProvider.providerId,
modelId: configWithProvider.modelId,
},
});
}
},
clearState: async () => {
const activeSession = activeSessionRef;
if (!activeSession?.compactionState) return;
try {
await this.clearActiveSessionCompactionState(activeSession);
} catch (error) {
configWithProvider.logger?.error?.(
"Failed to delete stale session compaction state",
{ sessionId: activeSession.sessionId, error },
);
captureSdkError(configWithProvider.telemetry, {
component: "core",
operation: "session.delete_compaction_state",
severity: "warn",
handled: true,
error,
context: {
sessionId: activeSession.sessionId,
providerId: configWithProvider.providerId,
modelId: configWithProvider.modelId,
},
});
}
},
});
const agentConfig = {
sessionId,
@@ -436,7 +537,7 @@ export class LocalRuntimeHost implements RuntimeHost {
systemPrompt: configWithProvider.systemPrompt,
maxIterations: configWithProvider.maxIterations,
execution: configWithProvider.execution,
prepareTurn: createContextCompactionPrepareTurn(configWithProvider),
prepareTurn,
tools,
hooks: bootstrap.hooks,
extensions,
@@ -580,6 +681,7 @@ export class LocalRuntimeHost implements RuntimeHost {
aborting: false,
interactive: input.interactive === true,
persistedMessages: initialMessages,
compactionState: initialCompactionState,
activeTeamRunIds: new Set<string>(),
pendingTeamRunUpdates: [],
teamRunWaiters: [],
@@ -589,6 +691,25 @@ export class LocalRuntimeHost implements RuntimeHost {
submitAndExitObserved: false,
lastInteractiveTurnFinishReason: undefined,
};
activeSessionRef = active;
if (
active.compactionState &&
!this.isCompactionStateForSession(
active.sessionId,
active.compactionState,
active,
)
) {
active.config.logger?.log?.(
"Ignoring session compaction state for a different conversation",
{
severity: "warn",
sessionId: active.sessionId,
conversationId: active.compactionState.conversation_id,
},
);
active.compactionState = undefined;
}
this.sessions.set(sessionId, active);
this.emitStatus(sessionId, "running");
if (initialMessages.length > 0 && !resumedArtifacts) {
@@ -599,6 +720,15 @@ export class LocalRuntimeHost implements RuntimeHost {
initialMessages,
active.config.systemPrompt,
);
if (active.compactionState) {
const result = await this.persistActiveSessionCompactionState(
active,
active.compactionState,
);
if (!result.updated) {
active.compactionState = undefined;
}
}
if (!startInput.prompt?.trim()) {
await this.updateStatus(active, "completed", 0);
}
@@ -883,6 +1013,168 @@ export class LocalRuntimeHost implements RuntimeHost {
return { updated: result?.updated === true };
}
async updateSessionCompactionState(
sessionId: string,
state: SessionCompactionState,
): Promise<{ updated: boolean }> {
const target = sessionId.trim();
if (!target) return { updated: false };
const activeSession = this.sessions.get(target);
const sessionRecord = activeSession
? undefined
: await this.getSession(target);
const existing = activeSession ?? sessionRecord;
if (!existing) return { updated: false };
if (
!(await this.canPersistCompactionState(
target,
state,
activeSession,
sessionRecord,
))
) {
return { updated: false };
}
if (activeSession) {
return await this.persistActiveSessionCompactionState(
activeSession,
state,
);
}
const current = await this.invokeOptionalValue<SessionCompactionState>(
"readSessionCompactionState",
target,
);
if (isIncomingCompactionStateStale(state, current)) {
return { updated: false };
}
await this.invoke<void>("persistSessionCompactionState", target, state);
return { updated: true };
}
async readSessionCompactionState(
sessionId: string,
): Promise<SessionCompactionState | undefined> {
const target = sessionId.trim();
if (!target) return undefined;
const activeSession = this.sessions.get(target);
if (activeSession) {
for (;;) {
const pendingWrite = activeSession.compactionStateWriteQueue;
if (!pendingWrite) {
return activeSession.compactionState;
}
await pendingWrite.catch(() => undefined);
}
}
return await this.invokeOptionalValue<SessionCompactionState>(
"readSessionCompactionState",
target,
);
}
private isCompactionStateForSession(
sessionId: string,
state: SessionCompactionState,
activeSession?: ActiveSession,
sessionRecord?: SessionRecord,
): boolean {
const conversationId = state.conversation_id?.trim();
if (!conversationId) {
return true;
}
if (conversationId === sessionId) {
return true;
}
const expectedConversationId =
activeSession?.agent.getConversationId()?.trim() ||
sessionRecord?.conversationId?.trim();
return expectedConversationId
? conversationId === expectedConversationId
: false;
}
private async canPersistCompactionState(
sessionId: string,
state: SessionCompactionState,
activeSession?: ActiveSession,
sessionRecord?: SessionRecord,
): Promise<boolean> {
if (!state.conversation_id?.trim()) {
return false;
}
if (
!this.isCompactionStateForSession(
sessionId,
state,
activeSession,
sessionRecord,
)
) {
return false;
}
const sourceMessages =
activeSession?.agent.getMessages() ??
(await this.readSessionMessages(sessionId));
return projectSessionCompactionState(state, sourceMessages) !== undefined;
}
private async persistActiveSessionCompactionState(
session: ActiveSession,
state: SessionCompactionState,
): Promise<{ updated: boolean }> {
if (!(await this.canPersistCompactionState(session.sessionId, state, session))) {
return { updated: false };
}
return await this.enqueueCompactionStateWrite(session, async () => {
if (isIncomingCompactionStateStale(state, session.compactionState)) {
return { updated: false };
}
await this.invoke<void>(
"persistSessionCompactionState",
session.sessionId,
state,
);
session.compactionState = state;
return { updated: true };
});
}
private async clearActiveSessionCompactionState(
session: ActiveSession,
): Promise<void> {
await this.enqueueCompactionStateWrite(session, async () => {
if (!session.compactionState) {
return;
}
await this.invoke<void>(
"deleteSessionCompactionState",
session.sessionId,
);
session.compactionState = undefined;
});
}
private async enqueueCompactionStateWrite<T>(
session: ActiveSession,
action: () => Promise<T>,
): Promise<T> {
const previous = session.compactionStateWriteQueue ?? Promise.resolve();
const run = previous.catch(() => undefined).then(action);
const tracked = run.then(
() => undefined,
() => undefined,
);
session.compactionStateWriteQueue = tracked;
try {
return await run;
} finally {
if (session.compactionStateWriteQueue === tracked) {
session.compactionStateWriteQueue = undefined;
}
}
}
async readSessionMessages(
sessionId: string,
): Promise<LlmsProviders.Message[]> {
@@ -7,6 +7,7 @@ import type {
import type { HookEventPayload } from "../../hooks";
import type { CheckpointEntry } from "../../hooks/checkpoint-hooks";
import type { ProviderSettings } from "../../services/llms/provider-settings";
import type { SessionCompactionState } from "../../session/models/session-compaction";
import type { SessionManifest } from "../../session/models/session-manifest";
import type { SessionSource } from "../../types/common";
import type { CoreSessionConfig } from "../../types/config";
@@ -104,6 +105,7 @@ export interface StartSessionInput {
interactive?: boolean;
sessionMetadata?: Record<string, unknown>;
initialMessages?: LlmsProviders.Message[];
initialCompactionState?: SessionCompactionState;
userImages?: string[];
userFiles?: string[];
/**
@@ -308,6 +310,13 @@ export interface RuntimeHost {
title?: string | null;
},
): Promise<{ updated: boolean }>;
updateSessionCompactionState(
sessionId: string,
state: SessionCompactionState,
): Promise<{ updated: boolean }>;
readSessionCompactionState(
sessionId: string,
): Promise<SessionCompactionState | undefined>;
readSessionMessages(sessionId: string): Promise<LlmsProviders.Message[]>;
dispatchHookEvent(payload: HookEventPayload): Promise<void>;
subscribe(
@@ -128,6 +128,55 @@ describe("RuntimeEventAdapter — suppressed events", () => {
});
});
describe("RuntimeEventAdapter — status notices", () => {
let adapter: RuntimeEventAdapter;
beforeEach(() => {
adapter = new RuntimeEventAdapter();
});
it("preserves bounded compaction reasons", () => {
for (const reason of [
"auto_compaction",
"manual_compaction",
"compaction_budget_emergency",
] as const) {
const out = adapter.translate({
type: "status-notice",
snapshot: makeSnapshot(),
message: "compaction status",
metadata: { reason },
});
expect(out).toEqual([
{
type: "notice",
noticeType: "status",
displayRole: "status",
message: "compaction status",
reason,
metadata: { reason },
},
]);
}
});
it("does not promote arbitrary status reasons", () => {
const out = adapter.translate({
type: "status-notice",
snapshot: makeSnapshot(),
message: "custom",
metadata: { reason: "surprise" },
});
expect(out[0]).toMatchObject({
type: "notice",
noticeType: "status",
message: "custom",
reason: undefined,
});
});
});
// ---------------------------------------------------------------------------
// Iteration lifecycle
// ---------------------------------------------------------------------------
@@ -66,6 +66,21 @@ import type {
// Helpers
// =============================================================================
type StatusNoticeReason = Extract<AgentEvent, { type: "notice" }>["reason"];
function resolveStatusNoticeReason(
reason: unknown,
): StatusNoticeReason | undefined {
switch (reason) {
case "auto_compaction":
case "manual_compaction":
case "compaction_budget_emergency":
return reason;
default:
return undefined;
}
}
function extractTextPart(message: AgentMessage): string | undefined {
const parts = message.content.filter(
(part): part is AgentTextPart => part.type === "text",
@@ -225,10 +240,7 @@ export class RuntimeEventAdapter {
noticeType: "status",
displayRole: "status",
message: event.message,
reason:
event.metadata?.reason === "auto_compaction"
? "auto_compaction"
: undefined,
reason: resolveStatusNoticeReason(event.metadata?.reason),
metadata: event.metadata,
},
];
@@ -79,6 +79,13 @@ export class SessionArtifacts {
);
}
public sessionCompactionPath(sessionId: string): string {
return join(
this.sessionArtifactsDir(sessionId),
`${sessionId}.compaction.json`,
);
}
public sessionManifestPath(sessionId: string, ensureDir = false): string {
const base = ensureDir
? this.ensureSessionArtifactsDir(sessionId)
@@ -2,6 +2,7 @@ import type { ITelemetryService } from "@cline/shared";
import { describe, expect, test, vi } from "vitest";
import {
CORE_TELEMETRY_EVENTS,
captureCompactionBudgetEmergency,
captureCompactionExecuted,
captureCompactionSkipped,
captureExtensionActivated,
@@ -441,6 +442,38 @@ describe("captureRunCommandsTimeout", () => {
});
});
describe("captureCompactionBudgetEmergency", () => {
test("emits task.compaction_budget_emergency with action metadata", () => {
const stub = createTelemetryStub();
captureCompactionBudgetEmergency(stub.telemetry, {
ulid: "ulid-1",
strategy: "basic",
mode: "auto",
policyIntent: "basic_compaction_projection",
actionCount: 2,
warningCount: 1,
liveTailHandling: "included_degraded",
provider: "anthropic",
modelId: "claude-sonnet-4",
});
const { event, properties } = captureCallAt(stub, 0);
expect(event).toBe("task.compaction_budget_emergency");
expect(properties).toMatchObject({
ulid: "ulid-1",
strategy: "basic",
mode: "auto",
policyIntent: "basic_compaction_projection",
actionCount: 2,
warningCount: 1,
liveTailHandling: "included_degraded",
});
expect(typeof (properties as Record<string, unknown>).timestamp).toBe(
"string",
);
});
});
/**
* Telemetry-policy regression coverage.
*
@@ -603,6 +636,24 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
expect(emitRequired).not.toHaveBeenCalled();
});
test("captureCompactionBudgetEmergency never invokes captureRequired", () => {
const { adapter, emitRequired } = createDisabledAdapter();
const service = new TelemetryService({
distinctId: "test-distinct-id",
adapters: [adapter],
});
captureCompactionBudgetEmergency(service, {
ulid: "ulid-1",
strategy: "basic",
mode: "auto",
policyIntent: "basic_compaction_projection",
actionCount: 1,
warningCount: 0,
liveTailHandling: "included_degraded",
});
expect(emitRequired).not.toHaveBeenCalled();
});
test("a correctly-policed adapter drops these events when disabled", () => {
// This test layers on top of the previous four to assert the *full*
// end-to-end policy: when the adapter is disabled, a real adapter
@@ -681,6 +732,15 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
command_count: 2,
duration_ms: 1502,
});
captureCompactionBudgetEmergency(service, {
ulid: "ulid-1",
strategy: "basic",
mode: "auto",
policyIntent: "basic_compaction_projection",
actionCount: 1,
warningCount: 0,
liveTailHandling: "included_degraded",
});
expect(observed).toEqual([]);
expect(dropped).toEqual([
"user.extension_activated",
@@ -691,6 +751,7 @@ describe("telemetry policy: helpers respect telemetry opt-out", () => {
"task.compaction_executed",
"task.compaction_skipped",
"sdk.tool_timeout",
"task.compaction_budget_emergency",
]);
});
});
@@ -3,6 +3,10 @@ import {
SDK_ERROR_TELEMETRY_EVENT,
type TelemetryProperties,
} from "@cline/shared";
import type {
CoreCompactionBudgetPolicyIntent,
CoreCompactionLiveTailHandling,
} from "../../types/config";
const MAX_ERROR_MESSAGE_LENGTH = 500;
@@ -61,6 +65,7 @@ export const CORE_TELEMETRY_EVENTS = {
SUBAGENT_COMPLETED: "task.subagent_completed",
COMPACTION_EXECUTED: "task.compaction_executed",
COMPACTION_SKIPPED: "task.compaction_skipped",
COMPACTION_BUDGET_EMERGENCY: "task.compaction_budget_emergency",
},
HOOKS: {
DISCOVERY_COMPLETED: "hooks.discovery_completed",
@@ -678,3 +683,26 @@ export function captureCompactionSkipped(
timestamp: new Date().toISOString(),
});
}
export interface CaptureCompactionBudgetEmergencyProperties {
ulid: string;
strategy: TelemetryCompactionStrategy;
mode: TelemetryCompactionMode;
policyIntent: CoreCompactionBudgetPolicyIntent;
actionCount: number;
warningCount: number;
liveTailHandling: CoreCompactionLiveTailHandling;
provider?: string;
modelId?: string;
}
export function captureCompactionBudgetEmergency(
telemetry: ITelemetryService | undefined,
properties: CaptureCompactionBudgetEmergencyProperties &
Partial<TelemetryAgentIdentityProperties>,
): void {
emit(telemetry, CORE_TELEMETRY_EVENTS.TASK.COMPACTION_BUDGET_EMERGENCY, {
...properties,
timestamp: new Date().toISOString(),
});
}
@@ -0,0 +1,128 @@
import { describe, expect, it } from "vitest";
import {
createSessionCompactionState,
parseSessionCompactionState,
projectSessionCompactionState,
} from "./session-compaction";
describe("session compaction state", () => {
it("rejects projection when the canonical prefix was edited before the boundary", () => {
const sourceMessages = [
{ id: "u1", role: "user" as const, content: "original detail" },
{ id: "a1", role: "assistant" as const, content: "answer" },
];
const state = createSessionCompactionState({
sourceMessages,
compactedMessages: [
{ id: "summary", role: "user" as const, content: "summary" },
],
updatedAt: "2026-01-01T00:00:00.000Z",
});
const editedPrefix = [
{ ...sourceMessages[0], content: "redacted detail" },
sourceMessages[1],
{ id: "u2", role: "user" as const, content: "tail" },
];
expect(projectSessionCompactionState(state, editedPrefix)).toBeUndefined();
});
it("projects compacted state when the canonical prefix matches exactly", () => {
const sourceMessages = [
{ id: "u1", role: "user" as const, content: "original detail" },
{ id: "a1", role: "assistant" as const, content: "answer" },
];
const compactedMessages = [
{ id: "summary", role: "user" as const, content: "summary" },
];
const tail = { id: "u2", role: "user" as const, content: "tail" };
const state = createSessionCompactionState({
sourceMessages,
compactedMessages,
updatedAt: "2026-01-01T00:00:00.000Z",
});
expect(
projectSessionCompactionState(state, [...sourceMessages, tail]),
).toEqual([...compactedMessages, tail]);
});
it("projects when resumed user input was display-normalized from persisted history", () => {
const sourceMessages = [
{
id: "u1",
role: "user" as const,
content: '<user_input mode="act">hello</user_input>',
},
{
id: "u2",
role: "user" as const,
content: [
{
type: "text" as const,
text: '<user_input mode="act">inspect</user_input>',
},
],
},
{ id: "a1", role: "assistant" as const, content: "answer" },
];
const resumedMessages = [
{ ...sourceMessages[0], content: "hello" },
{
...sourceMessages[1],
content: [{ type: "text" as const, text: "inspect" }],
},
sourceMessages[2],
{ id: "u3", role: "user" as const, content: "tail" },
];
const compactedMessages = [
{ id: "summary", role: "user" as const, content: "summary" },
];
const state = createSessionCompactionState({
sourceMessages,
compactedMessages,
updatedAt: "2026-01-01T00:00:00.000Z",
});
expect(projectSessionCompactionState(state, resumedMessages)).toEqual([
...compactedMessages,
resumedMessages[3],
]);
});
it("rejects anchor-free sidecars even when the source count is zero", () => {
const state = parseSessionCompactionState({
version: 1,
updated_at: "2026-01-01T00:00:00.000Z",
source_message_count: 0,
messages: [
{ id: "summary", role: "user" as const, content: "unanchored" },
],
});
expect(state).toBeDefined();
if (!state) {
throw new Error("expected parsed compaction state");
}
expect(
projectSessionCompactionState(state, [
{ id: "u1", role: "user", content: "canonical" },
]),
).toBeUndefined();
});
it("rejects malformed sidecar timestamps", () => {
const state = parseSessionCompactionState({
version: 1,
updated_at: "not-a-date",
source_message_count: 1,
source_prefix_hash: "sha256:test",
messages: [
{ id: "summary", role: "user" as const, content: "summary" },
],
});
expect(state).toBeUndefined();
});
});
@@ -0,0 +1,225 @@
import { createHash } from "node:crypto";
import {
formatDisplayUserInput,
type MessageWithMetadata,
} from "@cline/shared";
import { z } from "zod";
function isMessageWithMetadata(value: unknown): value is MessageWithMetadata {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const candidate = value as Partial<MessageWithMetadata>;
if (candidate.role !== "user" && candidate.role !== "assistant") {
return false;
}
return (
typeof candidate.content === "string" || Array.isArray(candidate.content)
);
}
const MessageWithMetadataSchema = z.custom<MessageWithMetadata>(
isMessageWithMetadata,
);
export const SessionCompactionStateSchema = z.object({
version: z.literal(1),
updated_at: z.string().datetime(),
conversation_id: z.string().min(1).optional(),
source_message_count: z.number().int().nonnegative(),
source_prefix_hash: z.string().min(1).optional(),
source_last_message_key: z.string().min(1).optional(),
messages: z.array(MessageWithMetadataSchema),
system_prompt: z.string().optional(),
});
export type SessionCompactionState = z.infer<
typeof SessionCompactionStateSchema
>;
function cloneMessages(
messages: readonly MessageWithMetadata[],
): MessageWithMetadata[] {
return JSON.parse(canonicalJson(messages)) as MessageWithMetadata[];
}
function toCanonicalJsonValue(value: unknown, seen: WeakSet<object>): unknown {
if (
value === null ||
typeof value === "string" ||
typeof value === "boolean"
) {
return value;
}
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value === "bigint") {
throw new TypeError("Cannot serialize bigint in session compaction state");
}
if (
value === undefined ||
typeof value === "function" ||
typeof value === "symbol"
) {
return undefined;
}
if (typeof value !== "object") {
return value;
}
const withToJson = value as { toJSON?: () => unknown };
if (typeof withToJson.toJSON === "function") {
const jsonValue = withToJson.toJSON();
if (jsonValue !== value) {
return toCanonicalJsonValue(jsonValue, seen);
}
}
if (seen.has(value)) {
throw new TypeError("Cannot serialize circular session compaction state");
}
seen.add(value);
try {
if (Array.isArray(value)) {
return value.map((item) => {
const normalized = toCanonicalJsonValue(item, seen);
return normalized === undefined ? null : normalized;
});
}
const record = value as Record<string, unknown>;
const normalized: Record<string, unknown> = {};
for (const key of Object.keys(record).sort()) {
const item = toCanonicalJsonValue(record[key], seen);
if (item !== undefined) {
normalized[key] = item;
}
}
return normalized;
} finally {
seen.delete(value);
}
}
function canonicalJson(value: unknown): string {
const json = JSON.stringify(
toCanonicalJsonValue(value, new WeakSet<object>()),
);
if (json === undefined) {
throw new TypeError("Cannot serialize undefined session compaction state");
}
return json;
}
function normalizeMessageForSourceHash(
message: MessageWithMetadata,
): MessageWithMetadata {
if (message.role !== "user") {
return message;
}
if (typeof message.content === "string") {
return {
...message,
content: formatDisplayUserInput(message.content),
};
}
return {
...message,
content: message.content.map((part) =>
part.type === "text"
? { ...part, text: formatDisplayUserInput(part.text) }
: part,
),
};
}
function messageBoundaryKey(message: MessageWithMetadata | undefined): string {
if (!message) {
return "";
}
const normalized = normalizeMessageForSourceHash(message);
if (typeof normalized.id === "string" && normalized.id.trim()) {
return `id:${normalized.id.trim()}`;
}
if (typeof normalized.ts === "number" && Number.isFinite(normalized.ts)) {
return `ts:${normalized.role}:${normalized.ts}`;
}
return `content:${normalized.role}:${JSON.stringify(normalized.content)}`;
}
function sourcePrefixHash(
messages: readonly MessageWithMetadata[],
count = messages.length,
): string {
const hash = createHash("sha256");
hash.update("cline-session-compaction-source-v1\n");
hash.update(`${count}\n`);
for (const message of messages.slice(0, count)) {
hash.update(canonicalJson(normalizeMessageForSourceHash(message)));
hash.update("\n");
}
return `sha256:${hash.digest("hex")}`;
}
export function createSessionCompactionState(input: {
sourceMessages: readonly MessageWithMetadata[];
compactedMessages: readonly MessageWithMetadata[];
conversationId?: string;
systemPrompt?: string;
updatedAt?: string;
}): SessionCompactionState {
const lastSourceMessage = input.sourceMessages.at(-1);
const sourceLastMessageKey = messageBoundaryKey(lastSourceMessage);
return SessionCompactionStateSchema.parse({
version: 1,
updated_at: input.updatedAt ?? new Date().toISOString(),
...(input.conversationId?.trim()
? { conversation_id: input.conversationId.trim() }
: {}),
source_message_count: input.sourceMessages.length,
source_prefix_hash: sourcePrefixHash(input.sourceMessages),
...(sourceLastMessageKey
? { source_last_message_key: sourceLastMessageKey }
: {}),
messages: cloneMessages(input.compactedMessages),
...(input.systemPrompt !== undefined
? { system_prompt: input.systemPrompt }
: {}),
});
}
export function projectSessionCompactionState(
state: SessionCompactionState,
sourceMessages: readonly MessageWithMetadata[],
): MessageWithMetadata[] | undefined {
if (state.source_message_count > sourceMessages.length) {
return undefined;
}
if (state.source_prefix_hash) {
if (
sourcePrefixHash(sourceMessages, state.source_message_count) !==
state.source_prefix_hash
) {
return undefined;
}
} else if (state.source_message_count > 0 && state.source_last_message_key) {
const boundary = sourceMessages[state.source_message_count - 1];
if (messageBoundaryKey(boundary) !== state.source_last_message_key) {
return undefined;
}
} else {
return undefined;
}
return [
...cloneMessages(state.messages),
...cloneMessages(sourceMessages.slice(state.source_message_count)),
];
}
export function parseSessionCompactionState(
value: unknown,
): SessionCompactionState | undefined {
const parsed = SessionCompactionStateSchema.safeParse(value);
return parsed.success ? parsed.data : undefined;
}
@@ -24,6 +24,7 @@ export const SessionManifestSchema = z.object({
prompt: z.string().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
messages_path: z.string().min(1).optional(),
compaction_path: z.string().min(1).optional(),
});
export type SessionManifest = z.infer<typeof SessionManifestSchema>;
@@ -73,6 +73,7 @@ export interface CreateRootSessionWithArtifactsInput {
export interface RootSessionArtifacts {
manifestPath: string;
messagesPath: string;
compactionPath?: string;
manifest: SessionManifest;
}
@@ -1,10 +1,17 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { SqliteSessionStore } from "../../services/storage/sqlite-session-store";
import { SessionSource } from "../../types/common";
import { createSessionCompactionState } from "../models/session-compaction";
import { FileSessionService } from "../services/file-session-service";
import { CoreSessionService } from "../services/session-service";
@@ -32,6 +39,149 @@ describe("UnifiedSessionPersistenceService", () => {
}
});
it("persists compaction state as a separate session artifact", async () => {
const sessionsDir = mkdtempSync(join(tmpdir(), "compaction-artifact-"));
tempDirs.push(sessionsDir);
const service = new FileSessionService(sessionsDir);
const sessionId = "session-with-compaction";
const artifacts = await service.createRootSessionWithArtifacts({
sessionId,
source: SessionSource.CLI,
pid: process.pid,
interactive: true,
provider: "anthropic",
model: "claude-sonnet",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: true,
enableTeams: false,
startedAt: "2026-01-01T00:00:00.000Z",
});
const sourceMessages = [
{ id: "u1", role: "user" as const, content: "full transcript" },
];
const compactedMessages = [
{ id: "summary", role: "user" as const, content: "summary" },
];
const state = createSessionCompactionState({
sourceMessages,
compactedMessages,
conversationId: "conv-1",
updatedAt: "2026-01-01T00:00:01.000Z",
});
await service.persistSessionMessages(sessionId, sourceMessages);
await service.persistSessionCompactionState(sessionId, state);
const messagesPayload = JSON.parse(
readFileSync(artifacts.messagesPath, "utf8"),
) as { messages?: unknown[] };
const compactionPayload = JSON.parse(
readFileSync(artifacts.compactionPath ?? "", "utf8"),
) as { messages?: unknown[]; source_message_count?: number };
expect(messagesPayload.messages).toHaveLength(1);
expect(compactionPayload).toMatchObject({
source_message_count: 1,
messages: compactedMessages,
});
await expect(
service.readSessionCompactionState(sessionId),
).resolves.toMatchObject({
source_message_count: 1,
messages: compactedMessages,
});
});
it("deletes persisted compaction state without mutating canonical messages", async () => {
const sessionsDir = mkdtempSync(join(tmpdir(), "compaction-delete-"));
tempDirs.push(sessionsDir);
const service = new FileSessionService(sessionsDir);
const sessionId = "session-delete-compaction";
const artifacts = await service.createRootSessionWithArtifacts({
sessionId,
source: SessionSource.CLI,
pid: process.pid,
interactive: true,
provider: "anthropic",
model: "claude-sonnet",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: true,
enableTeams: false,
startedAt: "2026-01-01T00:00:00.000Z",
});
const sourceMessages = [
{ id: "u1", role: "user" as const, content: "full transcript" },
];
const state = createSessionCompactionState({
sourceMessages,
compactedMessages: [
{ id: "summary", role: "user" as const, content: "summary" },
],
updatedAt: "2026-01-01T00:00:01.000Z",
});
await service.persistSessionMessages(sessionId, sourceMessages);
await service.persistSessionCompactionState(sessionId, state);
await service.deleteSessionCompactionState(sessionId);
expect(existsSync(artifacts.messagesPath)).toBe(true);
expect(existsSync(artifacts.compactionPath ?? "")).toBe(false);
await expect(
service.readSessionCompactionState(sessionId),
).resolves.toBeUndefined();
});
it("persists compaction state without backfilling old manifests during path resolution", async () => {
const sessionsDir = mkdtempSync(join(tmpdir(), "compaction-old-manifest-"));
tempDirs.push(sessionsDir);
const service = new FileSessionService(sessionsDir);
const sessionId = "session-old-manifest";
const artifacts = await service.createRootSessionWithArtifacts({
sessionId,
source: SessionSource.CLI,
pid: process.pid,
interactive: true,
provider: "anthropic",
model: "claude-sonnet",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: true,
enableSpawn: true,
enableTeams: false,
startedAt: "2026-01-01T00:00:00.000Z",
});
const manifest = JSON.parse(
readFileSync(artifacts.manifestPath, "utf8"),
) as {
compaction_path?: string;
};
delete manifest.compaction_path;
writeFileSync(
artifacts.manifestPath,
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
const state = createSessionCompactionState({
sourceMessages: [
{ id: "u1", role: "user" as const, content: "full transcript" },
],
compactedMessages: [
{ id: "summary", role: "user" as const, content: "summary" },
],
updatedAt: "2026-01-01T00:00:01.000Z",
});
await service.persistSessionCompactionState(sessionId, state);
expect(existsSync(artifacts.compactionPath ?? "")).toBe(true);
expect(
JSON.parse(readFileSync(artifacts.manifestPath, "utf8")),
).not.toHaveProperty("compaction_path");
});
sqliteIt(
"reconciles dead running sessions into failed manifests with terminal markers",
async () => {
@@ -30,6 +30,7 @@ import type {
SessionPersistenceAdapter,
StoredMessageWithMetadata,
} from "../../types/session";
import type { SessionCompactionState } from "../models/session-compaction";
import type { SessionRow } from "../models/session-row";
import { SessionManifestStore } from "../stores/session-manifest-store";
import { TeamChildSessionManager } from "../team";
@@ -109,6 +110,8 @@ export class UnifiedSessionPersistenceService {
providedId.length > 0 ? providedId : `${Date.now()}_${nanoid(5)}`;
const messagesPath =
this.manifestStore.artifacts.sessionMessagesPath(sessionId);
const compactionPath =
this.manifestStore.artifacts.sessionCompactionPath(sessionId);
const manifestPath =
this.manifestStore.artifacts.sessionManifestPath(sessionId);
const metadata = resolveMetadataWithTitle({
@@ -134,6 +137,7 @@ export class UnifiedSessionPersistenceService {
prompt: input.prompt?.trim() || undefined,
metadata,
messages_path: messagesPath,
compaction_path: compactionPath,
};
await this.adapter.upsertSession({
@@ -172,7 +176,7 @@ export class UnifiedSessionPersistenceService {
startedAt,
);
this.manifestStore.writeSessionManifest(manifestPath, manifest);
return { manifestPath, messagesPath, manifest };
return { manifestPath, messagesPath, compactionPath, manifest };
}
async updateSessionStatus(
@@ -320,6 +324,23 @@ export class UnifiedSessionPersistenceService {
);
}
async readSessionCompactionState(
sessionId: string,
): Promise<SessionCompactionState | undefined> {
return await this.manifestStore.readSessionCompactionState(sessionId);
}
async persistSessionCompactionState(
sessionId: string,
state: SessionCompactionState,
): Promise<void> {
await this.manifestStore.persistSessionCompactionState(sessionId, state);
}
async deleteSessionCompactionState(sessionId: string): Promise<void> {
await this.manifestStore.deleteSessionCompactionState(sessionId);
}
applySubagentStatus(
subSessionId: string,
event: HookEventPayload,
@@ -547,6 +568,9 @@ export class UnifiedSessionPersistenceService {
children.map(async (child) => {
await deleteCheckpointRefs(child.cwd, child.sessionId);
unlinkIfExists(child.messagesPath);
await this.manifestStore.deleteSessionCompactionState(
child.sessionId,
);
unlinkIfExists(
this.manifestStore.artifacts.sessionManifestPath(
child.sessionId,
@@ -561,6 +585,7 @@ export class UnifiedSessionPersistenceService {
await deleteCheckpointRefs(row.cwd, id);
unlinkIfExists(row.messagesPath);
await this.manifestStore.deleteSessionCompactionState(id);
unlinkIfExists(this.manifestStore.artifacts.sessionManifestPath(id, false));
if (row.isSubagent) {
this.manifestStore.artifacts.removeSessionDirIfEmpty(id);
@@ -1,3 +1,4 @@
import { randomUUID } from "node:crypto";
import {
appendFileSync,
existsSync,
@@ -5,6 +6,7 @@ import {
readFileSync,
writeFileSync,
} from "node:fs";
import { mkdir, open, readFile, rename, rm } from "node:fs/promises";
import { dirname, join } from "node:path";
import type * as LlmsProviders from "@cline/llms";
import type { BasicLogger } from "@cline/shared";
@@ -20,11 +22,72 @@ import type {
SessionPersistenceAdapter,
StoredMessageWithMetadata,
} from "../../types/session";
import {
parseSessionCompactionState,
type SessionCompactionState,
SessionCompactionStateSchema,
} from "../models/session-compaction";
import {
type SessionManifest,
SessionManifestSchema,
} from "../models/session-manifest";
async function fsyncBestEffort(path: string): Promise<void> {
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(path, "r");
await handle.sync();
} catch {
// Directory fsync is not available on all platforms/filesystems.
} finally {
if (handle !== undefined) {
try {
await handle.close();
} catch {
// Best-effort durability only.
}
}
}
}
async function writeFileAtomic(path: string, contents: string): Promise<void> {
await mkdir(dirname(path), { recursive: true });
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
let handle: Awaited<ReturnType<typeof open>> | undefined;
try {
handle = await open(tempPath, "w");
await handle.writeFile(contents, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await rename(tempPath, path);
await fsyncBestEffort(dirname(path));
} catch (error) {
if (handle !== undefined) {
try {
await handle.close();
} catch {
// Preserve the original write error.
}
}
try {
await rm(tempPath, { force: true });
} catch {
// Preserve the original write error.
}
throw error;
}
}
function isNotFoundError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "ENOENT"
);
}
export class SessionManifestStore {
readonly artifacts: SessionArtifacts;
@@ -135,6 +198,49 @@ export class SessionManifestStore {
}
}
private resolveCompactionPath(sessionId: string): string {
const { manifest } = this.readManifestFile(sessionId);
return (
manifest?.compaction_path?.trim() ||
this.artifacts.sessionCompactionPath(sessionId)
);
}
async readSessionCompactionState(
sessionId: string,
): Promise<SessionCompactionState | undefined> {
const path = this.resolveCompactionPath(sessionId);
try {
return parseSessionCompactionState(
JSON.parse(await readFile(path, "utf8")) as unknown,
);
} catch (error) {
if (isNotFoundError(error)) {
return undefined;
}
this.logger?.debug("Ignoring invalid session compaction state", {
sessionId,
path,
error,
recovery: "Canonical history is unchanged; deleting the sidecar is safe.",
});
return undefined;
}
}
async persistSessionCompactionState(
sessionId: string,
state: SessionCompactionState,
): Promise<void> {
const path = this.resolveCompactionPath(sessionId);
const payload = SessionCompactionStateSchema.parse(state);
await writeFileAtomic(path, `${JSON.stringify(payload, null, 2)}\n`);
}
async deleteSessionCompactionState(sessionId: string): Promise<void> {
await rm(this.resolveCompactionPath(sessionId), { force: true });
}
appendStaleSessionHookLog(
detectedAt: string,
sessionId: string,
+31
View File
@@ -64,8 +64,32 @@ export interface CoreCompactionContext {
utilizationRatio: number;
}
// Mirrors BudgetPolicyIntent in extensions/context/budget-projection/types.ts.
// Keep this public API type decoupled from the internal projection module.
export type CoreCompactionBudgetPolicyIntent =
| "agentic_summary"
| "basic_compaction_projection"
| "normal_provider_request";
// Mirrors LiveTailHandling in extensions/context/budget-projection/types.ts.
// Keep this public API type decoupled from the internal projection module.
export type CoreCompactionLiveTailHandling =
| "included_verbatim"
| "included_degraded"
| "summarized_as_context"
| "omitted_with_warning"
| "preserved_out_of_band";
export interface CoreCompactionBudgetMetadata {
policyIntent: CoreCompactionBudgetPolicyIntent;
actionCount: number;
warningCount: number;
liveTailHandling: CoreCompactionLiveTailHandling;
}
export interface CoreCompactionResult {
messages: MessageWithMetadata[];
budget?: CoreCompactionBudgetMetadata;
}
export interface CoreCompactionSummarizerConfig {
@@ -74,6 +98,13 @@ export interface CoreCompactionSummarizerConfig {
apiKey?: string;
baseUrl?: string;
headers?: Record<string, string>;
/**
* Optional pre-resolved model metadata for the summarizer. Supplying either
* this or `knownModels` lets agentic compaction budget summary input against
* the summarizer model's actual context window instead of falling back to the
* active model's window.
*/
modelInfo?: ModelInfo;
knownModels?: Record<string, ModelInfo>;
providerConfig?: ProviderConfig;
maxOutputTokens?: number;
+3
View File
@@ -3,6 +3,7 @@ import type { AgentFinishReason } from "@cline/shared";
import type { SessionAccumulatedUsage } from "../runtime/host/runtime-host";
import type { BuiltRuntime } from "../runtime/orchestration/session-runtime";
import type { SessionRuntime } from "../runtime/orchestration/session-runtime-orchestrator";
import type { SessionCompactionState } from "../session/models/session-compaction";
import type { SessionRow } from "../session/models/session-row";
import type { RootSessionArtifacts } from "../session/services/session-service";
import type { SessionSource, SessionStatus } from "./common";
@@ -26,6 +27,8 @@ export type ActiveSession = {
aborting: boolean;
interactive: boolean;
persistedMessages?: LlmsProviders.MessageWithMetadata[];
compactionState?: SessionCompactionState;
compactionStateWriteQueue?: Promise<void>;
activeTeamRunIds: Set<string>;
pendingTeamRunUpdates: TeamRunUpdate[];
teamRunWaiters: Array<() => void>;
+3 -3
View File
@@ -435,9 +435,9 @@ export interface AgentRuntimeConfig {
request: ToolApprovalRequest,
) => Promise<ToolApprovalResult> | ToolApprovalResult;
/**
* Optional host-owned context pipeline that can rewrite the transcript
* before each model request. When it returns messages, the runtime replaces
* its in-memory transcript so compaction persists into the final run result.
* Optional host-owned context pipeline that can project the transcript before
* each model request. Returned messages affect the provider request only; the
* runtime's canonical in-memory transcript remains append-only.
*/
prepareTurn?: (
context: AgentRuntimePrepareTurnContext,
+6 -3
View File
@@ -162,7 +162,9 @@ export interface AgentNoticeEvent extends AgentEventMetadata {
| "completion_without_submit"
| "tool_execution_failed"
| "mistake_limit"
| "auto_compaction";
| "auto_compaction"
| "manual_compaction"
| "compaction_budget_emergency";
metadata?: Record<string, unknown>;
}
@@ -800,8 +802,9 @@ export interface AgentConfig {
*/
logger?: BasicLogger;
/**
* Optional callback that can rewrite the turn input before each model call.
* This is the primary seam for host-owned context pipelines.
* Optional callback that can project the turn input before each model call.
* Returned messages affect the provider request only; the canonical runtime
* transcript remains append-only.
*/
prepareTurn?: (
context: AgentPrepareTurnContext,
+2
View File
@@ -313,6 +313,8 @@ export type HubCommandName =
| "session.restore"
| "session.delete"
| "session.update"
| "session.compaction.get"
| "session.compaction.update"
| "session.pending_prompts"
| "session.update_pending_prompt"
| "session.remove_pending_prompt"