mirror of
https://github.com/cline/cline.git
synced 2026-09-13 18:10:14 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2cf6e5411 | ||
|
|
139f29a98e | ||
|
|
0aa19dace0 | ||
|
|
5a9cac393b | ||
|
|
e137a9fbe0 | ||
|
|
4b7aed597e | ||
|
|
9c584ccbfe | ||
|
|
f20e1d2832 |
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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