mirror of
https://github.com/cline/cline.git
synced 2026-09-19 02:05:44 +08:00
Add compaction sidecar kill switch
This commit is contained in:
@@ -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" }];
|
||||
|
||||
@@ -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<SessionCompactionState | undefined> => {
|
||||
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,
|
||||
|
||||
@@ -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<void> | undefined;
|
||||
let modeChangeTarget: "plan" | "act" | undefined;
|
||||
|
||||
@@ -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,
|
||||
isCompactionSidecarEnabled:
|
||||
options.isCompactionSidecarEnabled ??
|
||||
createSessionCompactionSidecarEnabledResolver(featureFlags),
|
||||
};
|
||||
const host = await createRuntimeHost(normalizedOptions);
|
||||
const automationOptions = normalizeAutomationOptions(options.automation);
|
||||
const core = new ClineCore(
|
||||
host,
|
||||
options.clientName,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface HubTransportContext {
|
||||
readonly pendingCapabilityRequests: Map<string, PendingCapabilityRequest>;
|
||||
readonly suppressNextTerminalEventBySession: Map<string, string>;
|
||||
readonly telemetry?: ITelemetryService;
|
||||
readonly isCompactionSidecarEnabled: () => boolean;
|
||||
readonly sessionHost: RuntimeHost &
|
||||
Partial<PendingPromptsRuntimeService & SessionUsageRuntimeService>;
|
||||
publish(event: HubEventEnvelope): void;
|
||||
|
||||
@@ -14,6 +14,7 @@ function createContext(
|
||||
pendingApprovals: new Map(),
|
||||
pendingCapabilityRequests: new Map(),
|
||||
suppressNextTerminalEventBySession: new Map(),
|
||||
isCompactionSidecarEnabled: () => true,
|
||||
sessionHost: {
|
||||
startSession: vi.fn(),
|
||||
runTurn: vi.fn(),
|
||||
|
||||
@@ -111,9 +111,9 @@ export async function handleSessionCreate(
|
||||
payload.runtimeOptions && typeof payload.runtimeOptions === "object"
|
||||
? (payload.runtimeOptions as Record<string, unknown>)
|
||||
: {};
|
||||
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<string, unknown>)
|
||||
: {};
|
||||
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,
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface HubWebSocketServerOptions {
|
||||
* Ignored when `sessionHost` is supplied.
|
||||
*/
|
||||
telemetry?: ITelemetryService;
|
||||
isCompactionSidecarEnabled?: () => boolean;
|
||||
}
|
||||
|
||||
export interface HubWebSocketServer {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -109,6 +109,7 @@ function createLocalRuntimeHost(
|
||||
toolPolicies: options.toolPolicies,
|
||||
distinctId,
|
||||
fetch: options.fetch,
|
||||
isCompactionSidecarEnabled: options.isCompactionSidecarEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, ActiveSession>();
|
||||
private readonly usageBySession = new Map<string, SessionAccumulatedUsage>();
|
||||
@@ -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<SessionCompactionState>(
|
||||
"readSessionCompactionState",
|
||||
sessionId,
|
||||
);
|
||||
resumedArtifacts = {
|
||||
manifestPath,
|
||||
messagesPath: existingManifest.messages_path || messagesPath,
|
||||
compactionPath: existingManifest.compaction_path,
|
||||
manifest: existingManifest,
|
||||
};
|
||||
resumedCompactionState = this.isCompactionSidecarEnabled()
|
||||
? await this.invokeOptionalValue<SessionCompactionState>(
|
||||
"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<SessionCompactionState | undefined> {
|
||||
if (!this.isCompactionSidecarEnabled()) {
|
||||
return undefined;
|
||||
}
|
||||
const target = sessionId.trim();
|
||||
if (!target) return undefined;
|
||||
const activeSession = this.sessions.get(target);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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]: true,
|
||||
};
|
||||
|
||||
export const FEATURE_FLAGS: readonly FeatureFlag[] = Object.values(FeatureFlag);
|
||||
|
||||
@@ -77,7 +77,7 @@ export {
|
||||
export { PLUGIN_FILE_EXTENSIONS } from "./extensions/plugin";
|
||||
export {
|
||||
FEATURE_FLAGS,
|
||||
type FeatureFlag,
|
||||
FeatureFlag,
|
||||
FeatureFlagDefaultValue,
|
||||
type FeatureFlagPayload,
|
||||
type FeatureFlagsAndPayloads,
|
||||
|
||||
@@ -91,7 +91,7 @@ export {
|
||||
export { PLUGIN_FILE_EXTENSIONS } from "./extensions/plugin";
|
||||
export {
|
||||
FEATURE_FLAGS,
|
||||
type FeatureFlag,
|
||||
FeatureFlag,
|
||||
FeatureFlagDefaultValue,
|
||||
type FeatureFlagPayload,
|
||||
type FeatureFlagsAndPayloads,
|
||||
|
||||
Reference in New Issue
Block a user