Tighten compaction sidecar safety

This commit is contained in:
Robin Newhouse
2026-06-26 09:56:24 -07:00
parent 747aa65bd6
commit 9288570e7c
7 changed files with 98 additions and 62 deletions
@@ -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();