diff --git a/sdk/packages/core/src/extensions/context/compaction.test.ts b/sdk/packages/core/src/extensions/context/compaction.test.ts index b3307138a1..95886ef46b 100644 --- a/sdk/packages/core/src/extensions/context/compaction.test.ts +++ b/sdk/packages/core/src/extensions/context/compaction.test.ts @@ -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(); + }); }); diff --git a/sdk/packages/core/src/extensions/context/compaction.ts b/sdk/packages/core/src/extensions/context/compaction.ts index 8f473b52dd..cab0d24a59 100644 --- a/sdk/packages/core/src/extensions/context/compaction.ts +++ b/sdk/packages/core/src/extensions/context/compaction.ts @@ -442,7 +442,6 @@ export function createCompactionStateAwarePrepareTurn(input: { compact?: ContextPipelinePrepareTurn; getState?: () => SessionCompactionState | undefined; saveState?: (state: SessionCompactionState) => void | Promise; - clearState?: () => void | Promise; }): 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({ diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.ts index 60b55b9b9f..1758756476 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.ts @@ -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 { - await this.enqueueCompactionStateWrite(session, async () => { - if (!session.compactionState) { - return; - } - await this.invoke( - "deleteSessionCompactionState", - session.sessionId, - ); - session.compactionState = undefined; - }); - } - private async enqueueCompactionStateWrite( session: ActiveSession, action: () => Promise, diff --git a/sdk/packages/core/src/session/models/session-compaction.test.ts b/sdk/packages/core/src/session/models/session-compaction.test.ts index 51fa223f20..a0e5b04f74 100644 --- a/sdk/packages/core/src/session/models/session-compaction.test.ts +++ b/sdk/packages/core/src/session/models/session-compaction.test.ts @@ -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, diff --git a/sdk/packages/core/src/session/models/session-compaction.ts b/sdk/packages/core/src/session/models/session-compaction.ts index 5cfa8ee89a..901bbe2cfb 100644 --- a/sdk/packages/core/src/session/models/session-compaction.ts +++ b/sdk/packages/core/src/session/models/session-compaction.ts @@ -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)), diff --git a/sdk/packages/core/src/session/services/persistence-service.test.ts b/sdk/packages/core/src/session/services/persistence-service.test.ts index bf5d290e10..423b66034b 100644 --- a/sdk/packages/core/src/session/services/persistence-service.test.ts +++ b/sdk/packages/core/src/session/services/persistence-service.test.ts @@ -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); diff --git a/sdk/packages/core/src/session/stores/session-manifest-store.ts b/sdk/packages/core/src/session/stores/session-manifest-store.ts index ecef8d5e5f..5c9b8e361f 100644 --- a/sdk/packages/core/src/session/stores/session-manifest-store.ts +++ b/sdk/packages/core/src/session/stores/session-manifest-store.ts @@ -55,7 +55,7 @@ async function writeFileAtomic(path: string, contents: string): Promise { const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`; let handle: Awaited> | undefined; try { - handle = await open(tempPath, "w"); + handle = await open(tempPath, "wx"); await handle.writeFile(contents, "utf8"); await handle.sync(); await handle.close();