Compare commits

...
21 changed files with 634 additions and 163 deletions
@@ -213,11 +213,12 @@ async function makeRuntime(
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: makeSwitchToActModeTool(),
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
getCompactionSidecarEnabled: () => true,
});
}
describe("createInteractiveSessionRuntime", () => {
@@ -281,20 +282,21 @@ describe("createInteractiveSessionRuntime", () => {
compactionState,
});
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
providerSettingsManager: createProviderSettingsManager(),
chatCommandState: createChatCommandState(),
requestToolApproval: vi.fn(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
getCompactionSidecarEnabled: () => true,
});
await runtime.ensureReady();
const result = await runtime.compactCurrentSession();
@@ -327,6 +329,78 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe(sessionId);
});
it("manual compact runs compaction but reports not compacted when the sidecar flag is off", async () => {
const sessionId = "sess-active-sidecar-off";
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(),
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(),
resolveToolPolicy: () => ({ autoApprove: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
getCompactionSidecarEnabled: () => false,
});
await runtime.ensureReady();
const result = await runtime.compactCurrentSession();
expect(result).toEqual({
messagesBefore: messages.length,
messagesAfter: messages.length,
compacted: false,
});
expect(compactInteractiveMessagesMock).toHaveBeenCalledWith(
expect.objectContaining({
sessionId,
messages,
}),
);
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
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" }];
@@ -364,11 +438,12 @@ describe("createInteractiveSessionRuntime", () => {
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
getCompactionSidecarEnabled: () => true,
});
await runtime.ensureReady();
@@ -461,11 +536,12 @@ describe("createInteractiveSessionRuntime", () => {
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: {} as never,
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
});
onAgentEvent: vi.fn(),
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
getCompactionSidecarEnabled: () => true,
});
await runtime.ensureReady();
await runtime.applyMode("plan");
@@ -3,6 +3,8 @@ import {
type AgentHooks,
type CheckpointEntry,
createSessionCompactionState,
createSessionCompactionSidecarAccess,
createSessionCompactionSidecarEnabledResolver,
isSessionNotFoundError,
type PendingPromptMutationResult,
type ProviderSettingsManager,
@@ -103,8 +105,15 @@ export function createInteractiveSessionRuntime(input: {
onTeamEvent: (event: TeamEvent) => void;
onPendingPrompts: (event: PendingPromptSnapshot) => void;
onPendingPromptSubmitted: (event: PendingPromptSubmittedEvent) => void;
getCompactionSidecarEnabled?: () => boolean;
}) {
let sessionManager: CliCore | undefined;
const getCompactionSidecarEnabled =
input.getCompactionSidecarEnabled ??
createSessionCompactionSidecarEnabledResolver();
const compactionSidecar = createSessionCompactionSidecarAccess(
getCompactionSidecarEnabled,
);
let runtimeHooks: RuntimeHooks | undefined;
let unsubscribeAgent = () => {};
let unsubscribePendingPrompts = () => {};
@@ -213,13 +222,18 @@ export function createInteractiveSessionRuntime(input: {
): Promise<void> => {
const generation = sessionStartGeneration;
const manager = await ensureSessionManager();
const sidecarInitialCompactionState = compactionSidecar.initialState(
initialCompactionState,
);
const started = await manager.start({
source: SessionSource.CLI,
config: buildSessionConfig(),
toolPolicies: input.config.toolPolicies,
interactive: true,
initialMessages: initial,
...(initialCompactionState ? { initialCompactionState } : {}),
...(sidecarInitialCompactionState
? { initialCompactionState: sidecarInitialCompactionState }
: {}),
...(sessionMetadata ? { sessionMetadata } : {}),
localRuntime: {
onTeamRestored: () => {},
@@ -323,7 +337,9 @@ export function createInteractiveSessionRuntime(input: {
return undefined;
}
try {
return await manager.readSessionCompactionState(sessionId);
return await compactionSidecar.read(() =>
manager.readSessionCompactionState(sessionId),
);
} catch (error) {
input.config.logger?.log?.("Failed to read session compaction state", {
sessionId,
@@ -690,9 +706,17 @@ export function createInteractiveSessionRuntime(input: {
compacted: false,
};
}
const compactionState = result.compactionState;
if (!compactionSidecar.enabled) {
return {
messagesBefore,
messagesAfter: messagesBefore,
compacted: false,
};
}
const updated = await manager.updateSessionCompactionState(
sourceSessionId,
result.compactionState,
compactionState,
);
if (!updated.updated) {
throw new Error("Compaction could not be saved. Try again.");
@@ -700,7 +724,7 @@ export function createInteractiveSessionRuntime(input: {
return {
messagesBefore,
messagesAfter: result.canonicalMessages.length,
workingContextMessagesAfter: result.compactionState?.messages.length,
workingContextMessagesAfter: compactionState.messages.length,
compacted: true,
};
};
+4
View File
@@ -1,5 +1,6 @@
import {
getCurrentContextSize,
createSessionCompactionSidecarEnabledResolver,
type ProviderSettings,
ProviderSettingsManager,
type UserInstructionConfigService,
@@ -26,6 +27,7 @@ import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import type { QueuedPromptItem } from "../tui/types";
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
import { applyCliCompactionMode } from "../utils/compaction-mode";
import { getCliFeatureFlagsService } from "../utils/feature-flags";
import {
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
@@ -209,6 +211,8 @@ export async function runInteractive(
onPendingPromptSubmitted: (event) => {
uiEvents.emit("pending-prompt-submitted", event);
},
getCompactionSidecarEnabled:
createSessionCompactionSidecarEnabledResolver(getCliFeatureFlagsService()),
});
let modeChangePromise: Promise<void> | undefined;
let modeChangeTarget: "plan" | "act" | undefined;
+11 -3
View File
@@ -50,6 +50,7 @@ import {
NoOpFeatureFlagsProvider,
} from "./services/feature-flags";
import { resolveCoreDistinctId } from "./services/telemetry/distinct-id";
import { createSessionCompactionSidecarEnabledResolver } from "./session/models/session-compaction";
import type { CoreSessionEvent } from "./types/events";
import type { SessionHistoryRecord } from "./types/sessions";
@@ -202,9 +203,6 @@ export class ClineCore {
static async create(options: ClineCoreOptions = {}): Promise<ClineCore> {
const distinctId = resolveCoreDistinctId(options.distinctId);
const capabilities = normalizeRuntimeCapabilities(options.capabilities);
const normalizedOptions = { ...options, capabilities, distinctId };
const host = await createRuntimeHost(normalizedOptions);
const automationOptions = normalizeAutomationOptions(options.automation);
const featureFlags =
options.featureFlags ||
new FeatureFlagsService({
@@ -216,6 +214,16 @@ export class ClineCore {
clientName: options.clientName,
},
});
const normalizedOptions = {
...options,
capabilities,
distinctId,
getCompactionSidecarEnabled:
options.getCompactionSidecarEnabled ??
createSessionCompactionSidecarEnabledResolver(featureFlags),
};
const host = await createRuntimeHost(normalizedOptions);
const automationOptions = normalizeAutomationOptions(options.automation);
const core = new ClineCore(
host,
options.clientName,
@@ -220,6 +220,12 @@ export interface ClineCoreOptions {
* If omitted, Core uses a no-op provider with default flag values.
*/
featureFlags?: FeatureFlagsService;
/**
* Gets whether persisted compaction sidecar reads and writes are enabled.
* See createSessionCompactionSidecarAccess.
* @internal
*/
getCompactionSidecarEnabled?: () => boolean;
/**
* Optional structured logger for core-side operational diagnostics such as
* runtime-host selection and fallback decisions.
+145 -21
View File
@@ -657,6 +657,68 @@ describe("HubServerTransport boundaries", () => {
await expect(answerPromise).resolves.toBe("Use hub");
});
it("ignores initial compaction sidecar state when the sidecar flag is off", async () => {
let capturedStartInput: StartSessionInput | undefined;
const startSession = vi.fn(async (input: StartSessionInput) => {
capturedStartInput = input;
const sessionId = input.config.sessionId?.trim() || "session-1";
return {
sessionId,
manifest: {
version: 1,
session_id: sessionId,
source: "cli",
pid: 1,
started_at: new Date(0).toISOString(),
status: "running",
interactive: true,
provider: "cline",
model: "test-model",
cwd: "/tmp/project",
workspace_root: "/tmp/project",
enable_tools: true,
enable_spawn: true,
enable_teams: false,
},
manifestPath: "",
messagesPath: "",
result: undefined,
};
});
const transport = createTransport({
sessionHost: { startSession },
getCompactionSidecarEnabled: () => false,
});
const initialCompactionState = createSessionCompactionState({
sourceMessages: [{ role: "user", content: "source" }],
compactedMessages: [{ role: "user", content: "summary" }],
conversationId: "session-1",
});
const reply = await transport.handleCommand({
version: "v1",
requestId: "req-create-sidecar-off",
command: "session.create",
clientId: "client-1",
payload: {
workspaceRoot: "/tmp/project",
cwd: "/tmp/project",
sessionConfig: {
sessionId: "session-1",
providerId: "cline",
modelId: "test-model",
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
systemPrompt: "system",
},
initialCompactionState,
},
});
expect(reply.ok).toBe(true);
expect(capturedStartInput?.initialCompactionState).toBeUndefined();
});
it("does not transfer capability ownership to attached clients", async () => {
let createdSessionId = "";
const startSession = vi.fn(async (input: StartSessionInput) => {
@@ -829,6 +891,7 @@ describe("HubServerTransport boundaries", () => {
it("authorizes compaction sidecar access from server session state, not mutable metadata", async () => {
const readSessionCompactionState = vi.fn();
const transport = createTransport({
getCompactionSidecarEnabled: () => true,
sessionHost: {
getSession: vi.fn().mockResolvedValue({
sessionId: "session-1",
@@ -870,12 +933,13 @@ describe("HubServerTransport boundaries", () => {
const updateSessionCompactionState = vi
.fn()
.mockResolvedValue({ updated: true });
const transport = createTransport({
sessionHost: {
readSessionCompactionState,
updateSessionCompactionState,
},
});
const transport = createTransport({
sessionHost: {
readSessionCompactionState,
updateSessionCompactionState,
},
getCompactionSidecarEnabled: () => true,
});
const ctx = getContext(transport);
expect(ctx.sessionState.has("session-1")).toBe(false);
@@ -930,9 +994,10 @@ describe("HubServerTransport boundaries", () => {
conversationId: "session-1",
});
const readSessionCompactionState = vi.fn().mockResolvedValue(state);
const transport = createTransport({
sessionHost: { readSessionCompactionState },
});
const transport = createTransport({
sessionHost: { readSessionCompactionState },
getCompactionSidecarEnabled: () => true,
});
const ctx = getContext(transport);
ensureSessionParticipant(ctx, "session-1", "viewer-client", "participant");
@@ -966,9 +1031,10 @@ describe("HubServerTransport boundaries", () => {
it("clears compaction sidecar ownership when the owner detaches", async () => {
const readSessionCompactionState = vi.fn();
const transport = createTransport({
sessionHost: { readSessionCompactionState },
});
const transport = createTransport({
sessionHost: { readSessionCompactionState },
getCompactionSidecarEnabled: () => true,
});
const ctx = getContext(transport);
ensureSessionState(ctx, "session-1", "owner-client", "creator");
ensureSessionParticipant(ctx, "session-1", "viewer-client", "participant");
@@ -1006,9 +1072,10 @@ describe("HubServerTransport boundaries", () => {
it("clears compaction sidecar ownership when the owner unregisters", async () => {
const readSessionCompactionState = vi.fn();
const transport = createTransport({
sessionHost: { readSessionCompactionState },
});
const transport = createTransport({
sessionHost: { readSessionCompactionState },
getCompactionSidecarEnabled: () => true,
});
const ctx = getContext(transport);
ensureSessionState(ctx, "session-1", "owner-client", "creator");
ensureSessionParticipant(ctx, "session-1", "viewer-client", "participant");
@@ -1051,6 +1118,7 @@ describe("HubServerTransport boundaries", () => {
});
const readSessionCompactionState = vi.fn().mockResolvedValue(state);
const transport = createTransport({
getCompactionSidecarEnabled: () => true,
sessionHost: { readSessionCompactionState },
});
const ctx = getContext(transport);
@@ -1071,11 +1139,36 @@ describe("HubServerTransport boundaries", () => {
expect(readSessionCompactionState).toHaveBeenCalledWith("session-1");
});
it("does not read compaction sidecar state when the sidecar flag is off", async () => {
const readSessionCompactionState = vi.fn();
const transport = createTransport({
sessionHost: { readSessionCompactionState },
getCompactionSidecarEnabled: () => false,
});
const ctx = getContext(transport);
ensureSessionState(ctx, "session-1", "owner-client", "creator");
const reply = await transport.handleCommand({
version: "v1",
requestId: "req-compact-get-off",
command: "session.compaction.get",
clientId: "owner-client",
sessionId: "session-1",
});
expect(reply).toMatchObject({
ok: true,
payload: { sessionId: "session-1", disabled: true },
});
expect(readSessionCompactionState).not.toHaveBeenCalled();
});
it("rejects invalid compaction sidecar updates before calling the session host", async () => {
const updateSessionCompactionState = vi.fn();
const transport = createTransport({
sessionHost: { updateSessionCompactionState },
});
const transport = createTransport({
sessionHost: { updateSessionCompactionState },
getCompactionSidecarEnabled: () => true,
});
const ctx = getContext(transport);
ensureSessionState(ctx, "session-1", "owner-client", "creator");
@@ -1095,6 +1188,36 @@ describe("HubServerTransport boundaries", () => {
expect(updateSessionCompactionState).not.toHaveBeenCalled();
});
it("does not update compaction sidecar state when the sidecar flag is off", async () => {
const state = createSessionCompactionState({
sourceMessages: [{ role: "user", content: "source" }],
compactedMessages: [{ role: "user", content: "summary" }],
conversationId: "session-1",
});
const updateSessionCompactionState = vi.fn();
const transport = createTransport({
sessionHost: { updateSessionCompactionState },
getCompactionSidecarEnabled: () => false,
});
const ctx = getContext(transport);
ensureSessionState(ctx, "session-1", "owner-client", "creator");
const reply = await transport.handleCommand({
version: "v1",
requestId: "req-compact-update-off",
command: "session.compaction.update",
clientId: "owner-client",
sessionId: "session-1",
payload: { state },
});
expect(reply).toMatchObject({
ok: true,
payload: { sessionId: "session-1", updated: false, disabled: true },
});
expect(updateSessionCompactionState).not.toHaveBeenCalled();
});
it("publishes session updates after successful compaction sidecar updates", async () => {
const state = createSessionCompactionState({
sourceMessages: [{ role: "user", content: "source" }],
@@ -1104,9 +1227,10 @@ describe("HubServerTransport boundaries", () => {
const updateSessionCompactionState = vi
.fn()
.mockResolvedValue({ updated: true });
const transport = createTransport({
sessionHost: { updateSessionCompactionState },
});
const transport = createTransport({
sessionHost: { updateSessionCompactionState },
getCompactionSidecarEnabled: () => true,
});
const ctx = getContext(transport);
const events: HubEventEnvelope[] = [];
ensureSessionState(ctx, "session-1", "owner-client", "creator");
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { HubCommandEnvelope } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createSessionCompactionSidecarAccess } from "../../../session/models/session-compaction";
import { __test__, handleConnectorCommand } from "./connector-handlers";
import type { HubTransportContext } from "./context";
@@ -34,6 +35,7 @@ describe("connector hub handlers", () => {
pendingCapabilityRequests: new Map(),
suppressNextTerminalEventBySession: new Map(),
telemetry: telemetry as never,
compactionSidecar: createSessionCompactionSidecarAccess(() => true),
sessionHost: {} as never,
publish: vi.fn(),
buildEvent: vi.fn() as never,
@@ -14,6 +14,7 @@ import type {
RuntimeHost,
SessionUsageRuntimeService,
} from "../../../runtime/host/runtime-host";
import type { SessionCompactionSidecarAccess } from "../../../session/models/session-compaction";
import {
type CoreSessionSnapshot,
createCoreSessionSnapshot,
@@ -51,6 +52,7 @@ export interface HubTransportContext {
readonly pendingCapabilityRequests: Map<string, PendingCapabilityRequest>;
readonly suppressNextTerminalEventBySession: Map<string, string>;
readonly telemetry?: ITelemetryService;
readonly compactionSidecar: SessionCompactionSidecarAccess;
readonly sessionHost: RuntimeHost &
Partial<PendingPromptsRuntimeService & SessionUsageRuntimeService>;
publish(event: HubEventEnvelope): void;
@@ -1,6 +1,7 @@
import type { HubEventEnvelope } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RuntimeHost } from "../../../runtime/host/runtime-host";
import { createSessionCompactionSidecarAccess } from "../../../session/models/session-compaction";
import { buildHubEvent, type HubTransportContext } from "./context";
import { handleRunAbort, handleSessionInput } from "./run-handlers";
@@ -14,6 +15,7 @@ function createContext(
pendingApprovals: new Map(),
pendingCapabilityRequests: new Map(),
suppressNextTerminalEventBySession: new Map(),
compactionSidecar: createSessionCompactionSidecarAccess(() => true),
sessionHost: {
startSession: vi.fn(),
runTurn: vi.fn(),
@@ -111,8 +111,8 @@ export async function handleSessionCreate(
payload.runtimeOptions && typeof payload.runtimeOptions === "object"
? (payload.runtimeOptions as Record<string, unknown>)
: {};
const initialCompactionState = parseSessionCompactionState(
payload.initialCompactionState,
const initialCompactionState = ctx.compactionSidecar.initialState(
parseSessionCompactionState(payload.initialCompactionState),
);
if (typeof sessionConfig?.mode === "string") {
metadata.mode = sessionConfig.mode;
@@ -387,8 +387,8 @@ export async function handleSessionRestore(
payload.runtimeOptions && typeof payload.runtimeOptions === "object"
? (payload.runtimeOptions as Record<string, unknown>)
: {};
const initialCompactionState = parseSessionCompactionState(
payload.initialCompactionState,
const initialCompactionState = ctx.compactionSidecar.initialState(
parseSessionCompactionState(payload.initialCompactionState),
);
const metadata =
payload.metadata && typeof payload.metadata === "object"
@@ -757,6 +757,9 @@ export async function handleSessionCompactionGet(
`Unknown session: ${sessionId}`,
);
}
if (!ctx.compactionSidecar.enabled) {
return okReply(envelope, { sessionId, state: undefined, disabled: true });
}
const clientId = envelope.clientId?.trim() || "hub-client";
const unauthorized = authorizeSessionCompactionAccess({
sessionId,
@@ -767,7 +770,9 @@ export async function handleSessionCompactionGet(
if (unauthorized) {
return unauthorized;
}
const state = await ctx.sessionHost.readSessionCompactionState(sessionId);
const state = await ctx.compactionSidecar.read(() =>
ctx.sessionHost.readSessionCompactionState(sessionId),
);
return okReply(envelope, { sessionId, state });
}
@@ -841,6 +846,9 @@ export async function handleSessionCompactionUpdate(
`Unknown session: ${sessionId}`,
);
}
if (!ctx.compactionSidecar.enabled) {
return okReply(envelope, { sessionId, updated: false, disabled: true });
}
const unauthorized = authorizeSessionCompactionAccess({
sessionId,
ctx,
@@ -862,9 +870,8 @@ export async function handleSessionCompactionUpdate(
"session.compaction.update requires a valid compaction state",
);
}
const updated = await ctx.sessionHost.updateSessionCompactionState(
sessionId,
state,
const updated = await ctx.compactionSidecar.update(() =>
ctx.sessionHost.updateSessionCompactionState(sessionId, state),
);
const [updatedSession, snapshot] = updated.updated
? await Promise.all([
@@ -43,6 +43,7 @@ export interface HubWebSocketServerOptions {
* Ignored when `sessionHost` is supplied.
*/
telemetry?: ITelemetryService;
getCompactionSidecarEnabled?: () => boolean;
}
export interface HubWebSocketServer {
@@ -15,6 +15,10 @@ import type {
RuntimeHost,
} from "../../runtime/host/runtime-host";
import { SqliteSessionStore } from "../../services/storage/sqlite-session-store";
import {
createSessionCompactionSidecarAccess,
createSessionCompactionSidecarEnabledResolver,
} from "../../session/models/session-compaction";
import { CoreSessionService } from "../../session/services/session-service";
import {
type CoreSettingsListInput,
@@ -183,12 +187,19 @@ export class HubServerTransport implements NativeHubTransport {
private readonly ctx: HubTransportContext;
constructor(readonly options: HubWebSocketServerOptions) {
const getCompactionSidecarEnabled =
options.getCompactionSidecarEnabled ??
createSessionCompactionSidecarEnabledResolver();
const compactionSidecar = createSessionCompactionSidecarAccess(
getCompactionSidecarEnabled,
);
this.sessionHost =
options.sessionHost ??
new LocalRuntimeHost({
sessionService: new CoreSessionService(new SqliteSessionStore()),
fetch: options.fetch,
telemetry: options.telemetry,
getCompactionSidecarEnabled,
});
this.ctx = {
clients: this.clients,
@@ -198,6 +209,7 @@ export class HubServerTransport implements NativeHubTransport {
suppressNextTerminalEventBySession:
this.suppressNextTerminalEventBySession,
telemetry: options.telemetry,
compactionSidecar,
sessionHost: this.sessionHost,
publish: (event) => this.publish(event),
buildEvent: buildHubEvent,
+6 -5
View File
@@ -44,7 +44,6 @@ export type {
ClineAccountActionRequest,
ConnectorHookEvent,
ContentBlock,
FeatureFlag,
FeatureFlagPayload,
FeatureFlagsAndPayloads,
FeatureFlagsContext,
@@ -100,6 +99,7 @@ export {
createTool,
emptyWorkspaceManifest,
FEATURE_FLAGS,
FeatureFlag,
FeatureFlagDefaultValue,
formatDisplayUserInput,
noopBasicLogger,
@@ -734,10 +734,7 @@ export type {
CoreSettingsToggleInput,
CoreSettingsType,
} from "./settings";
export {
CoreSettingsService,
createCoreSettingsService,
} from "./settings";
export { CoreSettingsService, createCoreSettingsService } from "./settings";
export type {
ChatMessage,
ChatSessionConfig,
@@ -883,9 +880,13 @@ export {
type TelemetryServiceOptions,
} from "./services/telemetry/TelemetryService";
export {
createSessionCompactionSidecarAccess,
createSessionCompactionSidecarEnabledResolver,
createSessionCompactionState,
parseSessionCompactionState,
projectSessionCompactionState,
type SessionCompactionSidecarAccess,
type SessionCompactionSidecarUpdateResult,
type SessionCompactionState,
} from "./session/models/session-compaction";
// Compatibility barrel (legacy imports).
@@ -109,6 +109,7 @@ function createLocalRuntimeHost(
toolPolicies: options.toolPolicies,
distinctId,
fetch: options.fetch,
getCompactionSidecarEnabled: options.getCompactionSidecarEnabled,
});
}
@@ -4246,9 +4246,10 @@ describe("LocalRuntimeHost", () => {
tools: [],
shutdown: vi.fn(),
}),
},
createAgent: createAgent as never,
});
},
createAgent: createAgent as never,
getCompactionSidecarEnabled: () => true,
});
await manager.startSession(
normalizeStartInput({
@@ -4313,6 +4314,7 @@ describe("LocalRuntimeHost", () => {
}),
},
createAgent: createAgent as never,
getCompactionSidecarEnabled: () => true,
});
await manager.startSession(
@@ -4386,6 +4388,7 @@ describe("LocalRuntimeHost", () => {
}),
},
createAgent: createAgent as never,
getCompactionSidecarEnabled: () => true,
});
await manager.startSession(
@@ -4413,6 +4416,107 @@ describe("LocalRuntimeHost", () => {
);
});
it("keeps compaction active but ignores sidecar state when the sidecar flag is off", async () => {
const sessionId = "sess-compaction-sidecar-off";
const manifest = createManifest(sessionId);
const initialMessages: MessageWithMetadata[] = [
{ role: "user", content: "canonical source" },
];
const initialCompactionState = createSessionCompactionState({
sourceMessages: initialMessages,
compactedMessages: [{ role: "user", content: "summary" }],
conversationId: sessionId,
updatedAt: "2026-01-01T00:00:00.000Z",
});
const compact = vi.fn().mockResolvedValue({
messages: [{ role: "user", content: "compact summary" }],
});
const sessionService = {
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
manifestPath: "/tmp/manifest-compaction-sidecar-off.json",
messagesPath: "/tmp/messages-compaction-sidecar-off.json",
manifest,
}),
persistSessionMessages: vi.fn(),
persistSessionCompactionState: vi.fn(),
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
writeSessionManifest: vi.fn(),
listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
};
const createAgent = vi.fn().mockReturnValue({
run: vi.fn().mockResolvedValue(createResult()),
continue: vi.fn(),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
getConversationId: vi.fn().mockReturnValue(sessionId),
restore: vi.fn(),
shutdown: vi.fn().mockResolvedValue(undefined),
getMessages: vi.fn().mockReturnValue(initialMessages),
messages: initialMessages,
});
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: sessionService as never,
runtimeBuilder: {
build: vi.fn().mockReturnValue({
tools: [],
shutdown: vi.fn(),
}),
},
createAgent: createAgent as never,
getCompactionSidecarEnabled: () => false,
});
await manager.startSession(
normalizeStartInput({
config: createConfig({
sessionId,
compaction: {
enabled: true,
strategy: "basic",
compact,
maxInputTokens: 10,
},
}),
initialMessages,
initialCompactionState,
interactive: true,
}),
);
const prepareTurn = createAgent.mock.calls[0]?.[0]?.prepareTurn;
expect(prepareTurn).toEqual(expect.any(Function));
await prepareTurn?.({
agentId: "agent-1",
conversationId: sessionId,
parentAgentId: null,
iteration: 1,
abortSignal: new AbortController().signal,
systemPrompt: "",
tools: [],
messages: initialMessages,
apiMessages: initialMessages,
model: {
id: "mock-model",
provider: "mock-provider",
info: { id: "mock-model", maxInputTokens: 10 },
},
});
expect(compact).toHaveBeenCalled();
expect(sessionService.persistSessionCompactionState).not.toHaveBeenCalled();
await expect(
manager.readSessionCompactionState(sessionId),
).resolves.toBeUndefined();
await expect(
manager.updateSessionCompactionState(sessionId, initialCompactionState),
).resolves.toEqual({ updated: false });
});
it("does not project compaction state when compaction is disabled", async () => {
const sessionId = "sess-compaction-disabled";
const manifest = createManifest(sessionId);
@@ -4463,6 +4567,7 @@ describe("LocalRuntimeHost", () => {
}),
},
createAgent: createAgent as never,
getCompactionSidecarEnabled: () => true,
});
await manager.startSession(
@@ -51,7 +51,10 @@ import {
} from "../../services/usage";
import { enrichPromptWithMentions } from "../../services/workspace";
import {
createSessionCompactionSidecarAccess,
createSessionCompactionSidecarEnabledResolver,
projectSessionCompactionState,
type SessionCompactionSidecarAccess,
type SessionCompactionState,
} from "../../session/models/session-compaction";
import {
@@ -201,6 +204,7 @@ export interface LocalRuntimeHostOptions {
providerSettingsManager?: ProviderSettingsManager;
oauthTokenManager?: RuntimeOAuthTokenManager;
telemetry?: ITelemetryService;
getCompactionSidecarEnabled?: () => boolean;
/**
* Default custom `fetch` implementation threaded into every
* `ProviderConfig.fetch` built during local session bootstrap. Used by
@@ -222,6 +226,7 @@ export class LocalRuntimeHost implements RuntimeHost {
private readonly oauthTokenManager: RuntimeOAuthTokenManager;
private readonly defaultTelemetry?: ITelemetryService;
private readonly defaultFetch?: typeof fetch;
private readonly compactionSidecar: SessionCompactionSidecarAccess;
private readonly events = new RuntimeHostEventBus();
private readonly sessions = new Map<string, ActiveSession>();
private readonly usageBySession = new Map<string, SessionAccumulatedUsage>();
@@ -258,6 +263,10 @@ export class LocalRuntimeHost implements RuntimeHost {
this.defaultTelemetry = options.telemetry;
this.defaultTelemetry?.setDistinctId(distinctId);
this.defaultFetch = options.fetch;
this.compactionSidecar = createSessionCompactionSidecarAccess(
options.getCompactionSidecarEnabled ??
createSessionCompactionSidecarEnabledResolver(),
);
this.pendingPromptsController = new PendingPromptsController({
getSession: (sid) => this.sessions.get(sid),
@@ -381,11 +390,12 @@ export class LocalRuntimeHost implements RuntimeHost {
compactionPath: existingManifest.compaction_path,
manifest: existingManifest,
};
resumedCompactionState =
await this.invokeOptionalValue<SessionCompactionState>(
resumedCompactionState = await this.compactionSidecar.read(() =>
this.invokeOptionalValue<SessionCompactionState>(
"readSessionCompactionState",
sessionId,
);
),
);
}
}
const initialAggregateUsage = await this.seedAggregateUsageFromArtifacts({
@@ -487,60 +497,68 @@ export class LocalRuntimeHost implements RuntimeHost {
const compact = createContextCompactionPrepareTurn(configWithProvider);
const rawInitialCompactionState =
explicitInitialCompactionState ?? resumedCompactionState;
const initialCompactionState =
const initialCompactionState = this.compactionSidecar.initialState(
compact && rawInitialCompactionState
? {
...rawInitialCompactionState,
conversation_id:
rawInitialCompactionState.conversation_id?.trim() || sessionId,
}
: undefined;
: undefined,
);
const prepareTurn = compact
? createCompactionStateAwarePrepareTurn({
compact,
getState: () => activeSessionRef?.compactionState,
getState: () =>
this.compactionSidecar.initialState(
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,
},
await this.compactionSidecar.update(async () => {
const activeSession = activeSessionRef;
if (!activeSession) return { updated: false };
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,
},
);
}
return result;
} 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,
},
});
return { updated: false };
}
} 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,
},
});
}
});
},
})
: undefined;
})
: undefined;
const agentConfig = {
sessionId,
@@ -1047,60 +1065,65 @@ export class LocalRuntimeHost implements RuntimeHost {
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(
const result = await this.compactionSidecar.update(async () => {
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,
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 };
if (isIncomingCompactionStateStale(state, current)) {
return { updated: false };
}
await this.invoke<void>("persistSessionCompactionState", target, state);
return { updated: true };
});
return { updated: result.updated };
}
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;
return await this.compactionSidecar.read(async () => {
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);
}
await pendingWrite.catch(() => undefined);
}
}
return await this.invokeOptionalValue<SessionCompactionState>(
"readSessionCompactionState",
target,
);
return await this.invokeOptionalValue<SessionCompactionState>(
"readSessionCompactionState",
target,
);
});
}
private isCompactionStateForSession(
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
createSessionCompactionSidecarEnabledResolver,
createSessionCompactionState,
parseSessionCompactionState,
projectSessionCompactionState,
@@ -23,6 +24,20 @@ describe("session compaction state", () => {
).toThrow("Message role cannot contain ':'");
});
it("enables sidecar use only when the feature flag is explicitly true", () => {
expect(
createSessionCompactionSidecarEnabledResolver({
getBooleanFlagEnabled: () => false,
})(),
).toBe(false);
expect(
createSessionCompactionSidecarEnabledResolver({
getBooleanFlagEnabled: () => true,
})(),
).toBe(true);
expect(createSessionCompactionSidecarEnabledResolver()()).toBe(false);
});
it("rejects projection when the canonical prefix was edited before the boundary", () => {
const sourceMessages = [
{ id: "u1", role: "user" as const, content: "original detail" },
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import {
FeatureFlag,
formatDisplayUserInput,
type MessageWithMetadata,
} from "@cline/shared";
@@ -37,6 +38,60 @@ export type SessionCompactionState = z.infer<
typeof SessionCompactionStateSchema
>;
type CompactionSidecarFeatureFlags = {
getBooleanFlagEnabled(flagName: string): boolean;
};
export function createSessionCompactionSidecarEnabledResolver(
featureFlags?: CompactionSidecarFeatureFlags,
): () => boolean {
return () =>
featureFlags?.getBooleanFlagEnabled(FeatureFlag.COMPACTION_SIDECAR) ??
false;
}
export type SessionCompactionSidecarUpdateResult = {
updated: boolean;
disabled?: true;
};
export interface SessionCompactionSidecarAccess {
readonly enabled: boolean;
initialState(
state: SessionCompactionState | undefined,
): SessionCompactionState | undefined;
read<T>(read: () => Promise<T | undefined>): Promise<T | undefined>;
update(
update: () => Promise<{ updated: boolean }>,
): Promise<SessionCompactionSidecarUpdateResult>;
}
export function createSessionCompactionSidecarAccess(
isEnabled: () => boolean = createSessionCompactionSidecarEnabledResolver(),
): SessionCompactionSidecarAccess {
const enabled: SessionCompactionSidecarAccess = {
enabled: true,
initialState: (state) => state,
read: (read) => read(),
update: (update) => update(),
};
const disabled: SessionCompactionSidecarAccess = {
enabled: false,
initialState: () => undefined,
read: async () => undefined,
update: async () => ({ updated: false, disabled: true }),
};
const current = () => (isEnabled() ? enabled : disabled);
return {
get enabled() {
return current().enabled;
},
initialState: (state) => current().initialState(state),
read: (read) => current().read(read),
update: (update) => current().update(update),
};
}
function cloneMessages(
messages: readonly MessageWithMetadata[],
): MessageWithMetadata[] {
+3
View File
@@ -1,6 +1,8 @@
export const FeatureFlag = {
/** Enables ClinePass provider/model list exposure in supported clients. */
CLINE_PASS: "ext-cline-pass",
/** Enables persisted compaction sidecar projection state. */
COMPACTION_SIDECAR: "sdk-compaction-sidecar",
} as const;
export type KnownFeatureFlag = (typeof FeatureFlag)[keyof typeof FeatureFlag];
@@ -62,6 +64,7 @@ export const FeatureFlagDefaultValue: Partial<
Record<FeatureFlag, FeatureFlagPayload | undefined>
> = {
[FeatureFlag.CLINE_PASS]: false,
[FeatureFlag.COMPACTION_SIDECAR]: false,
};
export const FEATURE_FLAGS: readonly FeatureFlag[] = Object.values(FeatureFlag);
+1 -1
View File
@@ -77,7 +77,7 @@ export {
export { PLUGIN_FILE_EXTENSIONS } from "./extensions/plugin";
export {
FEATURE_FLAGS,
type FeatureFlag,
FeatureFlag,
FeatureFlagDefaultValue,
type FeatureFlagPayload,
type FeatureFlagsAndPayloads,
+1 -1
View File
@@ -91,7 +91,7 @@ export {
export { PLUGIN_FILE_EXTENSIONS } from "./extensions/plugin";
export {
FEATURE_FLAGS,
type FeatureFlag,
FeatureFlag,
FeatureFlagDefaultValue,
type FeatureFlagPayload,
type FeatureFlagsAndPayloads,