mirror of
https://github.com/cline/cline.git
synced 2026-09-21 05:10:09 +08:00
Tighten compaction sidecar safety
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import type * as LlmsProviders from "@cline/llms";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSessionCompactionState } from "../../session/models/session-compaction";
|
||||
import type { CoreCompactionContext } from "../../types/config";
|
||||
import { runBasicCompaction } from "./basic-compaction";
|
||||
import { createContextCompactionPrepareTurn } from "./compaction";
|
||||
import {
|
||||
createCompactionStateAwarePrepareTurn,
|
||||
createContextCompactionPrepareTurn,
|
||||
} from "./compaction";
|
||||
import {
|
||||
createTokenEstimator,
|
||||
resolveSummarizerConfig,
|
||||
@@ -1726,4 +1730,49 @@ describe("createContextCompactionPrepareTurn", () => {
|
||||
"manual",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps stale sidecar state when replacement compaction returns no result", async () => {
|
||||
const originalMessages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: "original" },
|
||||
];
|
||||
const existingState = createSessionCompactionState({
|
||||
sourceMessages: originalMessages,
|
||||
compactedMessages: [{ role: "user", content: "summary" }],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const compact = vi.fn().mockResolvedValue(undefined);
|
||||
const saveState = vi.fn();
|
||||
const prepareTurn = createCompactionStateAwarePrepareTurn({
|
||||
compact,
|
||||
getState: () => existingState,
|
||||
saveState,
|
||||
});
|
||||
const currentMessages: LlmsProviders.Message[] = [
|
||||
{ role: "user", content: "edited original" },
|
||||
{ role: "assistant", content: "tail" },
|
||||
];
|
||||
|
||||
const result = await prepareTurn({
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
parentAgentId: null,
|
||||
iteration: 1,
|
||||
abortSignal: new AbortController().signal,
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
messages: currentMessages,
|
||||
apiMessages: currentMessages,
|
||||
model: {
|
||||
id: "mock-model",
|
||||
provider: "anthropic",
|
||||
info: { id: "mock-model", maxInputTokens: 100_000 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(compact).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ messages: currentMessages }),
|
||||
);
|
||||
expect(saveState).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -442,7 +442,6 @@ export function createCompactionStateAwarePrepareTurn(input: {
|
||||
compact?: ContextPipelinePrepareTurn;
|
||||
getState?: () => SessionCompactionState | undefined;
|
||||
saveState?: (state: SessionCompactionState) => void | Promise<void>;
|
||||
clearState?: () => void | Promise<void>;
|
||||
}): ContextPipelinePrepareTurn {
|
||||
return async (context) => {
|
||||
const existingState = input.getState?.();
|
||||
@@ -484,10 +483,6 @@ export function createCompactionStateAwarePrepareTurn(input: {
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (existingState) {
|
||||
await input.clearState?.();
|
||||
}
|
||||
|
||||
const result = input.compact ? await input.compact(context) : undefined;
|
||||
if (result?.messages) {
|
||||
const nextState = createSessionCompactionState({
|
||||
|
||||
@@ -539,32 +539,8 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
});
|
||||
}
|
||||
},
|
||||
clearState: async () => {
|
||||
const activeSession = activeSessionRef;
|
||||
if (!activeSession?.compactionState) return;
|
||||
try {
|
||||
await this.clearActiveSessionCompactionState(activeSession);
|
||||
} catch (error) {
|
||||
configWithProvider.logger?.error?.(
|
||||
"Failed to delete stale session compaction state",
|
||||
{ sessionId: activeSession.sessionId, error },
|
||||
);
|
||||
captureSdkError(configWithProvider.telemetry, {
|
||||
component: "core",
|
||||
operation: "session.delete_compaction_state",
|
||||
severity: "warn",
|
||||
handled: true,
|
||||
error,
|
||||
context: {
|
||||
sessionId: activeSession.sessionId,
|
||||
providerId: configWithProvider.providerId,
|
||||
modelId: configWithProvider.modelId,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const agentConfig = {
|
||||
sessionId,
|
||||
@@ -1196,21 +1172,6 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
});
|
||||
}
|
||||
|
||||
private async clearActiveSessionCompactionState(
|
||||
session: ActiveSession,
|
||||
): Promise<void> {
|
||||
await this.enqueueCompactionStateWrite(session, async () => {
|
||||
if (!session.compactionState) {
|
||||
return;
|
||||
}
|
||||
await this.invoke<void>(
|
||||
"deleteSessionCompactionState",
|
||||
session.sessionId,
|
||||
);
|
||||
session.compactionState = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private async enqueueCompactionStateWrite<T>(
|
||||
session: ActiveSession,
|
||||
action: () => Promise<T>,
|
||||
|
||||
@@ -112,6 +112,29 @@ describe("session compaction state", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("projects legacy sidecars when the boundary key matches", () => {
|
||||
const sourceMessages = [
|
||||
{ id: "u1", role: "user" as const, content: "original detail" },
|
||||
{ id: "a1", role: "assistant" as const, content: "answer" },
|
||||
];
|
||||
const tail = { id: "u2", role: "user" as const, content: "tail" };
|
||||
const state = parseSessionCompactionState({
|
||||
version: 1,
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
source_message_count: sourceMessages.length,
|
||||
source_last_message_key: "id:a1",
|
||||
messages: [{ id: "summary", role: "user" as const, content: "summary" }],
|
||||
});
|
||||
|
||||
expect(state).toBeDefined();
|
||||
if (!state) {
|
||||
throw new Error("expected parsed compaction state");
|
||||
}
|
||||
expect(
|
||||
projectSessionCompactionState(state, [...sourceMessages, tail]),
|
||||
).toEqual([{ id: "summary", role: "user", content: "summary" }, tail]);
|
||||
});
|
||||
|
||||
it("rejects malformed sidecar timestamps", () => {
|
||||
const state = parseSessionCompactionState({
|
||||
version: 1,
|
||||
|
||||
@@ -139,6 +139,8 @@ function messageBoundaryKey(message: MessageWithMetadata | undefined): string {
|
||||
return "";
|
||||
}
|
||||
const normalized = normalizeMessageForSourceHash(message);
|
||||
// Message roles are limited to "user" and "assistant", so ":" is only a
|
||||
// separator in persisted fallback boundary keys.
|
||||
if (typeof normalized.id === "string" && normalized.id.trim()) {
|
||||
return `id:${normalized.id.trim()}`;
|
||||
}
|
||||
@@ -148,6 +150,8 @@ function messageBoundaryKey(message: MessageWithMetadata | undefined): string {
|
||||
return `content:${normalized.role}:${JSON.stringify(normalized.content)}`;
|
||||
}
|
||||
|
||||
// These anchors are persisted in session sidecars. Changing the format is safe
|
||||
// for saved transcripts, but invalidates existing compaction sidecars.
|
||||
function sourcePrefixHash(
|
||||
messages: readonly MessageWithMetadata[],
|
||||
count = messages.length,
|
||||
@@ -193,24 +197,27 @@ export function projectSessionCompactionState(
|
||||
state: SessionCompactionState,
|
||||
sourceMessages: readonly MessageWithMetadata[],
|
||||
): MessageWithMetadata[] | undefined {
|
||||
if (state.source_message_count > sourceMessages.length) {
|
||||
const hasEnoughSourceMessages =
|
||||
state.source_message_count <= sourceMessages.length;
|
||||
if (!hasEnoughSourceMessages) {
|
||||
return undefined;
|
||||
}
|
||||
if (state.source_prefix_hash) {
|
||||
if (
|
||||
sourcePrefixHash(sourceMessages, state.source_message_count) !==
|
||||
state.source_prefix_hash
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
} else if (state.source_message_count > 0 && state.source_last_message_key) {
|
||||
const boundary = sourceMessages[state.source_message_count - 1];
|
||||
if (messageBoundaryKey(boundary) !== state.source_last_message_key) {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
|
||||
const hasMatchingSourcePrefix =
|
||||
!!state.source_prefix_hash &&
|
||||
sourcePrefixHash(sourceMessages, state.source_message_count) ===
|
||||
state.source_prefix_hash;
|
||||
const boundary = sourceMessages[state.source_message_count - 1];
|
||||
const hasMatchingLegacyBoundary =
|
||||
!state.source_prefix_hash &&
|
||||
state.source_message_count > 0 &&
|
||||
!!state.source_last_message_key &&
|
||||
messageBoundaryKey(boundary) === state.source_last_message_key;
|
||||
const canProjectState = hasMatchingSourcePrefix || hasMatchingLegacyBoundary;
|
||||
if (!canProjectState) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [
|
||||
...cloneMessages(state.messages),
|
||||
...cloneMessages(sourceMessages.slice(state.source_message_count)),
|
||||
|
||||
@@ -125,6 +125,7 @@ describe("UnifiedSessionPersistenceService", () => {
|
||||
|
||||
await service.persistSessionMessages(sessionId, sourceMessages);
|
||||
await service.persistSessionCompactionState(sessionId, state);
|
||||
expect(existsSync(artifacts.compactionPath ?? "")).toBe(true);
|
||||
await service.deleteSessionCompactionState(sessionId);
|
||||
|
||||
expect(existsSync(artifacts.messagesPath)).toBe(true);
|
||||
|
||||
@@ -55,7 +55,7 @@ async function writeFileAtomic(path: string, contents: string): Promise<void> {
|
||||
const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
||||
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
||||
try {
|
||||
handle = await open(tempPath, "w");
|
||||
handle = await open(tempPath, "wx");
|
||||
await handle.writeFile(contents, "utf8");
|
||||
await handle.sync();
|
||||
await handle.close();
|
||||
|
||||
Reference in New Issue
Block a user