From d40334a757523e3ea016b0e528b18136382b2d6c Mon Sep 17 00:00:00 2001 From: Robin Newhouse Date: Thu, 25 Jun 2026 19:18:39 -0700 Subject: [PATCH] Add compaction sidecar kill switch --- .../interactive/session-runtime.test.ts | 67 ++++++++++ .../runtime/interactive/session-runtime.ts | 20 ++- apps/cli/src/runtime/run-interactive.ts | 4 + sdk/packages/core/src/ClineCore.ts | 14 +- sdk/packages/core/src/cline-core/types.ts | 5 + .../core/src/hub/server/boundary.test.ts | 124 +++++++++++++++++- .../handlers/connector-handlers.test.ts | 1 + .../core/src/hub/server/handlers/context.ts | 1 + .../hub/server/handlers/run-handlers.test.ts | 1 + .../hub/server/handlers/session-handlers.ts | 18 ++- .../core/src/hub/server/hub-server-options.ts | 1 + .../src/hub/server/hub-server-transport.ts | 6 + sdk/packages/core/src/index.ts | 3 +- sdk/packages/core/src/runtime/host/host.ts | 1 + .../runtime/host/local-runtime-host.test.ts | 80 +++++++++++ .../src/runtime/host/local-runtime-host.ts | 44 +++++-- .../session/models/session-compaction.test.ts | 20 +++ .../src/session/models/session-compaction.ts | 12 ++ sdk/packages/shared/src/feature-flags.ts | 3 + sdk/packages/shared/src/index.browser.ts | 2 +- sdk/packages/shared/src/index.ts | 2 +- 21 files changed, 398 insertions(+), 31 deletions(-) diff --git a/apps/cli/src/runtime/interactive/session-runtime.test.ts b/apps/cli/src/runtime/interactive/session-runtime.test.ts index 3407743531..d227020b41 100644 --- a/apps/cli/src/runtime/interactive/session-runtime.test.ts +++ b/apps/cli/src/runtime/interactive/session-runtime.test.ts @@ -327,6 +327,73 @@ describe("createInteractiveSessionRuntime", () => { expect(runtime.getActiveSessionId()).toBe(sessionId); }); + it("manual compact skips sidecar writes 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(), + isCompactionSidecarEnabled: () => false, + }); + + 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.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" }]; diff --git a/apps/cli/src/runtime/interactive/session-runtime.ts b/apps/cli/src/runtime/interactive/session-runtime.ts index d68263a015..1b2ecb8280 100644 --- a/apps/cli/src/runtime/interactive/session-runtime.ts +++ b/apps/cli/src/runtime/interactive/session-runtime.ts @@ -3,6 +3,7 @@ import { type AgentHooks, type CheckpointEntry, createSessionCompactionState, + createSessionCompactionSidecarEnabledResolver, isSessionNotFoundError, type PendingPromptMutationResult, type ProviderSettingsManager, @@ -96,8 +97,12 @@ export function createInteractiveSessionRuntime(input: { onTeamEvent: (event: TeamEvent) => void; onPendingPrompts: (event: PendingPromptSnapshot) => void; onPendingPromptSubmitted: (event: PendingPromptSubmittedEvent) => void; + isCompactionSidecarEnabled?: () => boolean; }) { let sessionManager: CliCore | undefined; + const isCompactionSidecarEnabled = + input.isCompactionSidecarEnabled ?? + createSessionCompactionSidecarEnabledResolver(); let runtimeHooks: RuntimeHooks | undefined; let unsubscribeAgent = () => {}; let unsubscribePendingPrompts = () => {}; @@ -210,7 +215,9 @@ export function createInteractiveSessionRuntime(input: { toolPolicies: input.config.toolPolicies, interactive: true, initialMessages: initial, - ...(initialCompactionState ? { initialCompactionState } : {}), + ...(isCompactionSidecarEnabled() && initialCompactionState + ? { initialCompactionState } + : {}), ...(sessionMetadata ? { sessionMetadata } : {}), localRuntime: { onTeamRestored: () => {}, @@ -291,6 +298,9 @@ export function createInteractiveSessionRuntime(input: { const readCompactionState = async ( sessionId: string, ): Promise => { + if (!isCompactionSidecarEnabled()) { + return undefined; + } const manager = sessionManager; if (!manager) { return undefined; @@ -624,6 +634,14 @@ export function createInteractiveSessionRuntime(input: { compacted: false, }; } + if (!isCompactionSidecarEnabled()) { + return { + messagesBefore, + messagesAfter: result.canonicalMessages.length, + workingContextMessagesAfter: result.compactionState.messages.length, + compacted: true, + }; + } const updated = await manager.updateSessionCompactionState( sourceSessionId, result.compactionState, diff --git a/apps/cli/src/runtime/run-interactive.ts b/apps/cli/src/runtime/run-interactive.ts index a665b0ec95..0cd61cf266 100644 --- a/apps/cli/src/runtime/run-interactive.ts +++ b/apps/cli/src/runtime/run-interactive.ts @@ -1,5 +1,6 @@ import { getCurrentContextSize, + createSessionCompactionSidecarEnabledResolver, type ProviderSettings, ProviderSettingsManager, type UserInstructionConfigService, @@ -25,6 +26,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, @@ -201,6 +203,8 @@ export async function runInteractive( onPendingPromptSubmitted: (event) => { uiEvents.emit("pending-prompt-submitted", event); }, + isCompactionSidecarEnabled: + createSessionCompactionSidecarEnabledResolver(getCliFeatureFlagsService()), }); let modeChangePromise: Promise | undefined; let modeChangeTarget: "plan" | "act" | undefined; diff --git a/sdk/packages/core/src/ClineCore.ts b/sdk/packages/core/src/ClineCore.ts index 417680e461..1ebb31b6a2 100644 --- a/sdk/packages/core/src/ClineCore.ts +++ b/sdk/packages/core/src/ClineCore.ts @@ -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 { 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, + isCompactionSidecarEnabled: + options.isCompactionSidecarEnabled ?? + createSessionCompactionSidecarEnabledResolver(featureFlags), + }; + const host = await createRuntimeHost(normalizedOptions); + const automationOptions = normalizeAutomationOptions(options.automation); const core = new ClineCore( host, options.clientName, diff --git a/sdk/packages/core/src/cline-core/types.ts b/sdk/packages/core/src/cline-core/types.ts index a0e0d3de51..2f3c8866fc 100644 --- a/sdk/packages/core/src/cline-core/types.ts +++ b/sdk/packages/core/src/cline-core/types.ts @@ -220,6 +220,11 @@ export interface ClineCoreOptions { * If omitted, Core uses a no-op provider with default flag values. */ featureFlags?: FeatureFlagsService; + /** + * Overrides the compaction sidecar rollout decision. + * @internal + */ + isCompactionSidecarEnabled?: () => boolean; /** * Optional structured logger for core-side operational diagnostics such as * runtime-host selection and fallback decisions. diff --git a/sdk/packages/core/src/hub/server/boundary.test.ts b/sdk/packages/core/src/hub/server/boundary.test.ts index 512631bb10..d0b3c6a3c4 100644 --- a/sdk/packages/core/src/hub/server/boundary.test.ts +++ b/sdk/packages/core/src/hub/server/boundary.test.ts @@ -654,11 +654,73 @@ describe("HubServerTransport boundaries", () => { }, }); - await expect(answerPromise).resolves.toBe("Use hub"); - }); + await expect(answerPromise).resolves.toBe("Use hub"); + }); - it("does not transfer capability ownership to attached clients", async () => { - let createdSessionId = ""; + 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 }, + isCompactionSidecarEnabled: () => 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) => { createdSessionId = input.config.sessionId?.trim() || "missing-session"; return { @@ -1071,6 +1133,30 @@ 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 }, + isCompactionSidecarEnabled: () => 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({ @@ -1095,6 +1181,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 }, + isCompactionSidecarEnabled: () => 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" }], diff --git a/sdk/packages/core/src/hub/server/handlers/connector-handlers.test.ts b/sdk/packages/core/src/hub/server/handlers/connector-handlers.test.ts index 293d96d662..1b182fb06f 100644 --- a/sdk/packages/core/src/hub/server/handlers/connector-handlers.test.ts +++ b/sdk/packages/core/src/hub/server/handlers/connector-handlers.test.ts @@ -34,6 +34,7 @@ describe("connector hub handlers", () => { pendingCapabilityRequests: new Map(), suppressNextTerminalEventBySession: new Map(), telemetry: telemetry as never, + isCompactionSidecarEnabled: () => true, sessionHost: {} as never, publish: vi.fn(), buildEvent: vi.fn() as never, diff --git a/sdk/packages/core/src/hub/server/handlers/context.ts b/sdk/packages/core/src/hub/server/handlers/context.ts index 69ee577368..2cbbb1cca1 100644 --- a/sdk/packages/core/src/hub/server/handlers/context.ts +++ b/sdk/packages/core/src/hub/server/handlers/context.ts @@ -51,6 +51,7 @@ export interface HubTransportContext { readonly pendingCapabilityRequests: Map; readonly suppressNextTerminalEventBySession: Map; readonly telemetry?: ITelemetryService; + readonly isCompactionSidecarEnabled: () => boolean; readonly sessionHost: RuntimeHost & Partial; publish(event: HubEventEnvelope): void; diff --git a/sdk/packages/core/src/hub/server/handlers/run-handlers.test.ts b/sdk/packages/core/src/hub/server/handlers/run-handlers.test.ts index 968eaf40e1..392f303c09 100644 --- a/sdk/packages/core/src/hub/server/handlers/run-handlers.test.ts +++ b/sdk/packages/core/src/hub/server/handlers/run-handlers.test.ts @@ -14,6 +14,7 @@ function createContext( pendingApprovals: new Map(), pendingCapabilityRequests: new Map(), suppressNextTerminalEventBySession: new Map(), + isCompactionSidecarEnabled: () => true, sessionHost: { startSession: vi.fn(), runTurn: vi.fn(), diff --git a/sdk/packages/core/src/hub/server/handlers/session-handlers.ts b/sdk/packages/core/src/hub/server/handlers/session-handlers.ts index 68c4317c95..a8816dadf5 100644 --- a/sdk/packages/core/src/hub/server/handlers/session-handlers.ts +++ b/sdk/packages/core/src/hub/server/handlers/session-handlers.ts @@ -111,9 +111,9 @@ export async function handleSessionCreate( payload.runtimeOptions && typeof payload.runtimeOptions === "object" ? (payload.runtimeOptions as Record) : {}; - const initialCompactionState = parseSessionCompactionState( - payload.initialCompactionState, - ); + const initialCompactionState = ctx.isCompactionSidecarEnabled() + ? parseSessionCompactionState(payload.initialCompactionState) + : undefined; if (typeof sessionConfig?.mode === "string") { metadata.mode = sessionConfig.mode; } else if (typeof runtimeOptions.mode === "string") { @@ -387,9 +387,9 @@ export async function handleSessionRestore( payload.runtimeOptions && typeof payload.runtimeOptions === "object" ? (payload.runtimeOptions as Record) : {}; - const initialCompactionState = parseSessionCompactionState( - payload.initialCompactionState, - ); + const initialCompactionState = ctx.isCompactionSidecarEnabled() + ? parseSessionCompactionState(payload.initialCompactionState) + : undefined; const metadata = payload.metadata && typeof payload.metadata === "object" ? JSON.parse(JSON.stringify(payload.metadata)) @@ -757,6 +757,9 @@ export async function handleSessionCompactionGet( `Unknown session: ${sessionId}`, ); } + if (!ctx.isCompactionSidecarEnabled()) { + return okReply(envelope, { sessionId, state: undefined, disabled: true }); + } const clientId = envelope.clientId?.trim() || "hub-client"; const unauthorized = authorizeSessionCompactionAccess({ sessionId, @@ -841,6 +844,9 @@ export async function handleSessionCompactionUpdate( `Unknown session: ${sessionId}`, ); } + if (!ctx.isCompactionSidecarEnabled()) { + return okReply(envelope, { sessionId, updated: false, disabled: true }); + } const unauthorized = authorizeSessionCompactionAccess({ sessionId, ctx, diff --git a/sdk/packages/core/src/hub/server/hub-server-options.ts b/sdk/packages/core/src/hub/server/hub-server-options.ts index ae5bd1f356..eb8b47af36 100644 --- a/sdk/packages/core/src/hub/server/hub-server-options.ts +++ b/sdk/packages/core/src/hub/server/hub-server-options.ts @@ -43,6 +43,7 @@ export interface HubWebSocketServerOptions { * Ignored when `sessionHost` is supplied. */ telemetry?: ITelemetryService; + isCompactionSidecarEnabled?: () => boolean; } export interface HubWebSocketServer { diff --git a/sdk/packages/core/src/hub/server/hub-server-transport.ts b/sdk/packages/core/src/hub/server/hub-server-transport.ts index 161122eadb..d9c45d40b9 100644 --- a/sdk/packages/core/src/hub/server/hub-server-transport.ts +++ b/sdk/packages/core/src/hub/server/hub-server-transport.ts @@ -15,6 +15,7 @@ import type { RuntimeHost, } from "../../runtime/host/runtime-host"; import { SqliteSessionStore } from "../../services/storage/sqlite-session-store"; +import { createSessionCompactionSidecarEnabledResolver } from "../../session/models/session-compaction"; import { CoreSessionService } from "../../session/services/session-service"; import { type CoreSettingsListInput, @@ -183,12 +184,16 @@ export class HubServerTransport implements NativeHubTransport { private readonly ctx: HubTransportContext; constructor(readonly options: HubWebSocketServerOptions) { + const isCompactionSidecarEnabled = + options.isCompactionSidecarEnabled ?? + createSessionCompactionSidecarEnabledResolver(); this.sessionHost = options.sessionHost ?? new LocalRuntimeHost({ sessionService: new CoreSessionService(new SqliteSessionStore()), fetch: options.fetch, telemetry: options.telemetry, + isCompactionSidecarEnabled, }); this.ctx = { clients: this.clients, @@ -198,6 +203,7 @@ export class HubServerTransport implements NativeHubTransport { suppressNextTerminalEventBySession: this.suppressNextTerminalEventBySession, telemetry: options.telemetry, + isCompactionSidecarEnabled, sessionHost: this.sessionHost, publish: (event) => this.publish(event), buildEvent: buildHubEvent, diff --git a/sdk/packages/core/src/index.ts b/sdk/packages/core/src/index.ts index f0b11d360c..b20a904d6c 100644 --- a/sdk/packages/core/src/index.ts +++ b/sdk/packages/core/src/index.ts @@ -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, @@ -879,6 +879,7 @@ export { type TelemetryServiceOptions, } from "./services/telemetry/TelemetryService"; export { + createSessionCompactionSidecarEnabledResolver, createSessionCompactionState, parseSessionCompactionState, projectSessionCompactionState, diff --git a/sdk/packages/core/src/runtime/host/host.ts b/sdk/packages/core/src/runtime/host/host.ts index 63fff2d2e1..8a8b92053d 100644 --- a/sdk/packages/core/src/runtime/host/host.ts +++ b/sdk/packages/core/src/runtime/host/host.ts @@ -109,6 +109,7 @@ function createLocalRuntimeHost( toolPolicies: options.toolPolicies, distinctId, fetch: options.fetch, + isCompactionSidecarEnabled: options.isCompactionSidecarEnabled, }); } diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts index 892323c213..c44af8c82c 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.test.ts @@ -4413,6 +4413,86 @@ 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 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, + isCompactionSidecarEnabled: () => false, + }); + + await manager.startSession( + normalizeStartInput({ + config: createConfig({ + sessionId, + compaction: { + enabled: true, + strategy: "basic", + compact: vi.fn(), + }, + }), + initialMessages, + initialCompactionState, + interactive: true, + }), + ); + + expect(createAgent.mock.calls[0]?.[0]?.prepareTurn).toEqual( + expect.any(Function), + ); + 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); diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.ts index 1758756476..e87188f3c1 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.ts @@ -51,6 +51,7 @@ import { } from "../../services/usage"; import { enrichPromptWithMentions } from "../../services/workspace"; import { + createSessionCompactionSidecarEnabledResolver, projectSessionCompactionState, type SessionCompactionState, } from "../../session/models/session-compaction"; @@ -201,6 +202,7 @@ export interface LocalRuntimeHostOptions { providerSettingsManager?: ProviderSettingsManager; oauthTokenManager?: RuntimeOAuthTokenManager; telemetry?: ITelemetryService; + isCompactionSidecarEnabled?: () => boolean; /** * Default custom `fetch` implementation threaded into every * `ProviderConfig.fetch` built during local session bootstrap. Used by @@ -222,6 +224,7 @@ export class LocalRuntimeHost implements RuntimeHost { private readonly oauthTokenManager: RuntimeOAuthTokenManager; private readonly defaultTelemetry?: ITelemetryService; private readonly defaultFetch?: typeof fetch; + private readonly isCompactionSidecarEnabled: () => boolean; private readonly events = new RuntimeHostEventBus(); private readonly sessions = new Map(); private readonly usageBySession = new Map(); @@ -254,10 +257,13 @@ export class LocalRuntimeHost implements RuntimeHost { new RuntimeOAuthTokenManager({ providerSettingsManager: this.providerSettingsManager, telemetry: options.telemetry, - }); + }); this.defaultTelemetry = options.telemetry; this.defaultTelemetry?.setDistinctId(distinctId); this.defaultFetch = options.fetch; + this.isCompactionSidecarEnabled = + options.isCompactionSidecarEnabled ?? + createSessionCompactionSidecarEnabledResolver(); this.pendingPromptsController = new PendingPromptsController({ getSession: (sid) => this.sessions.get(sid), @@ -375,17 +381,18 @@ export class LocalRuntimeHost implements RuntimeHost { ); if (existingManifest) { manifest = existingManifest; - resumedArtifacts = { - manifestPath, - messagesPath: existingManifest.messages_path || messagesPath, - compactionPath: existingManifest.compaction_path, - manifest: existingManifest, - }; - resumedCompactionState = - await this.invokeOptionalValue( - "readSessionCompactionState", - sessionId, - ); + resumedArtifacts = { + manifestPath, + messagesPath: existingManifest.messages_path || messagesPath, + compactionPath: existingManifest.compaction_path, + manifest: existingManifest, + }; + resumedCompactionState = this.isCompactionSidecarEnabled() + ? await this.invokeOptionalValue( + "readSessionCompactionState", + sessionId, + ) + : undefined; } } const initialAggregateUsage = await this.seedAggregateUsageFromArtifacts({ @@ -485,10 +492,11 @@ export class LocalRuntimeHost implements RuntimeHost { const explicitInitialCompactionState = startInput.initialCompactionState; let activeSessionRef: ActiveSession | undefined; const compact = createContextCompactionPrepareTurn(configWithProvider); + const sidecarEnabled = this.isCompactionSidecarEnabled(); const rawInitialCompactionState = explicitInitialCompactionState ?? resumedCompactionState; const initialCompactionState = - compact && rawInitialCompactionState + sidecarEnabled && compact && rawInitialCompactionState ? { ...rawInitialCompactionState, conversation_id: @@ -498,8 +506,10 @@ export class LocalRuntimeHost implements RuntimeHost { const prepareTurn = compact ? createCompactionStateAwarePrepareTurn({ compact, - getState: () => activeSessionRef?.compactionState, + getState: () => + sidecarEnabled ? activeSessionRef?.compactionState : undefined, saveState: async (state) => { + if (!sidecarEnabled) return; const activeSession = activeSessionRef; if (!activeSession) return; const stateForSession = { @@ -1047,6 +1057,9 @@ export class LocalRuntimeHost implements RuntimeHost { sessionId: string, state: SessionCompactionState, ): Promise<{ updated: boolean }> { + if (!this.isCompactionSidecarEnabled()) { + return { updated: false }; + } const target = sessionId.trim(); if (!target) return { updated: false }; const activeSession = this.sessions.get(target); @@ -1085,6 +1098,9 @@ export class LocalRuntimeHost implements RuntimeHost { async readSessionCompactionState( sessionId: string, ): Promise { + if (!this.isCompactionSidecarEnabled()) { + return undefined; + } const target = sessionId.trim(); if (!target) return undefined; const activeSession = this.sessions.get(target); diff --git a/sdk/packages/core/src/session/models/session-compaction.test.ts b/sdk/packages/core/src/session/models/session-compaction.test.ts index a0e5b04f74..d8dc912fa2 100644 --- a/sdk/packages/core/src/session/models/session-compaction.test.ts +++ b/sdk/packages/core/src/session/models/session-compaction.test.ts @@ -1,11 +1,31 @@ import { describe, expect, it } from "vitest"; import { + createSessionCompactionSidecarEnabledResolver, createSessionCompactionState, parseSessionCompactionState, projectSessionCompactionState, } from "./session-compaction"; describe("session compaction state", () => { + it("enables sidecar use unless the feature flag payload is explicitly false", () => { + expect( + createSessionCompactionSidecarEnabledResolver({ + getFlagPayload: () => false, + })(), + ).toBe(false); + expect( + createSessionCompactionSidecarEnabledResolver({ + getFlagPayload: () => true, + })(), + ).toBe(true); + expect( + createSessionCompactionSidecarEnabledResolver({ + getFlagPayload: () => undefined, + })(), + ).toBe(true); + expect(createSessionCompactionSidecarEnabledResolver()()).toBe(true); + }); + it("rejects projection when the canonical prefix was edited before the boundary", () => { const sourceMessages = [ { id: "u1", role: "user" as const, content: "original detail" }, diff --git a/sdk/packages/core/src/session/models/session-compaction.ts b/sdk/packages/core/src/session/models/session-compaction.ts index 901bbe2cfb..9b31f62501 100644 --- a/sdk/packages/core/src/session/models/session-compaction.ts +++ b/sdk/packages/core/src/session/models/session-compaction.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { + FeatureFlag, formatDisplayUserInput, type MessageWithMetadata, } from "@cline/shared"; @@ -37,6 +38,17 @@ export type SessionCompactionState = z.infer< typeof SessionCompactionStateSchema >; +type CompactionSidecarFeatureFlags = { + getFlagPayload(flagName: string): unknown; +}; + +export function createSessionCompactionSidecarEnabledResolver( + featureFlags?: CompactionSidecarFeatureFlags, +): () => boolean { + return () => + featureFlags?.getFlagPayload(FeatureFlag.COMPACTION_SIDECAR) !== false; +} + function cloneMessages( messages: readonly MessageWithMetadata[], ): MessageWithMetadata[] { diff --git a/sdk/packages/shared/src/feature-flags.ts b/sdk/packages/shared/src/feature-flags.ts index 447d5f799e..41fc04390f 100644 --- a/sdk/packages/shared/src/feature-flags.ts +++ b/sdk/packages/shared/src/feature-flags.ts @@ -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.CLINE_PASS]: false, + [FeatureFlag.COMPACTION_SIDECAR]: true, }; export const FEATURE_FLAGS: readonly FeatureFlag[] = Object.values(FeatureFlag); diff --git a/sdk/packages/shared/src/index.browser.ts b/sdk/packages/shared/src/index.browser.ts index 3fd14bec80..a9676d6e74 100644 --- a/sdk/packages/shared/src/index.browser.ts +++ b/sdk/packages/shared/src/index.browser.ts @@ -77,7 +77,7 @@ export { export { PLUGIN_FILE_EXTENSIONS } from "./extensions/plugin"; export { FEATURE_FLAGS, - type FeatureFlag, + FeatureFlag, FeatureFlagDefaultValue, type FeatureFlagPayload, type FeatureFlagsAndPayloads, diff --git a/sdk/packages/shared/src/index.ts b/sdk/packages/shared/src/index.ts index 364ccf0935..82dcbd4475 100644 --- a/sdk/packages/shared/src/index.ts +++ b/sdk/packages/shared/src/index.ts @@ -91,7 +91,7 @@ export { export { PLUGIN_FILE_EXTENSIONS } from "./extensions/plugin"; export { FEATURE_FLAGS, - type FeatureFlag, + FeatureFlag, FeatureFlagDefaultValue, type FeatureFlagPayload, type FeatureFlagsAndPayloads,