From 766b81a9297b603c31c49d1289cc14ca366eeb33 Mon Sep 17 00:00:00 2001 From: keepitmello <71975659+keepitmello@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:00:34 +0900 Subject: [PATCH 1/5] fix(cursor): reuse conversation checkpoints for incremental continuation Preserve Cursor's returned ConversationStateStructure after a successful no-tool turn and reuse that snapshot on validated linear continuations instead of rebuilding the full root history. Tool-result turns reuse the last completed checkpoint plus only the uncovered suffix. Compaction, helper/shadow isolation, account or model mismatch, missing refs, decode failures, and invalid_argument recovery keep the existing full-replay path. Bind checkpoint snapshots to conversation, credential identity, and model affinity. Keep an opaque process-local checkpointRef on Responses continuation state, pin referenced blobs for the checkpoint lifetime, and never treat OpenCodex usage as a cache-hit counter. Refs #1527 --- .../030_wave3_cursor.md | 15 ++ .../src/content/docs/ko/reference/adapters.md | 6 + .../src/content/docs/reference/adapters.md | 7 + src/adapters/cursor.ts | 104 +++++++++- src/adapters/cursor/checkpoint-store.ts | 181 +++++++++++++++++ src/adapters/cursor/discovery.ts | 7 + src/adapters/cursor/live-transport.ts | 19 +- src/adapters/cursor/native-exec.ts | 42 +++- src/adapters/cursor/protobuf-request.ts | 92 +++++++-- src/adapters/cursor/request-builder.ts | 57 +++++- src/adapters/cursor/transport.ts | 5 + src/adapters/cursor/types.ts | 12 ++ src/types.ts | 2 + structure/04_transports-and-sidecars.md | 22 +- tests/cursor-adapter.test.ts | 103 +++++++++- tests/cursor-blob.test.ts | 192 ++++++++++++++++++ tests/cursor-discovery.test.ts | 13 ++ tests/cursor-request-builder.test.ts | 187 +++++++++++++++++ tests/responses-state.test.ts | 31 +++ 19 files changed, 1064 insertions(+), 33 deletions(-) create mode 100644 src/adapters/cursor/checkpoint-store.ts diff --git a/devlog/_plan/260814_bug_resolution_campaign/030_wave3_cursor.md b/devlog/_plan/260814_bug_resolution_campaign/030_wave3_cursor.md index 8935facdce..db38b18279 100644 --- a/devlog/_plan/260814_bug_resolution_campaign/030_wave3_cursor.md +++ b/devlog/_plan/260814_bug_resolution_campaign/030_wave3_cursor.md @@ -48,6 +48,21 @@ Cursor tool/continuation/edit 경로의 correctness fix를 - teardown 문제 (정상 완료를 aborted/expectedClose:false로 기록)는 별도 작은 PR로 먼저 고친다 +2026-08-18 로컬 조사/prototype 메모 (fix/cursor-checkpoint-continuation, 아직 upstream PR 아님): + +- 병목의 1차 원인은 JSON 포맷 자체가 아니라, 매 턴 rootPromptMessages/conversationTurns로 + 과거 대화를 다시 만드는 full replay semantics다. +- ConversationStateStructure checkpoint를 다음 conversationState로 재사용하면 no-tool + follow-up에서 로컬 rootBytes가 history와 같이 커지지 않는다. grok-4.6 live 3턴에서 + 2·3턴이 continuationMode=checkpoint였고 ALPHA-7을 기억했다. +- 공식 cursor-agent 같은 계정 대조: 1턴 cacheReadTokens 0 / input 18937, 같은 세션 2턴 + cacheReadTokens 18816 / 새 input 331 / 답 ALPHA-7. OpenCodex Cursor wire는 usedTokens만 + 주므로 이쪽 usage로 cache hit를 주장하면 안 된다. +- tool-result는 마지막 정상 완료 턴 checkpoint + suffix replay가 live에서 동작했다. + client-tool suspend 턴 자체는 온전한 checkpoint가 없어 commit하지 않는다. +- 아직 미해결: 큰 context / 429 / kimi-k3 premature completion 재현, stateful live MCP + bridge, 정상 완료 teardown을 aborted로 분류하는 별건. + ### Step 5: #1623 분할 (behavior fix 안정화 후) 1. refactor/adapter-registry-authority diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 58c61cd7e4..d8b113c740 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -142,6 +142,12 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. - content-addressed blob으로 대화 상태를 재생하고 서버 툴 호출을 Codex에 다시 매핑합니다. protobuf `GetUsableModels` RPC로 실시간 Cursor 모델을 찾으며, run 요청이 wire에 commit되기 전까지만 재시도합니다. + 도구 없이 정상 완료된 턴 뒤에는 Cursor가 돌려준 ConversationStateStructure를 프로세스 로컬 + store에 보관하고, 검증된 선형 이어말하기에서는 전체 root history를 다시 만들지 않고 그 + checkpoint를 재사용합니다. tool-result 턴은 마지막 정상 완료 턴의 checkpoint에 커버되지 않은 + suffix만 붙입니다. compaction, helper/shadow 격리, 계정/모델 불일치, 없는 ref, decode 실패는 + 기존 full replay로 돌아갑니다. Cursor Connect는 권위 있는 cache_read_tokens를 주지 않으므로 + OpenCodex usage만 보고 cache hit라고 단정하지 않습니다. - `cursor/grok-4.5-fast`는 선택 가능한 모델로 유지하되, Cursor에는 정식 `grok-4.5` 모델을 보내고 별도의 `effort`, `fast=true` 값은 `requested_model.parameters`에 담습니다. - Cursor 네이티브 로컬 파일시스템/shell/network 실행은 기본적으로 거부합니다. 명시적인 diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 93a1c42f63..970f62cd06 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -195,6 +195,13 @@ advertised effort control on those models as proof of upstream-native reasoning - Replays conversation state through content-addressed blobs, maps server tool calls back to Codex, discovers live Cursor models through the protobuf `GetUsableModels` RPC, and retries only before a run request is committed to the wire. + After a successful no-tool turn, the adapter keeps Cursor's returned ConversationStateStructure + in a process-local store and reuses that checkpoint on the next validated linear continuation + instead of rebuilding the full root history. Tool-result turns reuse the last completed-turn + checkpoint plus only the uncovered suffix when the covered message boundary is known. + Compaction, helper/shadow isolation, account/model mismatch, missing refs, and decode failures + fall back to the existing full replay. Cursor Connect still does not expose authoritative + cache_read_tokens, so OpenCodex usage is not a cache-hit counter. - Exposes Cursor Router as `cursor/auto` plus explicit `cursor/auto-cost`, `cursor/auto-balance`, and `cursor/auto-intelligence` entries. Explicit levels are encoded in `requested_model.parameters` while the legacy `cursor/auto` entry retains the account/team default. diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 6f66004ea8..04b4867ef4 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -4,7 +4,7 @@ import type { ProviderAdapter } from "./base"; import { isTranslatorBudgetExceededError } from "../lib/translator-budget"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage } from "./cursor/cursor-errors"; -import { isCursorExternalWireModel } from "./cursor/discovery"; +import { cursorCheckpointModelAffinityId, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; import { createCursorRequest } from "./cursor/request-builder"; @@ -13,7 +13,14 @@ import { CursorMissingCredentialError, rekeyCursorContextUsage, resolveCursorToken, + capturedCursorCheckpointBytes, } from "./cursor/live-transport"; +import { + commitCursorCheckpoint, + cursorCheckpointRefHash, + invalidateCursorCheckpoint, +} from "./cursor/checkpoint-store"; +import { debugProviderDiagnostic } from "../lib/debug"; import { rememberCursorThreadConversation } from "./cursor/thread-continuity"; import { runCursorTurnWithRetry } from "./cursor/transport-retry"; import { @@ -112,6 +119,46 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda let emittedOutput = false; let replayUnsafe = false; const lastRawIsToolResult = _parsed.context.messages.at(-1)?.role === "toolResult"; + let completedNormally = false; + let lastTransport: { captured?: Uint8Array } | undefined; + let emittedClientTool = false; + + const commitCapturedCheckpoint = (activeRequest: ReturnType): void => { + if ( + replayUnsafe + || emittedClientTool + || _parsed._cursorIsolateConversation === true + || activeRequest.contextUsageStoreCheckpoints === false + || !lastTransport?.captured + || lastTransport.captured.byteLength === 0 + ) return; + const previousRef = _parsed._providerContinuation?.cursor?.checkpointRef; + const checkpointRef = commitCursorCheckpoint({ + conversationId: activeRequest.conversationId, + identityScope: _parsed._cursorIdentityScope, + modelId: cursorCheckpointModelAffinityId(activeRequest.modelId), + checkpointBytes: lastTransport.captured, + coveredMessageCount: _parsed.context.messages.length, + }); + if (!checkpointRef) return; + if (previousRef && previousRef !== checkpointRef) invalidateCursorCheckpoint(previousRef); + _parsed._providerContinuation = { + ...(_parsed._providerContinuation ?? {}), + cursor: { + ...(_parsed._providerContinuation?.cursor ?? {}), + conversationId: activeRequest.conversationId, + checkpointUsable: true, + checkpointRef, + }, + }; + debugProviderDiagnostic("cursor", "checkpoint-continuation", { + mode: activeRequest.continuationMode ?? "full-replay", + conversationHash: activeRequest.conversationId.slice(0, 16), + checkpointRefHash: cursorCheckpointRefHash(checkpointRef), + checkpointBytes: lastTransport.captured.byteLength, + wireModel: activeRequest.modelId, + }); + }; const runOnce = async (activeRequest: ReturnType) => { await runCursorTurnWithRetry( @@ -131,6 +178,10 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda return; } if (message.type === "local_side_effect") replayUnsafe = true; + if (message.type === "done") completedNormally = true; + if (message.type === "tool_call_end") emittedClientTool = true; + const captured = capturedCursorCheckpointBytes(activeTransport); + if (captured) lastTransport = { captured }; const events = mapCursorServerMessage(message, { kv, writeClient: clientMessage => { @@ -139,7 +190,28 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda }); for (const event of events) { if (event.type !== "heartbeat") emittedOutput = true; - emit(event); + if (event.type === "done") { + commitCapturedCheckpoint(activeRequest); + const inheritedCursor = _parsed._providerContinuation?.cursor; + const isolatedOrCompaction = + _parsed._cursorIsolateConversation === true + || activeRequest.contextUsageStoreCheckpoints === false; + const providerState = inheritedCursor + ? { + cursor: isolatedOrCompaction + ? { + conversationId: activeRequest.conversationId, + ...(inheritedCursor.checkpointUsable !== undefined + ? { checkpointUsable: inheritedCursor.checkpointUsable } + : {}), + } + : { ...inheritedCursor, conversationId: activeRequest.conversationId }, + } + : undefined; + emit(providerState ? { ...event, providerState } : event); + } else { + emit(event); + } } }, ); @@ -178,6 +250,34 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } await runOnce(request); } + if ( + request.checkpointInvalidationReason + && request.checkpointInvalidationReason !== "missing_ref" + && request.checkpointInvalidationReason !== "isolated_turn" + && request.checkpointInvalidationReason !== "compaction" + ) { + invalidateCursorCheckpoint(_parsed._providerContinuation?.cursor?.checkpointRef); + debugProviderDiagnostic("cursor", "checkpoint-invalidated", { + reason: request.checkpointInvalidationReason, + }); + } else if (!completedNormally && request.checkpointInvalidationReason) { + debugProviderDiagnostic("cursor", "checkpoint-invalidated", { + reason: request.checkpointInvalidationReason, + }); + } + if ( + _parsed._cursorIsolateConversation === true + || request.contextUsageStoreCheckpoints === false + ) { + const inherited = _parsed._providerContinuation?.cursor; + if (inherited) { + const { checkpointRef: _ignoredCheckpointRef, ...cursorWithoutCheckpointRef } = inherited; + _parsed._providerContinuation = { + ...(_parsed._providerContinuation ?? {}), + cursor: cursorWithoutCheckpointRef, + }; + } + } } catch (err) { if (isCursorBenignCancelError(err)) return; const partialUsage = (err as { partialUsage?: import("../types").OcxUsage }).partialUsage; diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts new file mode 100644 index 0000000000..fd9160d461 --- /dev/null +++ b/src/adapters/cursor/checkpoint-store.ts @@ -0,0 +1,181 @@ +import { createHash } from "node:crypto"; +import { fromBinary } from "@bufbuild/protobuf"; +import { ConversationStateStructureSchema } from "./gen/agent_pb"; +import { + createCursorBlobCheckpointLease, + pinCursorBlobIdsForCheckpoint, + releaseCursorBlobRequestScope, + type CursorBlobRequestScopeToken, +} from "./native-exec"; + +export const CURSOR_CHECKPOINT_TTL_MS = 15 * 60_000; +export const CURSOR_CHECKPOINT_MAX_ENTRIES = 64; +export const CURSOR_CHECKPOINT_MAX_TOTAL_BYTES = 16 * 1024 * 1024; + +export type CursorCheckpointInvalidationReason = + | "missing_ref" + | "expired" + | "decode_failed" + | "conversation_changed" + | "identity_changed" + | "model_changed" + | "compaction" + | "isolated_turn" + | "trailing_tool_result" + | "force_fresh" + | "upstream_invalid_argument"; + +export interface CursorCheckpointSnapshot { + ref: string; + conversationId: string; + identityScope: string; + modelId: string; + checkpointBytes: Uint8Array; + createdAt: number; + lastAccessAt: number; + blobLease?: CursorBlobRequestScopeToken; + coveredMessageCount?: number; +} + +interface CursorCheckpointStore { + snapshots: Map; + totalBytes: number; +} + +const store: CursorCheckpointStore = { + snapshots: new Map(), + totalBytes: 0, +}; + +function now(): number { + return Date.now(); +} + +function prune(at = now()): void { + for (const [ref, snapshot] of store.snapshots) { + if (at - snapshot.lastAccessAt > CURSOR_CHECKPOINT_TTL_MS) deleteSnapshot(ref); + } + while (store.snapshots.size > CURSOR_CHECKPOINT_MAX_ENTRIES || store.totalBytes > CURSOR_CHECKPOINT_MAX_TOTAL_BYTES) { + const oldest = store.snapshots.keys().next().value; + if (oldest === undefined) break; + deleteSnapshot(oldest); + } +} + +function deleteSnapshot(ref: string): void { + const existing = store.snapshots.get(ref); + if (!existing) return; + if (existing.blobLease) releaseCursorBlobRequestScope(existing.blobLease); + store.snapshots.delete(ref); + store.totalBytes = Math.max(0, store.totalBytes - existing.checkpointBytes.byteLength); +} + +function collectCheckpointBlobIds(checkpointBytes: Uint8Array): Uint8Array[] | undefined { + try { + const state = fromBinary(ConversationStateStructureSchema, checkpointBytes); + const ids: Uint8Array[] = [ + ...state.rootPromptMessagesJson, + ...state.turns, + ...state.turnsOld, + ...state.todos, + ...state.summaryArchives, + ]; + if (state.summary) ids.push(state.summary); + if (state.summaryArchive) ids.push(state.summaryArchive); + if (state.plan) ids.push(state.plan); + for (const value of Object.values(state.fileStates)) ids.push(value); + for (const value of Object.values(state.fileStatesV2)) { + if (value.content) ids.push(value.content); + if (value.initialContent) ids.push(value.initialContent); + } + return ids.filter(id => id.byteLength > 0); + } catch { + return undefined; + } +} + +export function cursorCheckpointRefHash(ref: string): string { + return createHash("sha256").update("ocx:cursor:ckpt-ref:").update(ref).digest("hex").slice(0, 16); +} + +export function commitCursorCheckpoint(input: { + conversationId: string; + identityScope?: string; + modelId: string; + checkpointBytes: Uint8Array; + coveredMessageCount?: number; +}): string | undefined { + if (!input.conversationId || !input.modelId || input.checkpointBytes.byteLength === 0) return undefined; + if (input.checkpointBytes.byteLength > CURSOR_CHECKPOINT_MAX_TOTAL_BYTES) return undefined; + prune(); + const createdAt = now(); + const ref = createHash("sha256") + .update("ocx:cursor:ckpt:") + .update(input.conversationId) + .update("|") + .update(input.identityScope?.trim() || "local") + .update("|") + .update(input.modelId) + .update("|") + .update(String(createdAt)) + .update("|") + .update(input.checkpointBytes) + .digest("hex") + .slice(0, 32); + const snapshot: CursorCheckpointSnapshot = { + ref, + conversationId: input.conversationId, + identityScope: input.identityScope?.trim() || "local", + modelId: input.modelId, + checkpointBytes: input.checkpointBytes.slice(), + createdAt, + lastAccessAt: createdAt, + ...(input.coveredMessageCount !== undefined ? { coveredMessageCount: input.coveredMessageCount } : {}), + }; + const blobIds = collectCheckpointBlobIds(input.checkpointBytes); + if (blobIds === undefined) return undefined; + if (blobIds.length > 0) { + const lease = createCursorBlobCheckpointLease(ref); + if (!pinCursorBlobIdsForCheckpoint(blobIds, lease)) { + releaseCursorBlobRequestScope(lease); + return undefined; + } + snapshot.blobLease = lease; + } + deleteSnapshot(ref); + store.snapshots.set(ref, snapshot); + store.totalBytes += snapshot.checkpointBytes.byteLength; + prune(createdAt); + return store.snapshots.has(ref) ? ref : undefined; +} + +export function getCursorCheckpoint(ref: string | undefined): CursorCheckpointSnapshot | undefined { + if (!ref) return undefined; + prune(); + const snapshot = store.snapshots.get(ref); + if (!snapshot) return undefined; + const at = now(); + if (at - snapshot.lastAccessAt > CURSOR_CHECKPOINT_TTL_MS) { + deleteSnapshot(ref); + return undefined; + } + snapshot.lastAccessAt = at; + store.snapshots.delete(ref); + store.snapshots.set(ref, snapshot); + return snapshot; +} + +export function invalidateCursorCheckpoint(ref: string | undefined): void { + if (!ref) return; + deleteSnapshot(ref); +} + +export function clearCursorCheckpointsForTests(): void { + for (const ref of [...store.snapshots.keys()]) deleteSnapshot(ref); + store.snapshots.clear(); + store.totalBytes = 0; +} + +export function cursorCheckpointStoreMetricsForTests(): { count: number; totalBytes: number } { + return { count: store.snapshots.size, totalBytes: store.totalBytes }; +} diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 888f1c1f79..a262712154 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -155,6 +155,13 @@ function stripCursorEffortSuffix(wireModelId: string): string { return wireModelId; } +/** Compare Cursor wire models without effort suffix or the grok cursor- request prefix. */ +export function cursorCheckpointModelAffinityId(modelId: string): string { + const wire = cursorCodexToWireModelId(modelId).trim().toLowerCase(); + const withoutPrefix = wire.startsWith("cursor-") ? wire.slice("cursor-".length) : wire; + return stripCursorEffortSuffix(withoutPrefix); +} + export function isCursorRouterModelId(modelId: string): boolean { return (CURSOR_ROUTER_MODEL_IDS as readonly string[]).includes(modelId); } diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 0d32b94b5b..dee2787592 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -33,6 +33,7 @@ import { CreatePlanRequestResponseSchema, CreatePlanResultSchema, CreatePlanSuccessSchema, + ConversationStateStructureSchema, ExaFetchRequestResponseSchema, ExaFetchRequestResponse_ApprovedSchema, ExaSearchRequestResponseSchema, @@ -436,11 +437,12 @@ class LiveCursorTransport implements CursorTransport { private framesReceived = 0; private sawAssistantText = false; private firstFrameAt?: number; - private firstFrameLogged = false; + private firstFrameLogged = false; /** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */ private readonly sessionId: string; /** Per-transport owner for native-exec / background shells. Must not share conversationId. */ private readonly shellOwnerId = crypto.randomUUID(); + private capturedCheckpointBytes?: Uint8Array; constructor(private readonly input: CursorTransportFactoryInput) { this.sessionId = input.sessionId?.trim() || crypto.randomUUID(); @@ -1096,6 +1098,10 @@ class LiveCursorTransport implements CursorTransport { }, HEARTBEAT_MS); } + capturedConversationCheckpoint(): Uint8Array | undefined { + return this.capturedCheckpointBytes; + } + private async handleServerMessage( message: AgentServerMessage, state: ReturnType, @@ -1103,6 +1109,13 @@ class LiveCursorTransport implements CursorTransport { ): Promise { if (!this.stream) return; debugProviderDiagnostic("cursor", "frame", describeCursorServerFrame(message)); + if (message.message.case === "conversationCheckpointUpdate") { + try { + this.capturedCheckpointBytes = toBinary(ConversationStateStructureSchema, message.message.value); + } catch { + this.capturedCheckpointBytes = undefined; + } + } if (message.message.case === "kvServerMessage") { this.stream.write(encodeConnectFrame(handleCursorNativeKv(message.message.value, this.blobRequestScope))); return; @@ -1312,3 +1325,7 @@ function cursorConnectErrorCode(payload: Uint8Array): string | undefined { export function createLiveCursorTransport(input: CursorTransportFactoryInput): CursorTransport { return new LiveCursorTransport(input); } + +export function capturedCursorCheckpointBytes(transport: CursorTransport): Uint8Array | undefined { + return transport.capturedConversationCheckpoint?.(); +} diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index dd05c1a5fc..750cfbef7d 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -117,6 +117,7 @@ interface CursorBlobLimits { interface CursorBlobRequestScopeState { keys: Set; sealed: boolean; + kind: "request" | "checkpoint"; } const DEFAULT_BLOB_LIMITS: CursorBlobLimits = { @@ -376,7 +377,7 @@ export function createCursorBlobRequestScope(): CursorBlobRequestScopeToken { // IDENTITY, not just pin counts (review C2-2: identical descriptions made // scope-swap bugs invisible to deep comparison). const scope = Symbol(`cursor-blob-request-${++blobScopeSequence}`); - blobRequestScopes.set(scope, { keys: new Set(), sealed: false }); + blobRequestScopes.set(scope, { keys: new Set(), sealed: false, kind: "request" }); return scope; } @@ -402,6 +403,41 @@ export function storeCursorBlob(data: Uint8Array, requestScope?: CursorBlobReque return blobId; } +/** + * Long-lived pin for blobs referenced by an active Cursor conversation checkpoint. + * Unlike a request scope, this lease is not sealed and is not released by getBlob hydration. + */ +export function createCursorBlobCheckpointLease(label: string): CursorBlobRequestScopeToken { + const scope = Symbol("cursor-blob-checkpoint-" + (++blobScopeSequence) + "-" + label.slice(0, 16)); + blobRequestScopes.set(scope, { keys: new Set(), sealed: false, kind: "checkpoint" }); + return scope; +} + +export function pinCursorBlobIdsForCheckpoint( + blobIds: readonly Uint8Array[], + lease: CursorBlobRequestScopeToken, +): boolean { + const state = blobRequestScopes.get(lease); + if (!state || state.sealed) return false; + for (const blobId of blobIds) { + if (blobId.byteLength === 0) continue; + const k = key(blobId); + const entry = blobs.get(k); + if (!entry) return false; + if (isExpired(entry, Date.now()) && entry.requestPins.size === 0 && entry.provenance !== "remote-setBlobArgs") { + return false; + } + entry.requestPins.add(lease); + state.keys.add(k); + } + reconcileBlobClassAccountingAndEnforce(); + return true; +} + +export function hasCursorBlob(blobId: Uint8Array): boolean { + return getBlob(key(blobId)) !== undefined; +} + export interface CursorBlobMetrics { count: number; totalBytes: number; @@ -574,7 +610,9 @@ export function handleCursorNativeKv( if (kvMsg.message.case === "getBlobArgs") { const blobKey = key(kvMsg.message.value.blobId); const blobData = getBlob(blobKey); - if (blobData) releaseHydratedBlob(blobKey, requestScope); + if (blobData && requestScope && blobRequestScopes.get(requestScope)?.kind === "request") { + releaseHydratedBlob(blobKey, requestScope); + } return clientBytes({ message: { case: "kvClientMessage", diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 2a55cdb179..bf545afb0d 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -798,9 +798,69 @@ function buildPreparedCursorRunRequest( }), }, }); - const rootPromptMessagesState = rootPromptMessages(request, requestScope); - const rootPromptMessageIds = rootPromptMessagesState.ids; - const turnIds = conversationTurns(request, requestScope, rootPromptMessagesState.historyMessageStart); + let continuationMode: "full-replay" | "checkpoint" = "full-replay"; + let checkpointInvalidationReason = request.checkpointInvalidationReason; + let conversationState; + let rootPromptMessagesState: ReturnType | undefined; + if (request.checkpointBytes && request.checkpointBytes.byteLength > 0) { + try { + conversationState = fromBinary(ConversationStateStructureSchema, request.checkpointBytes); + continuationMode = "checkpoint"; + const suffixStart = request.checkpointSuffixStart; + if ( + typeof suffixStart === "number" + && Number.isSafeInteger(suffixStart) + && suffixStart >= 0 + && request.rawMessages + && suffixStart < request.rawMessages.length + ) { + const suffixRequest: CursorRunRequest = { + ...request, + rawMessages: request.rawMessages.slice(suffixStart), + }; + const suffixRoots = rootPromptMessages(suffixRequest, requestScope); + const suffixTurns = conversationTurns(suffixRequest, requestScope, suffixRoots.historyMessageStart); + const suffixSystemCount = systemPromptBlobs(suffixRequest).length; + const suffixHistoryIds = suffixRoots.ids.slice(suffixSystemCount); + const suffixHistorySerialized = suffixRoots.serialized.slice(suffixSystemCount); + conversationState = create(ConversationStateStructureSchema, { + ...conversationState, + rootPromptMessagesJson: [ + ...conversationState.rootPromptMessagesJson, + ...suffixHistoryIds, + ], + turns: [ + ...conversationState.turns, + ...suffixTurns, + ], + }); + rootPromptMessagesState = { + ids: suffixHistoryIds, + byteLength: suffixRoots.byteLength, + historyMessageStart: suffixRoots.historyMessageStart, + serialized: suffixHistorySerialized, + }; + } + } catch { + checkpointInvalidationReason = "decode_failed"; + } + } + if (!conversationState) { + rootPromptMessagesState = rootPromptMessages(request, requestScope); + conversationState = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: rootPromptMessagesState.ids, + turns: conversationTurns(request, requestScope, rootPromptMessagesState.historyMessageStart), + todos: [], + pendingToolCalls: [], + previousWorkspaceUris: [], + fileStates: {}, + fileStatesV2: {}, + summaryArchives: [], + turnTimings: [], + subagentStates: {}, + readPaths: [], + }); + } // Hoisted out of the mcp_tools spread below so the estimate can read the same // filtered definitions the wire carries. Both helpers are pure. const visibleTools = cursorToolsForActivePrompt(request.tools, rawText, request.toolChoice); @@ -812,9 +872,13 @@ function buildPreparedCursorRunRequest( turnType: lastRawIsToolResult ? "tool-continuation" : "initial", externalModel: isCursorExternalWireModel(request.modelId), rawMessages: request.rawMessages?.length ?? 0, - rootBlobs: rootPromptMessageIds.length, - rootBytes: rootPromptMessagesState.byteLength, - turnBlobs: turnIds.length, + continuationMode, + checkpointPresent: continuationMode === "checkpoint", + checkpointBytes: continuationMode === "checkpoint" ? request.checkpointBytes?.byteLength : undefined, + checkpointInvalidationReason, + rootBlobs: conversationState.rootPromptMessagesJson.length, + rootBytes: rootPromptMessagesState?.byteLength ?? 0, + turnBlobs: conversationState.turns.length, tools: request.tools?.length ?? 0, }); @@ -825,19 +889,7 @@ function buildPreparedCursorRunRequest( const hasExplicitModelParameters = (request.requestedModelParameters?.length ?? 0) > 0; const runRequest = create(AgentRunRequestSchema, { conversationId: request.conversationId, - conversationState: create(ConversationStateStructureSchema, { - rootPromptMessagesJson: rootPromptMessageIds, - turns: turnIds, - todos: [], - pendingToolCalls: [], - previousWorkspaceUris: [], - fileStates: {}, - fileStatesV2: {}, - summaryArchives: [], - turnTimings: [], - subagentStates: {}, - readPaths: [], - }), + conversationState, action, // Explicit model-picker parameters follow current Cursor clients and use requested_model alone. // Keep legacy model_details for flat model ids and the already-live Router path; sending both for @@ -884,7 +936,7 @@ function buildPreparedCursorRunRequest( // Same instances that produced `bytes`, so the estimate cannot count history or // tools the payload dropped — the defect that blocked PR #376. const modelVisibleParts = [ - ...rootPromptMessagesState.serialized, + ...(rootPromptMessagesState?.serialized ?? []), ...(actionCase === "userMessageAction" ? [actionText] : []), ...mcpToolDefs.map(modelVisibleToolText), ]; diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index da003f85a0..de91fa6279 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -9,7 +9,7 @@ import type { } from "../../types"; import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types"; import type { CursorRequestMessage, CursorRequestedModelParameter, CursorRunRequest } from "./types"; -import { cursorWireModelSelection, type CursorRoutingLevel } from "./discovery"; +import { cursorCheckpointModelAffinityId, cursorWireModelSelection, type CursorRoutingLevel } from "./discovery"; import { cursorEffortSuffix, cursorRequestWireModelIdWithEffort } from "./effort-map"; import { cursorMcpToolEncodedSize, @@ -25,6 +25,10 @@ import { isCursorWaitTool, } from "./tool-definitions"; import { lookupCursorThreadConversation } from "./thread-continuity"; +import { + getCursorCheckpoint, + type CursorCheckpointInvalidationReason, +} from "./checkpoint-store"; /** Probe-verified Cursor Connect boundaries, with byte headroom for the enclosing field. */ export const CURSOR_TOOL_COUNT_LIMIT = 330; @@ -303,6 +307,37 @@ export interface CreateCursorRequestOptions { forceFreshConversation?: boolean; } +function checkpointInvalidationReason( + parsed: OcxParsedRequest, + request: CursorRunRequest, + options: CreateCursorRequestOptions, +): CursorCheckpointInvalidationReason | undefined { + if (options.forceFreshConversation === true) return "force_fresh"; + if (parsed._cursorIsolateConversation === true) return "isolated_turn"; + if (parsed._compactionRequest === true || parsed._contextCompactionBoundary === true) return "compaction"; + const cursorState = parsed._providerContinuation?.cursor; + const ref = cursorState?.checkpointRef; + if (!ref) return "missing_ref"; + const snapshot = getCursorCheckpoint(ref); + if (!snapshot) return "expired"; + if (snapshot.conversationId !== request.conversationId) return "conversation_changed"; + const identityScope = parsed._cursorIdentityScope?.trim() || "local"; + if (snapshot.identityScope !== identityScope) return "identity_changed"; + if (cursorCheckpointModelAffinityId(snapshot.modelId) !== cursorCheckpointModelAffinityId(request.modelId)) { + return "model_changed"; + } + const lastRole = parsed.context.messages.at(-1)?.role; + if (lastRole === "toolResult") { + if (snapshot.coveredMessageCount === undefined) return "trailing_tool_result"; + if (snapshot.coveredMessageCount < 0 || snapshot.coveredMessageCount >= parsed.context.messages.length) { + return "trailing_tool_result"; + } + } else if (cursorState?.checkpointUsable === false) { + return "trailing_tool_result"; + } + return undefined; +} + export function createCursorRequest( parsed: OcxParsedRequest, options: CreateCursorRequestOptions = {}, @@ -315,7 +350,7 @@ export function createCursorRequest( const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice); const limitNote = catalogLimitNote(budget.tools, budget.omitted); const model = normalizeCursorModelId(parsed.modelId, parsed.options.reasoning); - return { + const request: CursorRunRequest = { modelId: model.modelId, ...(model.requestedModelParameters ? { requestedModelParameters: model.requestedModelParameters } : {}), ...(model.routingLevel ? { routingLevel: model.routingLevel } : {}), @@ -329,4 +364,22 @@ export function createCursorRequest( ...(parsed.options.toolChoice ? { toolChoice: parsed.options.toolChoice } : {}), ...(parsed.options.parallelToolCalls !== undefined ? { parallelToolCalls: parsed.options.parallelToolCalls } : {}), }; + const invalidation = checkpointInvalidationReason(parsed, request, options); + if (invalidation) { + request.continuationMode = "full-replay"; + request.checkpointInvalidationReason = invalidation; + return request; + } + const snapshot = getCursorCheckpoint(parsed._providerContinuation?.cursor?.checkpointRef); + if (!snapshot) { + request.continuationMode = "full-replay"; + request.checkpointInvalidationReason = "missing_ref"; + return request; + } + request.checkpointBytes = snapshot.checkpointBytes; + request.continuationMode = "checkpoint"; + if (parsed.context.messages.at(-1)?.role === "toolResult" && snapshot.coveredMessageCount !== undefined) { + request.checkpointSuffixStart = snapshot.coveredMessageCount; + } + return request; } diff --git a/src/adapters/cursor/transport.ts b/src/adapters/cursor/transport.ts index ce18fbd5be..b864c84a63 100644 --- a/src/adapters/cursor/transport.ts +++ b/src/adapters/cursor/transport.ts @@ -12,6 +12,11 @@ export interface CursorTransport { * accepted is never replayed. Absent (undefined) is treated as "committed" — safe by default. */ requestCommitted?(): boolean; + /** + * Last ConversationStateStructure captured from conversationCheckpointUpdate on this transport. + * Test and adapter seams use this instead of reaching into LiveCursorTransport. + */ + capturedConversationCheckpoint?(): Uint8Array | undefined; } export interface CursorTransportFactoryInput { diff --git a/src/adapters/cursor/types.ts b/src/adapters/cursor/types.ts index b32026a07d..28fbdb4d1d 100644 --- a/src/adapters/cursor/types.ts +++ b/src/adapters/cursor/types.ts @@ -31,6 +31,18 @@ export interface CursorRunRequest { * pre-compaction history being summarized and must not become the next turn's carry-forward total. */ contextUsageStoreCheckpoints?: boolean; + /** + * Reuse a previously captured ConversationStateStructure instead of rebuilding historical + * root/turn blobs. Absent means the existing full-replay path. + */ + checkpointBytes?: Uint8Array; + continuationMode?: "full-replay" | "checkpoint"; + checkpointInvalidationReason?: string; + /** + * When set with checkpointBytes, only this suffix of rawMessages is replayed onto the + * decoded ConversationStateStructure. Used for tool-result continuations. + */ + checkpointSuffixStart?: number; } export interface CursorRequestMessage { diff --git a/src/types.ts b/src/types.ts index ecf375f265..424e553ad5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -335,6 +335,8 @@ export interface OcxProviderContinuationState { cursor?: { conversationId?: string; checkpointUsable?: boolean; + /** Opaque process-local Cursor ConversationStateStructure snapshot ref. Never raw protobuf. */ + checkpointRef?: string; }; kiro?: { conversationId?: string; diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 0dc663c8ea..22961b20d2 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -514,6 +514,26 @@ pre-compaction checkpoint is not persisted for later carry-forward. - 장점, 단점 및 영향: Active-context reporting stays monotonic within an uncompacted Cursor conversation; no-checkpoint turns remain estimated; a process restart loses the numeric cache, and when neither a checkpoint nor a carry-forward is available the turn reports a request-local estimate derived from the same pruned payload sent to Cursor (#373 — reporting output-only usage made Codex read the context as nearly empty). Estimates are never persisted or promoted into checkpoint carry-forward; only live checkpoint frames update the cache. ``` +## Cursor conversation checkpoint reuse + +After a successful no-tool turn, the Cursor adapter keeps the returned ConversationStateStructure in +a process-local store and reuses that snapshot on the next validated linear continuation instead of +rebuilding rootPromptMessagesJson and conversationTurns. Tool-result turns reuse the last completed +checkpoint plus only the uncovered suffix. Compaction, helper/shadow isolation, account or model +mismatch, missing refs, decode failures, and invalid_argument recovery keep the existing full-replay +path. previous_response_id may select a branch's opaque checkpointRef; it is never a Cursor +conversation ownership key. Cursor Connect still does not expose authoritative cache_read_tokens. + +```text +[Decision Log] +- 목적과 의도: Reuse Cursor's returned ConversationStateStructure on validated linear continuations so OpenCodex does not rebuild the full root history every turn. +- 기존 구현 및 제약 조건: Stable conversation ids already exist (#366), but every turn still reconstructed rootPromptMessagesJson and conversationTurns. Cursor Connect still reports only usedTokens/maxTokens, so cache_read_tokens cannot be treated as authoritative (#275). +- 검토한 주요 대안: Keep full replay; copy Pi's live MCP bridge immediately; store raw protobuf in Responses JSON; key checkpoints only by conversation id. +- 선택한 방식: Keep an opaque process-local checkpointRef on OcxProviderContinuationState.cursor, bind the snapshot to conversation/account/model affinity, pin referenced blobs for the checkpoint lifetime, and fall back to the existing full-replay path for isolation, compaction, restart, missing refs, and invalid_argument recovery. Tool-result turns reuse the last completed checkpoint plus an uncovered suffix. previous_response_id is a branch anchor, never a Cursor conversation ownership key. +- 다른 대안 대신 이 방식을 선택한 이유: It removes avoidable replay cost without claiming cache-hit rates, without changing OAuth, and without collapsing helper/compaction isolation or tool-call replay safety. +- 장점, 단점 및 영향: Validated no-tool follow-ups stop growing local rootBytes with history; a process restart or missing blob lease falls back to full replay; large-context 429 / premature-completion acceptance for #1527 is still unproven; a stateful live MCP bridge remains out of scope. +``` + ## Google thought-text visibility boundary Google-family responses may represent model-internal reasoning as a text-bearing part with @@ -860,7 +880,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | | Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. | | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | -| Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts` | Thread continuity is the point: a retry must not start a new Cursor thread. | +| Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts`, `src/adapters/cursor/checkpoint-store.ts` | Thread continuity is the point: a retry must not start a new Cursor thread, and a validated checkpoint must not rebuild the full root history. | | Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | | Chat Completions inbound | `src/server/chat-completions.ts`, `src/chat/` | Inbound translation onto the same routing pipeline. | | Hosted search relay | `src/server/search.ts` | Direct relay; distinct from the web-search sidecar loop below. | diff --git a/tests/cursor-adapter.test.ts b/tests/cursor-adapter.test.ts index 83f4b77263..8294beac78 100644 --- a/tests/cursor-adapter.test.ts +++ b/tests/cursor-adapter.test.ts @@ -7,6 +7,13 @@ import { clearCursorThreadContinuityForTests, lookupCursorThreadConversation, } from "../src/adapters/cursor/thread-continuity"; +import { + clearCursorCheckpointsForTests, + commitCursorCheckpoint, + getCursorCheckpoint, +} from "../src/adapters/cursor/checkpoint-store"; +import { create, toBinary } from "@bufbuild/protobuf"; +import { ConversationStateStructureSchema } from "../src/adapters/cursor/gen/agent_pb"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; import type { CursorClientMessage, CursorRunRequest, CursorServerMessage } from "../src/adapters/cursor/types"; import type { CursorTransportFactoryInput } from "../src/adapters/cursor/transport"; @@ -85,11 +92,9 @@ describe("Cursor adapter live transport", () => { expect(requests[0]?.modelId).toBe("default"); expect(requests[0]?.routingLevel).toBeUndefined(); expect(writes).toEqual([]); - expect(events).toEqual([ - { type: "thinking_delta", thinking: "검토 중" }, - { type: "text_delta", text: "안녕하세요" }, - { type: "done", usage: { inputTokens: 3, outputTokens: 5 } }, - ]); + expect(events[0]).toEqual({ type: "thinking_delta", thinking: "검토 중" }); + expect(events[1]).toEqual({ type: "text_delta", text: "안녕하세요" }); + expect(events[2]).toMatchObject({ type: "done", usage: { inputTokens: 3, outputTokens: 5 } }); }); test("runTurn preserves explicit Cursor Router optimization levels", async () => { @@ -544,4 +549,92 @@ describe("Cursor adapter live transport", () => { expect(events.some(event => event.type === "text_delta")).toBe(true); expect(events.some(event => event.type === "error")).toBe(true); }); + + test("attaches a committed checkpoint ref on done so stream persist can reuse it", async () => { + clearCursorCheckpointsForTests(); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["stream-fixture"], + })); + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + yield { type: "text", text: "remembered" } satisfies CursorServerMessage; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } satisfies CursorServerMessage; + }, + writeClient() {}, + capturedConversationCheckpoint() { + return checkpointBytes; + }, + }), + }); + + const events: AdapterEvent[] = []; + const body: OcxParsedRequest = { + ...parsed, + modelId: "cursor/grok-4.6", + context: { messages: [{ role: "user", content: "hi", timestamp: 1 }] }, + _cursorConversationId: "cursor_stream_persist", + _cursorIdentityScope: "acct-stream", + }; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + const done = events.find(event => event.type === "done"); + expect(done?.type).toBe("done"); + if (done?.type !== "done") throw new Error("expected done"); + expect(done.providerState?.cursor?.checkpointRef).toBeDefined(); + expect(done.providerState?.cursor?.checkpointUsable).toBe(true); + expect(getCursorCheckpoint(done.providerState?.cursor?.checkpointRef)?.conversationId).toBe("cursor_stream_persist"); + expect(body._providerContinuation?.cursor?.checkpointRef).toBe(done.providerState?.cursor?.checkpointRef); + clearCursorCheckpointsForTests(); + }); + + test("isolated helper turns do not inherit or invalidate a parent checkpoint ref", async () => { + clearCursorCheckpointsForTests(); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["isolate-fixture"], + })); + const parentRef = commitCursorCheckpoint({ + conversationId: "cursor_parent_real", + identityScope: "acct-isolate-test", + modelId: "default", + checkpointBytes, + coveredMessageCount: 1, + }); + expect(parentRef).toBeDefined(); + + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + }), + }); + const body: OcxParsedRequest = { + modelId: "cursor/auto", + context: { messages: [{ role: "user", content: "summarize", timestamp: 1 }] }, + stream: false, + options: {}, + _cursorConversationId: "cursor_parent_real", + _cursorIsolateConversation: true, + _cursorIdentityScope: "acct-isolate-test", + _providerContinuation: { + cursor: { conversationId: "cursor_parent_real", checkpointUsable: true, checkpointRef: parentRef }, + }, + }; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + + expect(getCursorCheckpoint(parentRef)?.ref).toBe(parentRef); + expect(body._providerContinuation?.cursor?.checkpointRef).toBeUndefined(); + const done = events.find(event => event.type === "done"); + expect(done && done.type === "done" ? done.providerState?.cursor?.checkpointRef : undefined).toBeUndefined(); + clearCursorCheckpointsForTests(); + }); }); diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index e91111f782..476812adb1 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { createHash } from "node:crypto"; import { create, fromBinary } from "@bufbuild/protobuf"; +import { toBinary } from "@bufbuild/protobuf"; import { createCursorBlobRequestScope, cursorBlobMetrics, @@ -16,6 +17,11 @@ import { storeCursorBlob, type CursorBlobRequestScopeToken, } from "../src/adapters/cursor/native-exec"; +import { + clearCursorCheckpointsForTests, + commitCursorCheckpoint, + invalidateCursorCheckpoint, +} from "../src/adapters/cursor/checkpoint-store"; import { configureAppOwnedMemoryBudget, registerRetainedStore, @@ -34,6 +40,7 @@ import { AgentClientMessageSchema, ConversationStepSchema, ConversationTurnStructureSchema, + ConversationStateStructureSchema, GetBlobArgsSchema, KvServerMessageSchema, SetBlobArgsSchema, @@ -1391,3 +1398,188 @@ describe("Cursor blob ID key channel bounds", () => { expect(cursorBlobRetainedStoreSnapshot().bytes).toBe(0); }); }); + +describe("Cursor checkpoint request construction", () => { + test("uses decoded ConversationStateStructure and skips historical root replay", () => { + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [new Uint8Array(32).fill(7)], + turns: [new Uint8Array(32).fill(8)], + }); + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt", + system: ["You are helpful."], + messages: [ + { role: "user", content: "old user" }, + { role: "assistant", content: "old assistant" }, + { role: "user", content: "new user" }, + ], + rawMessages: [ + { role: "user", content: "old user", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "old assistant" }], timestamp: 2 }, + { role: "user", content: "new user", timestamp: 3 }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + expect(Array.from(run?.conversationState?.rootPromptMessagesJson[0] ?? [])).toEqual(Array.from({ length: 32 }, () => 7)); + expect(Array.from(run?.conversationState?.turns[0] ?? [])).toEqual(Array.from({ length: 32 }, () => 8)); + expect(run?.action?.action.case).toBe("userMessageAction"); + }); + + test("invalid checkpoint bytes fall back to full replay", () => { + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt", + system: ["You are helpful."], + messages: [{ role: "user", content: "hello" }], + rawMessages: [{ role: "user", content: "hello", timestamp: 1 }], + checkpointBytes: new Uint8Array([1, 2, 3, 4]), + }); + const roots = decodeRootMessages(prepared.bytes); + expect(roots.length).toBeGreaterThan(0); + expect(JSON.stringify(roots)).toContain("You are helpful."); + }); + + test("checkpoint suffix replay appends only uncovered history", () => { + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [new Uint8Array(32).fill(7)], + turns: [new Uint8Array(32).fill(8)], + }); + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt", + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages: [ + { role: "user", content: "old user", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "old assistant" }], timestamp: 2 }, + { role: "user", content: "please read", timestamp: 3 }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read_file", + content: "FILE CONTENTS HERE", + isError: false, + timestamp: 4, + }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 2, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + expect(Array.from(roots[0] ?? [])).toEqual(Array.from({ length: 32 }, () => 7)); + expect(roots.length).toBeGreaterThan(1); + const suffix = roots.slice(1).map(id => JSON.parse(new TextDecoder().decode(blobData(id))) as { content?: unknown }); + const serialized = JSON.stringify(suffix); + expect(serialized).toContain("FILE CONTENTS HERE"); + expect(serialized).not.toContain("old user"); + }); + + test("active checkpoint lease keeps referenced blobs after request pin release", () => { + clearCursorCheckpointsForTests(); + const data = new TextEncoder().encode('{"role":"system","content":"lease-me"}'); + const scope = createCursorBlobRequestScope(); + const blobId = storeCursorBlob(data, scope); + sealCursorBlobRequestScope(scope); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [blobId], + })); + const ref = commitCursorCheckpoint({ + conversationId: "cursor_lease", + identityScope: "acct", + modelId: "grok-4.6", + checkpointBytes, + }); + expect(ref).toBeDefined(); + releaseCursorBlobRequestScope(scope); + expect(cursorBlobRetainedStoreSnapshot().pinnedBytes).toBeGreaterThan(0); + expect(evictOldestCursorBlobForBudget()).toBe(0); + expectBlobHit(blobId, data); + invalidateCursorCheckpoint(ref); + expect(evictOldestCursorBlobForBudget()).toBeGreaterThan(0); + clearCursorCheckpointsForTests(); + }); + + test("missing checkpoint blobs fail closed instead of committing a lease", () => { + clearCursorCheckpointsForTests(); + const missingId = new Uint8Array(32).fill(11); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [missingId], + })); + const ref = commitCursorCheckpoint({ + conversationId: "cursor_missing_blob", + identityScope: "acct", + modelId: "grok-4.6", + checkpointBytes, + }); + expect(ref).toBeUndefined(); + expect(cursorBlobRetainedStoreSnapshot().pinnedBytes).toBe(0); + clearCursorCheckpointsForTests(); + }); + + test("getBlob hydration does not release an active checkpoint lease", () => { + clearCursorCheckpointsForTests(); + const data = new TextEncoder().encode('{"role":"system","content":"keep-me"}'); + const requestScope = createCursorBlobRequestScope(); + const blobId = storeCursorBlob(data, requestScope); + sealCursorBlobRequestScope(requestScope); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [blobId], + })); + const ref = commitCursorCheckpoint({ + conversationId: "cursor_hydrate", + identityScope: "acct", + modelId: "grok-4.6", + checkpointBytes, + }); + expect(ref).toBeDefined(); + releaseCursorBlobRequestScope(requestScope); + expectBlobHit(blobId, data, requestScope); + expect(cursorBlobRetainedStoreSnapshot().pinnedBytes).toBeGreaterThan(0); + expect(evictOldestCursorBlobForBudget()).toBe(0); + invalidateCursorCheckpoint(ref); + clearCursorCheckpointsForTests(); + }); + + test("checkpoint suffix replay does not re-append the system prompt", () => { + const checkpoint = create(ConversationStateStructureSchema, { + rootPromptMessagesJson: [new Uint8Array(32).fill(7)], + turns: [new Uint8Array(32).fill(8)], + }); + const prepared = prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt", + system: ["You are helpful."], + messages: [{ role: "tool", content: "result" }], + rawMessages: [ + { role: "user", content: "old user", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "old assistant" }], timestamp: 2 }, + { role: "user", content: "please read", timestamp: 3 }, + { + role: "toolResult", + toolCallId: "call_1", + toolName: "read_file", + content: "FILE CONTENTS HERE", + isError: false, + timestamp: 4, + }, + ], + checkpointBytes: toBinary(ConversationStateStructureSchema, checkpoint), + continuationMode: "checkpoint", + checkpointSuffixStart: 2, + }); + const message = fromBinary(AgentClientMessageSchema, prepared.bytes); + const run = message.message.case === "runRequest" ? message.message.value : undefined; + const roots = run?.conversationState?.rootPromptMessagesJson ?? []; + const suffix = roots.slice(1).map(id => JSON.parse(new TextDecoder().decode(blobData(id))) as { content?: unknown }); + const serialized = JSON.stringify(suffix); + expect(serialized).not.toContain("You are helpful."); + expect(serialized).toContain("FILE CONTENTS HERE"); + }); +}); diff --git a/tests/cursor-discovery.test.ts b/tests/cursor-discovery.test.ts index 7e9595aca2..ee504de96f 100644 --- a/tests/cursor-discovery.test.ts +++ b/tests/cursor-discovery.test.ts @@ -6,6 +6,7 @@ import { CURSOR_ROUTING_LEVELS, CURSOR_STATIC_MODELS, cursorCodexToWireModelId, + cursorCheckpointModelAffinityId, filterCursorConfiguredModelsByLiveDiscovery, isCursorModelAvailableForAccount, cursorModelContextWindows, @@ -187,4 +188,16 @@ describe("Cursor discovery metadata", () => { expect(isCursorExternalWireModel("claude-4.6-sonnet-high")).toBe(true); expect(isCursorExternalWireModel("cursor/gpt-5.6-sol")).toBe(true); }); + + test("normalizes Cursor checkpoint model affinity across prefix and effort", () => { + expect(cursorCheckpointModelAffinityId("cursor/grok-4.6")).toBe( + cursorCheckpointModelAffinityId("cursor-grok-4.6-low"), + ); + expect(cursorCheckpointModelAffinityId("grok-4.6")).toBe( + cursorCheckpointModelAffinityId("cursor/grok-4.6"), + ); + expect(cursorCheckpointModelAffinityId("cursor/gpt-5.6-sol")).not.toBe( + cursorCheckpointModelAffinityId("cursor/grok-4.6"), + ); + }); }); diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index f3aaafd656..6e09303fb1 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -1,4 +1,11 @@ import { describe, expect, test } from "bun:test"; +import { create, toBinary } from "@bufbuild/protobuf"; +import { ConversationStateStructureSchema } from "../src/adapters/cursor/gen/agent_pb"; +import { + clearCursorCheckpointsForTests, + commitCursorCheckpoint, + getCursorCheckpoint, +} from "../src/adapters/cursor/checkpoint-store"; import { applyCursorToolBudget, createCursorRequest, @@ -698,4 +705,184 @@ describe("Cursor request builder", () => { expect(request.conversationId).not.toBe("cursor_force_me"); expect(request.conversationId.startsWith("cursor_")).toBe(true); }); + + test("reuses a validated checkpoint and ignores it for isolation or uncovered tool results", () => { + clearCursorCheckpointsForTests(); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["builder-fixture"], + })); + const checkpointRef = commitCursorCheckpoint({ + conversationId: "cursor_stable", + identityScope: "acct-1", + modelId: "default", + checkpointBytes, + coveredMessageCount: 2, + }); + expect(checkpointRef).toBeDefined(); + + const reused = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef }, + }, + context: { messages: [{ role: "user", content: "continue", timestamp: 1 }] }, + }); + expect(reused.continuationMode).toBe("checkpoint"); + expect(reused.checkpointBytes?.byteLength).toBe(checkpointBytes.byteLength); + + const isolated = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + _cursorIsolateConversation: true, + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef }, + }, + }); + expect(isolated.continuationMode).toBe("full-replay"); + expect(isolated.checkpointInvalidationReason).toBe("isolated_turn"); + expect(isolated.checkpointBytes).toBeUndefined(); + + const toolResult = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: false, checkpointRef }, + }, + context: { + messages: [ + { role: "user", content: "read", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "calling" }], timestamp: 2 }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "read_file", + content: "ok", + isError: false, + timestamp: 3, + }, + ], + }, + }); + expect(toolResult.continuationMode).toBe("checkpoint"); + expect(toolResult.checkpointSuffixStart).toBe(2); + + const uncovered = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: false, checkpointRef }, + }, + context: { + messages: [{ + role: "toolResult", + toolCallId: "call-1", + toolName: "read_file", + content: "ok", + isError: false, + timestamp: 2, + }], + }, + }); + expect(uncovered.continuationMode).toBe("full-replay"); + expect(uncovered.checkpointInvalidationReason).toBe("trailing_tool_result"); + clearCursorCheckpointsForTests(); + }); + + test("falls back to full replay when the checkpoint identity no longer matches", () => { + clearCursorCheckpointsForTests(); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["identity-fixture"], + })); + const checkpointRef = commitCursorCheckpoint({ + conversationId: "cursor_stable", + identityScope: "acct-1", + modelId: "grok-4.6", + checkpointBytes, + }); + expect(checkpointRef).toBeDefined(); + + const missing = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + }); + expect(missing.continuationMode).toBe("full-replay"); + expect(missing.checkpointInvalidationReason).toBe("missing_ref"); + + const expired = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef: "missing-ref" }, + }, + }); + expect(expired.continuationMode).toBe("full-replay"); + expect(expired.checkpointInvalidationReason).toBe("expired"); + + const modelChanged = createCursorRequest({ + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef }, + }, + }); + expect(modelChanged.continuationMode).toBe("full-replay"); + expect(modelChanged.checkpointInvalidationReason).toBe("model_changed"); + + const identityChanged = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-2", + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef }, + }, + }); + expect(identityChanged.continuationMode).toBe("full-replay"); + expect(identityChanged.checkpointInvalidationReason).toBe("identity_changed"); + + const conversationChanged = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_other", + _cursorIdentityScope: "acct-1", + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef }, + }, + }); + expect(conversationChanged.continuationMode).toBe("full-replay"); + expect(conversationChanged.checkpointInvalidationReason).toBe("conversation_changed"); + + const forceFresh = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef }, + }, + }, { forceFreshConversation: true }); + expect(forceFresh.continuationMode).toBe("full-replay"); + expect(forceFresh.checkpointInvalidationReason).toBe("force_fresh"); + + const compaction = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + _compactionRequest: true, + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef }, + }, + }); + expect(compaction.continuationMode).toBe("full-replay"); + expect(compaction.checkpointInvalidationReason).toBe("compaction"); + expect(compaction.checkpointBytes).toBeUndefined(); + expect(getCursorCheckpoint(checkpointRef)?.ref).toBe(checkpointRef); + clearCursorCheckpointsForTests(); + }); }); diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 3084c44858..d48a8d062a 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -1759,6 +1759,37 @@ describe("Responses previous_response_id state", () => { expect(previousResponseConversationId(first.id as string)).toBe("cursor_conversation_1"); }); + test("persists an opaque Cursor checkpoint ref without raw protobuf bytes", async () => { + const first = buildResponseJSON([ + { type: "text_delta", text: "answer", phase: "final_answer" }, + { type: "done", endTurn: true }, + ], "cursor/auto"); + rememberResponseState( + { model: "cursor/auto", input: "hello" }, + first, + { + cursor: { + conversationId: "cursor_conversation_ref", + checkpointUsable: true, + checkpointRef: "opaque-checkpoint-ref", + }, + }, + ); + await flushResponseState(); + clearResponseStateMemoryForTests(); + + expect(previousResponseProviderState(first.id as string)).toEqual({ + cursor: { + conversationId: "cursor_conversation_ref", + checkpointUsable: true, + checkpointRef: "opaque-checkpoint-ref", + }, + }); + const snapshot = readFileSync(join(home, "responses-state.json"), "utf8"); + expect(snapshot).toContain("opaque-checkpoint-ref"); + expect(snapshot).not.toContain("rootPromptMessagesJson"); + }); + test("preserves provider conversation id after a client tool-call response (multi-turn continuation)", () => { const firstBody = { model: "cursor/auto", input: "use ping" }; const first = buildResponseJSON([ From 1ffee21730f9d4b4ea6f9583a7338e14fb648468 Mon Sep 17 00:00:00 2001 From: keepitmello <71975659+keepitmello@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:35:19 +0900 Subject: [PATCH 2/5] fix(cursor): pin store:false chat hops and helper-owned cache Chat Completions / Codex Sol hops often omit previous_response_id and thread headers, so every hop minted a new conversation and missed the checkpoint store. Pin those hops to the first user text and reuse the live snapshot. Isolated helpers keep their own cache and stay off the parent thread. Refs #1527 --- src/adapters/cursor.ts | 2 - src/adapters/cursor/checkpoint-store.ts | 12 +- src/adapters/cursor/request-builder.ts | 85 +++++++----- src/adapters/cursor/types.ts | 3 +- structure/04_transports-and-sidecars.md | 11 +- tests/cursor-request-builder.test.ts | 167 +++++++++++++++++++++++- 6 files changed, 237 insertions(+), 43 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 04b4867ef4..c2dffcdde7 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -127,7 +127,6 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda if ( replayUnsafe || emittedClientTool - || _parsed._cursorIsolateConversation === true || activeRequest.contextUsageStoreCheckpoints === false || !lastTransport?.captured || lastTransport.captured.byteLength === 0 @@ -253,7 +252,6 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda if ( request.checkpointInvalidationReason && request.checkpointInvalidationReason !== "missing_ref" - && request.checkpointInvalidationReason !== "isolated_turn" && request.checkpointInvalidationReason !== "compaction" ) { invalidateCursorCheckpoint(_parsed._providerContinuation?.cursor?.checkpointRef); diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index fd9160d461..0056bfe595 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -20,7 +20,6 @@ export type CursorCheckpointInvalidationReason = | "identity_changed" | "model_changed" | "compaction" - | "isolated_turn" | "trailing_tool_result" | "force_fresh" | "upstream_invalid_argument"; @@ -149,6 +148,17 @@ export function commitCursorCheckpoint(input: { return store.snapshots.has(ref) ? ref : undefined; } +export function getLatestCursorCheckpoint( + match: (snapshot: CursorCheckpointSnapshot) => boolean, +): CursorCheckpointSnapshot | undefined { + prune(); + let found: CursorCheckpointSnapshot | undefined; + for (const snapshot of store.snapshots.values()) { + if (match(snapshot)) found = snapshot; + } + return found ? getCursorCheckpoint(found.ref) : undefined; +} + export function getCursorCheckpoint(ref: string | undefined): CursorCheckpointSnapshot | undefined { if (!ref) return undefined; prune(); diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index de91fa6279..3b082ec74e 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -27,7 +27,9 @@ import { import { lookupCursorThreadConversation } from "./thread-continuity"; import { getCursorCheckpoint, + getLatestCursorCheckpoint, type CursorCheckpointInvalidationReason, + type CursorCheckpointSnapshot, } from "./checkpoint-store"; /** Probe-verified Cursor Connect boundaries, with byte headroom for the enclosing field. */ @@ -289,53 +291,76 @@ export function resolveCursorConversationId( options: CreateCursorRequestOptions = {}, ): string { if (options.forceFreshConversation === true) return generatedCursorConversationId(); - // Helper/shadow/compaction turns must not append into the parent's Cursor conversation, - // even when previous_response_id restored the parent's remembered id. - if (parsed._cursorIsolateConversation === true) return generatedCursorConversationId(); + // Helper/shadow turns must not append into the parent's Cursor conversation, even when + // previous_response_id restored the parent's remembered id. They still keep their own + // cache, keyed off this turn's first user/developer text. + if (parsed._cursorIsolateConversation === true) { + const isolatedSeed = cursorHistorySeed(parsed); + if (isolatedSeed) { + return cursorConversationIdFromClientThread(`isolate:${isolatedSeed}`, parsed._cursorIdentityScope); + } + return generatedCursorConversationId(); + } if (parsed._cursorConversationId) return parsed._cursorConversationId; const threadId = parsed._clientThreadId?.trim(); if (threadId) { const recovered = lookupCursorThreadConversation(threadId, parsed._cursorIdentityScope); if (recovered) return recovered; - return cursorConversationIdFromClientThread(threadId, parsed._cursorIdentityScope); + return cursorConversationIdFromClientThread(`thread:${threadId}`, parsed._cursorIdentityScope); } + const historySeed = cursorHistorySeed(parsed); + if (historySeed) return cursorConversationIdFromClientThread(`history:${historySeed}`, parsed._cursorIdentityScope); return generatedCursorConversationId(); } +function cursorHistorySeed(parsed: OcxParsedRequest): string | undefined { + for (const message of parsed.context.messages) { + if (message.role !== "user" && message.role !== "developer") continue; + const text = contentToText(message.content).trim(); + if (text) return text; + } + return undefined; +} + export interface CreateCursorRequestOptions { /** Force a brand-new Cursor conversation id even when remembered state exists. */ forceFreshConversation?: boolean; } -function checkpointInvalidationReason( +function resolveCursorCheckpoint( parsed: OcxParsedRequest, request: CursorRunRequest, options: CreateCursorRequestOptions, -): CursorCheckpointInvalidationReason | undefined { - if (options.forceFreshConversation === true) return "force_fresh"; - if (parsed._cursorIsolateConversation === true) return "isolated_turn"; - if (parsed._compactionRequest === true || parsed._contextCompactionBoundary === true) return "compaction"; +): { snapshot: CursorCheckpointSnapshot } | { reason: CursorCheckpointInvalidationReason } { + if (options.forceFreshConversation === true) return { reason: "force_fresh" }; + if (parsed._compactionRequest === true || parsed._contextCompactionBoundary === true) return { reason: "compaction" }; + const isolated = parsed._cursorIsolateConversation === true; const cursorState = parsed._providerContinuation?.cursor; - const ref = cursorState?.checkpointRef; - if (!ref) return "missing_ref"; - const snapshot = getCursorCheckpoint(ref); - if (!snapshot) return "expired"; - if (snapshot.conversationId !== request.conversationId) return "conversation_changed"; + const ref = isolated ? undefined : cursorState?.checkpointRef; const identityScope = parsed._cursorIdentityScope?.trim() || "local"; - if (snapshot.identityScope !== identityScope) return "identity_changed"; - if (cursorCheckpointModelAffinityId(snapshot.modelId) !== cursorCheckpointModelAffinityId(request.modelId)) { - return "model_changed"; + const modelAffinity = cursorCheckpointModelAffinityId(request.modelId); + let snapshot = getCursorCheckpoint(ref); + if (!snapshot) { + snapshot = getLatestCursorCheckpoint(candidate => + candidate.conversationId === request.conversationId + && candidate.identityScope === identityScope + && cursorCheckpointModelAffinityId(candidate.modelId) === modelAffinity + ); } + if (!snapshot) return { reason: ref ? "expired" : "missing_ref" }; + if (snapshot.conversationId !== request.conversationId) return { reason: "conversation_changed" }; + if (snapshot.identityScope !== identityScope) return { reason: "identity_changed" }; + if (cursorCheckpointModelAffinityId(snapshot.modelId) !== modelAffinity) return { reason: "model_changed" }; const lastRole = parsed.context.messages.at(-1)?.role; if (lastRole === "toolResult") { - if (snapshot.coveredMessageCount === undefined) return "trailing_tool_result"; + if (snapshot.coveredMessageCount === undefined) return { reason: "trailing_tool_result" }; if (snapshot.coveredMessageCount < 0 || snapshot.coveredMessageCount >= parsed.context.messages.length) { - return "trailing_tool_result"; + return { reason: "trailing_tool_result" }; } } else if (cursorState?.checkpointUsable === false) { - return "trailing_tool_result"; + return { reason: "trailing_tool_result" }; } - return undefined; + return { snapshot }; } export function createCursorRequest( @@ -364,22 +389,16 @@ export function createCursorRequest( ...(parsed.options.toolChoice ? { toolChoice: parsed.options.toolChoice } : {}), ...(parsed.options.parallelToolCalls !== undefined ? { parallelToolCalls: parsed.options.parallelToolCalls } : {}), }; - const invalidation = checkpointInvalidationReason(parsed, request, options); - if (invalidation) { - request.continuationMode = "full-replay"; - request.checkpointInvalidationReason = invalidation; - return request; - } - const snapshot = getCursorCheckpoint(parsed._providerContinuation?.cursor?.checkpointRef); - if (!snapshot) { + const resolved = resolveCursorCheckpoint(parsed, request, options); + if ("reason" in resolved) { request.continuationMode = "full-replay"; - request.checkpointInvalidationReason = "missing_ref"; + request.checkpointInvalidationReason = resolved.reason; return request; } - request.checkpointBytes = snapshot.checkpointBytes; + request.checkpointBytes = resolved.snapshot.checkpointBytes; request.continuationMode = "checkpoint"; - if (parsed.context.messages.at(-1)?.role === "toolResult" && snapshot.coveredMessageCount !== undefined) { - request.checkpointSuffixStart = snapshot.coveredMessageCount; + if (parsed.context.messages.at(-1)?.role === "toolResult" && resolved.snapshot.coveredMessageCount !== undefined) { + request.checkpointSuffixStart = resolved.snapshot.coveredMessageCount; } return request; } diff --git a/src/adapters/cursor/types.ts b/src/adapters/cursor/types.ts index 28fbdb4d1d..c7b8d65a2d 100644 --- a/src/adapters/cursor/types.ts +++ b/src/adapters/cursor/types.ts @@ -1,6 +1,7 @@ import type { OcxUsage } from "../../types"; import type { OcxMessage, OcxRequestOptions, OcxTool } from "../../types"; import type { CursorRoutingLevel } from "./discovery"; +import type { CursorCheckpointInvalidationReason } from "./checkpoint-store"; export interface CursorRequestedModelParameter { id: string; @@ -37,7 +38,7 @@ export interface CursorRunRequest { */ checkpointBytes?: Uint8Array; continuationMode?: "full-replay" | "checkpoint"; - checkpointInvalidationReason?: string; + checkpointInvalidationReason?: CursorCheckpointInvalidationReason; /** * When set with checkpointBytes, only this suffix of rawMessages is replayed onto the * decoded ConversationStateStructure. Used for tool-result continuations. diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 22961b20d2..a38e12b89a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -519,10 +519,13 @@ pre-compaction checkpoint is not persisted for later carry-forward. After a successful no-tool turn, the Cursor adapter keeps the returned ConversationStateStructure in a process-local store and reuses that snapshot on the next validated linear continuation instead of rebuilding rootPromptMessagesJson and conversationTurns. Tool-result turns reuse the last completed -checkpoint plus only the uncovered suffix. Compaction, helper/shadow isolation, account or model -mismatch, missing refs, decode failures, and invalid_argument recovery keep the existing full-replay -path. previous_response_id may select a branch's opaque checkpointRef; it is never a Cursor -conversation ownership key. Cursor Connect still does not expose authoritative cache_read_tokens. +checkpoint plus only the uncovered suffix. Chat Completions hops that omit previous_response_id and +thread headers pin to the first user/developer text and reuse the live snapshot for that +conversation. Isolated helper/shadow turns keep their own checkpoint and never join the parent +conversation. Compaction, account or model mismatch, missing refs, decode failures, and +invalid_argument recovery keep the existing full-replay path. previous_response_id may select a +branch's opaque checkpointRef; it is never a Cursor conversation ownership key. Cursor Connect still +does not expose authoritative cache_read_tokens. ```text [Decision Log] diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index 6e09303fb1..c33aad62e1 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -73,6 +73,87 @@ describe("Cursor request builder", () => { expect(continuation.conversationId).toBe(initial.conversationId); }); + test("pins chat-style store:false turns to the first user message when no thread header exists", () => { + const first = createCursorRequest({ + ...base, + modelId: "cursor/gpt-5.6-sol", + context: { messages: [{ role: "user", content: "fix the cache in sol", timestamp: 1 }] }, + }); + const toolHop = createCursorRequest({ + ...base, + modelId: "cursor/gpt-5.6-sol", + context: { + messages: [ + { role: "user", content: "fix the cache in sol", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "calling" }], timestamp: 2 }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "read_file", + content: "ok", + isError: false, + timestamp: 3, + }, + ], + }, + }); + const other = createCursorRequest({ + ...base, + modelId: "cursor/gpt-5.6-sol", + context: { messages: [{ role: "user", content: "a different task", timestamp: 1 }] }, + }); + + expect(toolHop.conversationId).toBe(first.conversationId); + expect(other.conversationId).not.toBe(first.conversationId); + }); + + test("reuses a live checkpoint by conversation when chat omits the continuation ref", () => { + clearCursorCheckpointsForTests(); + const first = createCursorRequest({ + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user", content: "fix the cache in sol", timestamp: 1 }] }, + }); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["chat-store-false"], + })); + const checkpointRef = commitCursorCheckpoint({ + conversationId: first.conversationId, + identityScope: "acct-1", + modelId: first.modelId, + checkpointBytes, + coveredMessageCount: 1, + }); + expect(checkpointRef).toBeDefined(); + + const toolHop = createCursorRequest({ + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorIdentityScope: "acct-1", + context: { + messages: [ + { role: "user", content: "fix the cache in sol", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "calling" }], timestamp: 2 }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "read_file", + content: "ok", + isError: false, + timestamp: 3, + }, + ], + }, + }); + + expect(toolHop.conversationId).toBe(first.conversationId); + expect(toolHop.continuationMode).toBe("checkpoint"); + expect(toolHop.checkpointBytes?.byteLength).toBe(checkpointBytes.byteLength); + expect(toolHop.checkpointSuffixStart).toBe(1); + clearCursorCheckpointsForTests(); + }); + test("isolates client threads even when they share a prompt cache key", () => { const first = createCursorRequest({ ...base, @@ -145,6 +226,79 @@ describe("Cursor request builder", () => { expect(helper.conversationId).not.toBe(main.conversationId); }); + test("isolated helper turns keep their own cache and never reuse the parent checkpoint", () => { + clearCursorCheckpointsForTests(); + const parentBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["parent-fixture"], + })); + const parentRef = commitCursorCheckpoint({ + conversationId: "cursor_parent_real", + identityScope: "acct-1", + modelId: "default", + checkpointBytes: parentBytes, + coveredMessageCount: 1, + }); + expect(parentRef).toBeDefined(); + + const helperPrompt = { role: "user" as const, content: "summarize this helper task", timestamp: 1 }; + const first = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_parent_real", + _cursorIdentityScope: "acct-1", + _cursorIsolateConversation: true, + _clientThreadId: "thread-a", + _providerContinuation: { + cursor: { conversationId: "cursor_parent_real", checkpointUsable: true, checkpointRef: parentRef }, + }, + context: { messages: [helperPrompt] }, + }); + expect(first.conversationId).not.toBe("cursor_parent_real"); + expect(first.continuationMode).toBe("full-replay"); + expect(first.checkpointBytes).toBeUndefined(); + + const helperBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["helper-fixture"], + })); + const helperRef = commitCursorCheckpoint({ + conversationId: first.conversationId, + identityScope: "acct-1", + modelId: first.modelId, + checkpointBytes: helperBytes, + coveredMessageCount: 1, + }); + expect(helperRef).toBeDefined(); + + const second = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_parent_real", + _cursorIdentityScope: "acct-1", + _cursorIsolateConversation: true, + _clientThreadId: "thread-a", + _providerContinuation: { + cursor: { conversationId: "cursor_parent_real", checkpointUsable: true, checkpointRef: parentRef }, + }, + context: { + messages: [ + helperPrompt, + { role: "assistant", content: [{ type: "text", text: "calling" }], timestamp: 2 }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "read_file", + content: "ok", + isError: false, + timestamp: 3, + }, + ], + }, + }); + expect(second.conversationId).toBe(first.conversationId); + expect(second.continuationMode).toBe("checkpoint"); + expect(second.checkpointBytes?.byteLength).toBe(helperBytes.byteLength); + expect(getCursorCheckpoint(parentRef)?.ref).toBe(parentRef); + clearCursorCheckpointsForTests(); + }); + test("isolation wins over a remembered parent conversation id", () => { const helper = createCursorRequest({ ...base, @@ -741,8 +895,8 @@ describe("Cursor request builder", () => { cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef }, }, }); + expect(isolated.conversationId).not.toBe("cursor_stable"); expect(isolated.continuationMode).toBe("full-replay"); - expect(isolated.checkpointInvalidationReason).toBe("isolated_turn"); expect(isolated.checkpointBytes).toBeUndefined(); const toolResult = createCursorRequest({ @@ -806,11 +960,20 @@ describe("Cursor request builder", () => { }); expect(checkpointRef).toBeDefined(); - const missing = createCursorRequest({ + const implied = createCursorRequest({ ...base, + modelId: "cursor/grok-4.6", _cursorConversationId: "cursor_stable", _cursorIdentityScope: "acct-1", }); + expect(implied.continuationMode).toBe("checkpoint"); + expect(implied.checkpointBytes?.byteLength).toBe(checkpointBytes.byteLength); + + const missing = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_unknown", + _cursorIdentityScope: "acct-1", + }); expect(missing.continuationMode).toBe("full-replay"); expect(missing.checkpointInvalidationReason).toBe("missing_ref"); From 3c3f8431f7071313c125684fb60c3fd319e7f72c Mon Sep 17 00:00:00 2001 From: keepitmello <71975659+keepitmello@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:05:51 +0900 Subject: [PATCH 3/5] fix(cursor): keep recovered checkpoints and close review holes Do not invalidate the checkpoint just committed during forced-fresh recovery. Invalidate the inherited ref, including compaction leftovers. Pin checkpoint blobs atomically, collect nested subagent blob ids, and keep suffix replay off the system prompt. Refs #1527 --- .../src/content/docs/ko/reference/adapters.md | 9 +-- .../src/content/docs/reference/adapters.md | 9 +-- src/adapters/cursor.ts | 4 +- src/adapters/cursor/checkpoint-store.ts | 41 +++++++------ src/adapters/cursor/native-exec.ts | 15 +++-- src/adapters/cursor/protobuf-request.ts | 4 +- tests/cursor-adapter.test.ts | 60 +++++++++++++++++++ tests/cursor-blob.test.ts | 14 ++++- 8 files changed, 121 insertions(+), 35 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index d8b113c740..a6ed3c8d5c 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -142,12 +142,13 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. - content-addressed blob으로 대화 상태를 재생하고 서버 툴 호출을 Codex에 다시 매핑합니다. protobuf `GetUsableModels` RPC로 실시간 Cursor 모델을 찾으며, run 요청이 wire에 commit되기 전까지만 재시도합니다. - 도구 없이 정상 완료된 턴 뒤에는 Cursor가 돌려준 ConversationStateStructure를 프로세스 로컬 +- 도구 없이 정상 완료된 턴 뒤에는 Cursor가 돌려준 ConversationStateStructure를 프로세스 로컬 store에 보관하고, 검증된 선형 이어말하기에서는 전체 root history를 다시 만들지 않고 그 checkpoint를 재사용합니다. tool-result 턴은 마지막 정상 완료 턴의 checkpoint에 커버되지 않은 - suffix만 붙입니다. compaction, helper/shadow 격리, 계정/모델 불일치, 없는 ref, decode 실패는 - 기존 full replay로 돌아갑니다. Cursor Connect는 권위 있는 cache_read_tokens를 주지 않으므로 - OpenCodex usage만 보고 cache hit라고 단정하지 않습니다. + suffix만 붙입니다. compaction, helper/shadow 격리, 계정/모델 불일치, 없는 ref, decode 실패, + forced-fresh 복구, invalid_argument 재시도는 기존 full replay로 돌아갑니다. Cursor Connect는 + 권위 있는 cache_read_tokens를 주지 않으므로 OpenCodex usage만 보고 cache hit라고 단정하지 + 않습니다. - `cursor/grok-4.5-fast`는 선택 가능한 모델로 유지하되, Cursor에는 정식 `grok-4.5` 모델을 보내고 별도의 `effort`, `fast=true` 값은 `requested_model.parameters`에 담습니다. - Cursor 네이티브 로컬 파일시스템/shell/network 실행은 기본적으로 거부합니다. 명시적인 diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 970f62cd06..ec7635d3e9 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -195,13 +195,14 @@ advertised effort control on those models as proof of upstream-native reasoning - Replays conversation state through content-addressed blobs, maps server tool calls back to Codex, discovers live Cursor models through the protobuf `GetUsableModels` RPC, and retries only before a run request is committed to the wire. - After a successful no-tool turn, the adapter keeps Cursor's returned ConversationStateStructure +- After a successful no-tool turn, the adapter keeps Cursor's returned ConversationStateStructure in a process-local store and reuses that checkpoint on the next validated linear continuation instead of rebuilding the full root history. Tool-result turns reuse the last completed-turn checkpoint plus only the uncovered suffix when the covered message boundary is known. - Compaction, helper/shadow isolation, account/model mismatch, missing refs, and decode failures - fall back to the existing full replay. Cursor Connect still does not expose authoritative - cache_read_tokens, so OpenCodex usage is not a cache-hit counter. + Compaction, helper/shadow isolation, account/model mismatch, missing refs, decode failures, + forced-fresh recovery, and invalid_argument retries fall back to the existing full replay. Cursor + Connect still does not expose authoritative cache_read_tokens, so OpenCodex usage is not a + cache-hit counter. - Exposes Cursor Router as `cursor/auto` plus explicit `cursor/auto-cost`, `cursor/auto-balance`, and `cursor/auto-intelligence` entries. Explicit levels are encoded in `requested_model.parameters` while the legacy `cursor/auto` entry retains the account/team default. diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index c2dffcdde7..c0fd9586d4 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -103,6 +103,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda /* Missing credential is handled by the live transport path below. */ } } + const inheritedCheckpointRef = _parsed._providerContinuation?.cursor?.checkpointRef; const previousConversationId = _parsed._cursorConversationId; let request = createCursorRequest(_parsed); // The builder may derive a stable provider id from the client thread when Responses state @@ -252,9 +253,8 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda if ( request.checkpointInvalidationReason && request.checkpointInvalidationReason !== "missing_ref" - && request.checkpointInvalidationReason !== "compaction" ) { - invalidateCursorCheckpoint(_parsed._providerContinuation?.cursor?.checkpointRef); + invalidateCursorCheckpoint(inheritedCheckpointRef); debugProviderDiagnostic("cursor", "checkpoint-invalidated", { reason: request.checkpointInvalidationReason, }); diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index 0056bfe595..4e89529993 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { fromBinary } from "@bufbuild/protobuf"; -import { ConversationStateStructureSchema } from "./gen/agent_pb"; +import { ConversationStateStructureSchema, type ConversationStateStructure } from "./gen/agent_pb"; import { createCursorBlobCheckpointLease, pinCursorBlobIdsForCheckpoint, @@ -69,24 +69,31 @@ function deleteSnapshot(ref: string): void { store.totalBytes = Math.max(0, store.totalBytes - existing.checkpointBytes.byteLength); } +function collectStateBlobIds(state: ConversationStateStructure, ids: Uint8Array[]): void { + ids.push( + ...state.rootPromptMessagesJson, + ...state.turns, + ...state.turnsOld, + ...state.todos, + ...state.summaryArchives, + ); + if (state.summary) ids.push(state.summary); + if (state.summaryArchive) ids.push(state.summaryArchive); + if (state.plan) ids.push(state.plan); + for (const value of Object.values(state.fileStates)) ids.push(value); + for (const value of Object.values(state.fileStatesV2)) { + if (value.content) ids.push(value.content); + if (value.initialContent) ids.push(value.initialContent); + } + for (const nested of Object.values(state.subagentStates)) { + if (nested.conversationState) collectStateBlobIds(nested.conversationState, ids); + } +} + function collectCheckpointBlobIds(checkpointBytes: Uint8Array): Uint8Array[] | undefined { try { - const state = fromBinary(ConversationStateStructureSchema, checkpointBytes); - const ids: Uint8Array[] = [ - ...state.rootPromptMessagesJson, - ...state.turns, - ...state.turnsOld, - ...state.todos, - ...state.summaryArchives, - ]; - if (state.summary) ids.push(state.summary); - if (state.summaryArchive) ids.push(state.summaryArchive); - if (state.plan) ids.push(state.plan); - for (const value of Object.values(state.fileStates)) ids.push(value); - for (const value of Object.values(state.fileStatesV2)) { - if (value.content) ids.push(value.content); - if (value.initialContent) ids.push(value.initialContent); - } + const ids: Uint8Array[] = []; + collectStateBlobIds(fromBinary(ConversationStateStructureSchema, checkpointBytes), ids); return ids.filter(id => id.byteLength > 0); } catch { return undefined; diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index 750cfbef7d..c72fa4715a 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -419,16 +419,23 @@ export function pinCursorBlobIdsForCheckpoint( ): boolean { const state = blobRequestScopes.get(lease); if (!state || state.sealed) return false; + const added: Array<{ entry: { requestPins: Set }; key: string }> = []; for (const blobId of blobIds) { if (blobId.byteLength === 0) continue; const k = key(blobId); const entry = blobs.get(k); - if (!entry) return false; - if (isExpired(entry, Date.now()) && entry.requestPins.size === 0 && entry.provenance !== "remote-setBlobArgs") { + if (!entry || (isExpired(entry, Date.now()) && entry.requestPins.size === 0 && entry.provenance !== "remote-setBlobArgs")) { + for (const pinned of added) { + pinned.entry.requestPins.delete(lease); + state.keys.delete(pinned.key); + } return false; } - entry.requestPins.add(lease); - state.keys.add(k); + if (!entry.requestPins.has(lease)) { + entry.requestPins.add(lease); + state.keys.add(k); + added.push({ entry, key: k }); + } } reconcileBlobClassAccountingAndEnforce(); return true; diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index bf545afb0d..5dd524e974 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -25,6 +25,7 @@ import { ConversationActionSchema, ConversationStepSchema, ConversationStateStructureSchema, + type ConversationStateStructure, ConversationTurnStructureSchema, McpArgsSchema, McpSuccessSchema, @@ -800,7 +801,7 @@ function buildPreparedCursorRunRequest( }); let continuationMode: "full-replay" | "checkpoint" = "full-replay"; let checkpointInvalidationReason = request.checkpointInvalidationReason; - let conversationState; + let conversationState: ConversationStateStructure | undefined; let rootPromptMessagesState: ReturnType | undefined; if (request.checkpointBytes && request.checkpointBytes.byteLength > 0) { try { @@ -816,6 +817,7 @@ function buildPreparedCursorRunRequest( ) { const suffixRequest: CursorRunRequest = { ...request, + system: [], rawMessages: request.rawMessages.slice(suffixStart), }; const suffixRoots = rootPromptMessages(suffixRequest, requestScope); diff --git a/tests/cursor-adapter.test.ts b/tests/cursor-adapter.test.ts index 8294beac78..68f5994c2c 100644 --- a/tests/cursor-adapter.test.ts +++ b/tests/cursor-adapter.test.ts @@ -334,6 +334,66 @@ describe("Cursor adapter live transport", () => { expect(events.filter(event => event.type === "error")).toHaveLength(0); }); + test("forced-fresh recovery keeps the new checkpoint instead of deleting it", async () => { + clearCursorCheckpointsForTests(); + const parentBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["parent-stale"], + })); + const parentRef = commitCursorCheckpoint({ + conversationId: "cursor_stale", + identityScope: "acct-fresh", + modelId: "gpt-5.6-sol", + checkpointBytes: parentBytes, + }); + expect(parentRef).toBeDefined(); + const recoveredBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["recovered"], + })); + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run() { + attempts += 1; + if (attempts === 1) { + throw Object.assign( + new Error("Cursor invalid request: Cursor Connect error invalid_argument: Error"), + { code: "invalid_argument" }, + ); + } + yield { type: "done" } satisfies CursorServerMessage; + }, + writeClient() {}, + capturedConversationCheckpoint() { + return attempts === 1 ? undefined : recoveredBytes; + }, + }), + }); + const body: OcxParsedRequest = { + modelId: "cursor/gpt-5.6-sol", + context: { messages: [{ role: "user", content: "retry me", timestamp: 1 }] }, + stream: false, + options: {}, + _cursorConversationId: "cursor_stale", + _cursorIdentityScope: "acct-fresh", + _providerContinuation: { + cursor: { conversationId: "cursor_stale", checkpointUsable: true, checkpointRef: parentRef }, + }, + }; + const events: AdapterEvent[] = []; + await adapter.runTurn?.(body, { headers: new Headers() }, event => events.push(event)); + expect(attempts).toBe(2); + expect(getCursorCheckpoint(parentRef)).toBeUndefined(); + const done = events.find(event => event.type === "done"); + const newRef = done && done.type === "done" ? done.providerState?.cursor?.checkpointRef : undefined; + expect(newRef).toBeDefined(); + expect(newRef).not.toBe(parentRef); + expect(getCursorCheckpoint(newRef)?.conversationId).toBe(body._cursorConversationId); + clearCursorCheckpointsForTests(); + }); + test("does not replay invalid_argument after a local side effect", async () => { let attempts = 0; const adapter = createCursorAdapter({ diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index 476812adb1..d65b6dfa11 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -1439,8 +1439,14 @@ describe("Cursor checkpoint request construction", () => { checkpointBytes: new Uint8Array([1, 2, 3, 4]), }); const roots = decodeRootMessages(prepared.bytes); - expect(roots.length).toBeGreaterThan(0); - expect(JSON.stringify(roots)).toContain("You are helpful."); + const fullReplay = decodeRootMessages(prepareCursorRunRequest({ + modelId: "grok-4.6", + conversationId: "cursor_ckpt", + system: ["You are helpful."], + messages: [{ role: "user", content: "hello" }], + rawMessages: [{ role: "user", content: "hello", timestamp: 1 }], + }).bytes); + expect(roots).toEqual(fullReplay); }); test("checkpoint suffix replay appends only uncovered history", () => { @@ -1540,7 +1546,9 @@ describe("Cursor checkpoint request construction", () => { }); expect(ref).toBeDefined(); releaseCursorBlobRequestScope(requestScope); - expectBlobHit(blobId, data, requestScope); + const hydrateScope = createCursorBlobRequestScope(); + expectBlobHit(blobId, data, hydrateScope); + releaseCursorBlobRequestScope(hydrateScope); expect(cursorBlobRetainedStoreSnapshot().pinnedBytes).toBeGreaterThan(0); expect(evictOldestCursorBlobForBudget()).toBe(0); invalidateCursorCheckpoint(ref); From a8f007d0c3907b4537168e48ec8dd7a69831c28b Mon Sep 17 00:00:00 2001 From: keepitmello <71975659+keepitmello@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:44:24 +0900 Subject: [PATCH 4/5] fix(cursor): fail closed on stale refs and prefix collisions An explicit missing checkpointRef now expires instead of picking another snapshot. Ref-less Chat hops look up only a unique covered-prefix plus system digest. Identical first prompts no longer share a conversation. Expired snapshots are pruned by an unref timer, not the next request. Refs #1527 --- src/adapters/cursor.ts | 11 +- src/adapters/cursor/checkpoint-store.ts | 109 +++++++++- src/adapters/cursor/request-builder.ts | 108 ++++++---- structure/04_transports-and-sidecars.md | 7 +- tests/cursor-blob.test.ts | 38 ++++ tests/cursor-request-builder.test.ts | 267 +++++++++++++++++------- 6 files changed, 419 insertions(+), 121 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index c0fd9586d4..ed71ce7fd8 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -7,7 +7,11 @@ import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErro import { cursorCheckpointModelAffinityId, isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; -import { createCursorRequest } from "./cursor/request-builder"; +import { + createCursorRequest, + cursorCoveredPrefixDigest, + cursorInstructionDigest, +} from "./cursor/request-builder"; import { createLiveCursorTransport, CursorMissingCredentialError, @@ -133,12 +137,15 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda || lastTransport.captured.byteLength === 0 ) return; const previousRef = _parsed._providerContinuation?.cursor?.checkpointRef; + const coveredMessageCount = _parsed.context.messages.length; const checkpointRef = commitCursorCheckpoint({ conversationId: activeRequest.conversationId, identityScope: _parsed._cursorIdentityScope, modelId: cursorCheckpointModelAffinityId(activeRequest.modelId), checkpointBytes: lastTransport.captured, - coveredMessageCount: _parsed.context.messages.length, + coveredMessageCount, + prefixDigest: cursorCoveredPrefixDigest(_parsed, coveredMessageCount), + systemDigest: cursorInstructionDigest(_parsed), }); if (!checkpointRef) return; if (previousRef && previousRef !== checkpointRef) invalidateCursorCheckpoint(previousRef); diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index 4e89529993..337b07caa5 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -22,7 +22,8 @@ export type CursorCheckpointInvalidationReason = | "compaction" | "trailing_tool_result" | "force_fresh" - | "upstream_invalid_argument"; + | "upstream_invalid_argument" + | "lineage_mismatch"; export interface CursorCheckpointSnapshot { ref: string; @@ -34,20 +35,77 @@ export interface CursorCheckpointSnapshot { lastAccessAt: number; blobLease?: CursorBlobRequestScopeToken; coveredMessageCount?: number; + prefixDigest?: string; + systemDigest?: string; } interface CursorCheckpointStore { snapshots: Map; + prefixIndex: Map>; totalBytes: number; } const store: CursorCheckpointStore = { snapshots: new Map(), + prefixIndex: new Map(), totalBytes: 0, }; +let nowFn = (): number => Date.now(); +let scheduleFn = (fn: () => void, ms: number): ReturnType => { + const timer = setTimeout(fn, ms); + timer.unref?.(); + return timer; +}; +let clearScheduleFn = (timer: ReturnType): void => { + clearTimeout(timer); +}; +let pruneTimer: ReturnType | undefined; + function now(): number { - return Date.now(); + return nowFn(); +} + +export function installCursorCheckpointClockForTests(input: { + now?: () => number; + schedule?: (fn: () => void, ms: number) => ReturnType; + clear?: (timer: ReturnType) => void; +}): void { + if (input.now) nowFn = input.now; + if (input.schedule) scheduleFn = input.schedule; + if (input.clear) clearScheduleFn = input.clear; +} + +export function resetCursorCheckpointClockForTests(): void { + nowFn = () => Date.now(); + scheduleFn = (fn, ms) => { + const timer = setTimeout(fn, ms); + timer.unref?.(); + return timer; + }; + clearScheduleFn = timer => { + clearTimeout(timer); + }; + stopPruneTimer(); +} + +function stopPruneTimer(): void { + if (pruneTimer !== undefined) clearScheduleFn(pruneTimer); + pruneTimer = undefined; +} + +function schedulePrune(at = now()): void { + stopPruneTimer(); + let nextExpiry = Number.POSITIVE_INFINITY; + for (const snapshot of store.snapshots.values()) { + nextExpiry = Math.min(nextExpiry, snapshot.lastAccessAt + CURSOR_CHECKPOINT_TTL_MS); + } + if (!Number.isFinite(nextExpiry)) return; + pruneTimer = scheduleFn(() => { + pruneTimer = undefined; + prune(); + schedulePrune(); + }, Math.max(0, nextExpiry - at)); } function prune(at = now()): void { @@ -59,12 +117,29 @@ function prune(at = now()): void { if (oldest === undefined) break; deleteSnapshot(oldest); } + schedulePrune(at); +} + +function indexPrefix(digest: string | undefined, ref: string): void { + if (!digest) return; + const refs = store.prefixIndex.get(digest) ?? new Set(); + refs.add(ref); + store.prefixIndex.set(digest, refs); +} + +function unindexPrefix(digest: string | undefined, ref: string): void { + if (!digest) return; + const refs = store.prefixIndex.get(digest); + if (!refs) return; + refs.delete(ref); + if (refs.size === 0) store.prefixIndex.delete(digest); } function deleteSnapshot(ref: string): void { const existing = store.snapshots.get(ref); if (!existing) return; if (existing.blobLease) releaseCursorBlobRequestScope(existing.blobLease); + unindexPrefix(existing.prefixDigest, ref); store.snapshots.delete(ref); store.totalBytes = Math.max(0, store.totalBytes - existing.checkpointBytes.byteLength); } @@ -110,6 +185,8 @@ export function commitCursorCheckpoint(input: { modelId: string; checkpointBytes: Uint8Array; coveredMessageCount?: number; + prefixDigest?: string; + systemDigest?: string; }): string | undefined { if (!input.conversationId || !input.modelId || input.checkpointBytes.byteLength === 0) return undefined; if (input.checkpointBytes.byteLength > CURSOR_CHECKPOINT_MAX_TOTAL_BYTES) return undefined; @@ -137,6 +214,8 @@ export function commitCursorCheckpoint(input: { createdAt, lastAccessAt: createdAt, ...(input.coveredMessageCount !== undefined ? { coveredMessageCount: input.coveredMessageCount } : {}), + ...(input.prefixDigest ? { prefixDigest: input.prefixDigest } : {}), + ...(input.systemDigest ? { systemDigest: input.systemDigest } : {}), }; const blobIds = collectCheckpointBlobIds(input.checkpointBytes); if (blobIds === undefined) return undefined; @@ -151,10 +230,33 @@ export function commitCursorCheckpoint(input: { deleteSnapshot(ref); store.snapshots.set(ref, snapshot); store.totalBytes += snapshot.checkpointBytes.byteLength; + indexPrefix(snapshot.prefixDigest, ref); prune(createdAt); return store.snapshots.has(ref) ? ref : undefined; } +export function getCursorCheckpointForPrefix(input: { + prefixDigest: string; + systemDigest: string; + coveredMessageCount: number; + identityScope?: string; + modelId: string; +}): CursorCheckpointSnapshot | undefined { + prune(); + const refs = store.prefixIndex.get(input.prefixDigest); + if (!refs || refs.size !== 1) return undefined; + const [ref] = refs; + if (!ref) return undefined; + const snapshot = getCursorCheckpoint(ref); + if (!snapshot) return undefined; + const identityScope = input.identityScope?.trim() || "local"; + if (snapshot.systemDigest !== input.systemDigest) return undefined; + if (snapshot.coveredMessageCount !== input.coveredMessageCount) return undefined; + if (snapshot.identityScope !== identityScope) return undefined; + if (snapshot.modelId !== input.modelId) return undefined; + return snapshot; +} + export function getLatestCursorCheckpoint( match: (snapshot: CursorCheckpointSnapshot) => boolean, ): CursorCheckpointSnapshot | undefined { @@ -188,9 +290,12 @@ export function invalidateCursorCheckpoint(ref: string | undefined): void { } export function clearCursorCheckpointsForTests(): void { + stopPruneTimer(); for (const ref of [...store.snapshots.keys()]) deleteSnapshot(ref); store.snapshots.clear(); + store.prefixIndex.clear(); store.totalBytes = 0; + resetCursorCheckpointClockForTests(); } export function cursorCheckpointStoreMetricsForTests(): { count: number; totalBytes: number } { diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 3b082ec74e..ff11845705 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -27,7 +27,7 @@ import { import { lookupCursorThreadConversation } from "./thread-continuity"; import { getCursorCheckpoint, - getLatestCursorCheckpoint, + getCursorCheckpointForPrefix, type CursorCheckpointInvalidationReason, type CursorCheckpointSnapshot, } from "./checkpoint-store"; @@ -291,16 +291,7 @@ export function resolveCursorConversationId( options: CreateCursorRequestOptions = {}, ): string { if (options.forceFreshConversation === true) return generatedCursorConversationId(); - // Helper/shadow turns must not append into the parent's Cursor conversation, even when - // previous_response_id restored the parent's remembered id. They still keep their own - // cache, keyed off this turn's first user/developer text. - if (parsed._cursorIsolateConversation === true) { - const isolatedSeed = cursorHistorySeed(parsed); - if (isolatedSeed) { - return cursorConversationIdFromClientThread(`isolate:${isolatedSeed}`, parsed._cursorIdentityScope); - } - return generatedCursorConversationId(); - } + if (parsed._cursorIsolateConversation === true) return generatedCursorConversationId(); if (parsed._cursorConversationId) return parsed._cursorConversationId; const threadId = parsed._clientThreadId?.trim(); if (threadId) { @@ -308,18 +299,29 @@ export function resolveCursorConversationId( if (recovered) return recovered; return cursorConversationIdFromClientThread(`thread:${threadId}`, parsed._cursorIdentityScope); } - const historySeed = cursorHistorySeed(parsed); - if (historySeed) return cursorConversationIdFromClientThread(`history:${historySeed}`, parsed._cursorIdentityScope); return generatedCursorConversationId(); } -function cursorHistorySeed(parsed: OcxParsedRequest): string | undefined { +export function cursorInstructionDigest(parsed: OcxParsedRequest): string { + const hash = createHash("sha256").update("ocx:cursor:sys:"); + for (const line of parsed.context.systemPrompt ?? []) { + hash.update(line).update("\n"); + } for (const message of parsed.context.messages) { - if (message.role !== "user" && message.role !== "developer") continue; - const text = contentToText(message.content).trim(); - if (text) return text; + if (message.role !== "developer") continue; + hash.update(contentToText(message.content)).update("\n"); } - return undefined; + return hash.digest("hex"); +} + +export function cursorCoveredPrefixDigest(parsed: OcxParsedRequest, coveredMessageCount: number): string { + const hash = createHash("sha256").update("ocx:cursor:prefix:"); + hash.update(cursorInstructionDigest(parsed)).update("\0"); + for (const message of parsed.context.messages.slice(0, coveredMessageCount)) { + hash.update(message.role).update("\0"); + hash.update(contentToText(message.content)).update("\n"); + } + return hash.digest("hex"); } export interface CreateCursorRequestOptions { @@ -327,6 +329,42 @@ export interface CreateCursorRequestOptions { forceFreshConversation?: boolean; } +function lookupPrefixSnapshot( + parsed: OcxParsedRequest, + request: CursorRunRequest, + identityScope: string, +): CursorCheckpointSnapshot | undefined { + const systemDigest = cursorInstructionDigest(parsed); + const modelId = cursorCheckpointModelAffinityId(request.modelId); + for (let covered = parsed.context.messages.length; covered >= 1; covered--) { + const snapshot = getCursorCheckpointForPrefix({ + prefixDigest: cursorCoveredPrefixDigest(parsed, covered), + systemDigest, + coveredMessageCount: covered, + identityScope, + modelId, + }); + if (snapshot) return snapshot; + } + return undefined; +} + +function lineageMismatch( + parsed: OcxParsedRequest, + snapshot: CursorCheckpointSnapshot, +): CursorCheckpointInvalidationReason | undefined { + const covered = snapshot.coveredMessageCount; + if (covered === undefined || covered < 0 || covered > parsed.context.messages.length) { + return "lineage_mismatch"; + } + if (!snapshot.prefixDigest || !snapshot.systemDigest) return "lineage_mismatch"; + if (snapshot.systemDigest !== cursorInstructionDigest(parsed)) return "lineage_mismatch"; + if (snapshot.prefixDigest !== cursorCoveredPrefixDigest(parsed, covered)) return "lineage_mismatch"; + const lastRole = parsed.context.messages.at(-1)?.role; + if (lastRole === "toolResult" && covered >= parsed.context.messages.length) return "trailing_tool_result"; + return undefined; +} + function resolveCursorCheckpoint( parsed: OcxParsedRequest, request: CursorRunRequest, @@ -338,28 +376,26 @@ function resolveCursorCheckpoint( const cursorState = parsed._providerContinuation?.cursor; const ref = isolated ? undefined : cursorState?.checkpointRef; const identityScope = parsed._cursorIdentityScope?.trim() || "local"; - const modelAffinity = cursorCheckpointModelAffinityId(request.modelId); - let snapshot = getCursorCheckpoint(ref); - if (!snapshot) { - snapshot = getLatestCursorCheckpoint(candidate => - candidate.conversationId === request.conversationId - && candidate.identityScope === identityScope - && cursorCheckpointModelAffinityId(candidate.modelId) === modelAffinity - ); + let snapshot: CursorCheckpointSnapshot | undefined; + if (ref) { + snapshot = getCursorCheckpoint(ref); + if (!snapshot) return { reason: "expired" }; + } else { + snapshot = lookupPrefixSnapshot(parsed, request, identityScope); + if (!snapshot) return { reason: "missing_ref" }; + } + if (!isolated && snapshot.conversationId !== request.conversationId && ref) { + return { reason: "conversation_changed" }; } - if (!snapshot) return { reason: ref ? "expired" : "missing_ref" }; - if (snapshot.conversationId !== request.conversationId) return { reason: "conversation_changed" }; if (snapshot.identityScope !== identityScope) return { reason: "identity_changed" }; - if (cursorCheckpointModelAffinityId(snapshot.modelId) !== modelAffinity) return { reason: "model_changed" }; - const lastRole = parsed.context.messages.at(-1)?.role; - if (lastRole === "toolResult") { - if (snapshot.coveredMessageCount === undefined) return { reason: "trailing_tool_result" }; - if (snapshot.coveredMessageCount < 0 || snapshot.coveredMessageCount >= parsed.context.messages.length) { - return { reason: "trailing_tool_result" }; - } - } else if (cursorState?.checkpointUsable === false) { + if (cursorCheckpointModelAffinityId(snapshot.modelId) !== cursorCheckpointModelAffinityId(request.modelId)) { + return { reason: "model_changed" }; + } + if (parsed.context.messages.at(-1)?.role !== "toolResult" && cursorState?.checkpointUsable === false) { return { reason: "trailing_tool_result" }; } + const lineage = lineageMismatch(parsed, snapshot); + if (lineage) return { reason: lineage }; return { snapshot }; } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index a38e12b89a..625d543455 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -519,10 +519,9 @@ pre-compaction checkpoint is not persisted for later carry-forward. After a successful no-tool turn, the Cursor adapter keeps the returned ConversationStateStructure in a process-local store and reuses that snapshot on the next validated linear continuation instead of rebuilding rootPromptMessagesJson and conversationTurns. Tool-result turns reuse the last completed -checkpoint plus only the uncovered suffix. Chat Completions hops that omit previous_response_id and -thread headers pin to the first user/developer text and reuse the live snapshot for that -conversation. Isolated helper/shadow turns keep their own checkpoint and never join the parent -conversation. Compaction, account or model mismatch, missing refs, decode failures, and +checkpoint plus only the uncovered suffix. Chat Completions hops that omit previous_response_id and thread headers reuse a snapshot only when +the covered message prefix and system/developer digest match exactly one stored snapshot. Isolated +helper/shadow turns never join the parent conversation. An explicit missing checkpointRef full-replays. Compaction, account or model mismatch, missing refs, decode failures, and invalid_argument recovery keep the existing full-replay path. previous_response_id may select a branch's opaque checkpointRef; it is never a Cursor conversation ownership key. Cursor Connect still does not expose authoritative cache_read_tokens. diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index d65b6dfa11..dc220e31b3 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -20,6 +20,9 @@ import { import { clearCursorCheckpointsForTests, commitCursorCheckpoint, + CURSOR_CHECKPOINT_TTL_MS, + cursorCheckpointStoreMetricsForTests, + installCursorCheckpointClockForTests, invalidateCursorCheckpoint, } from "../src/adapters/cursor/checkpoint-store"; import { @@ -1591,3 +1594,38 @@ describe("Cursor checkpoint request construction", () => { expect(serialized).toContain("FILE CONTENTS HERE"); }); }); + +describe("Cursor checkpoint idle TTL", () => { + afterEach(() => { + clearCursorCheckpointsForTests(); + }); + + test("releases expired checkpoint leases without another request", () => { + let now = 1_000; + let scheduled: (() => void) | undefined; + installCursorCheckpointClockForTests({ + now: () => now, + schedule: fn => { + scheduled = fn; + return 1 as unknown as ReturnType; + }, + clear: () => { + scheduled = undefined; + }, + }); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["ttl"], + })); + expect(commitCursorCheckpoint({ + conversationId: "cursor_ttl", + identityScope: "acct-1", + modelId: "grok-4.6", + checkpointBytes, + })).toBeDefined(); + expect(cursorCheckpointStoreMetricsForTests().count).toBe(1); + expect(scheduled).toBeTypeOf("function"); + now += CURSOR_CHECKPOINT_TTL_MS + 1; + scheduled?.(); + expect(cursorCheckpointStoreMetricsForTests().count).toBe(0); + }); +}); diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index c33aad62e1..43a6afb6b1 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -9,9 +9,12 @@ import { import { applyCursorToolBudget, createCursorRequest, + cursorCoveredPrefixDigest, + cursorInstructionDigest, CURSOR_TOOL_BYTES_LIMIT, CURSOR_TOOL_COUNT_LIMIT, } from "../src/adapters/cursor/request-builder"; +import { cursorCheckpointModelAffinityId } from "../src/adapters/cursor/discovery"; import { cursorMcpToolsEncodedSize } from "../src/adapters/cursor/tool-definitions"; import { parseRequest } from "../src/responses/parser"; import type { OcxParsedRequest } from "../src/types"; @@ -73,85 +76,18 @@ describe("Cursor request builder", () => { expect(continuation.conversationId).toBe(initial.conversationId); }); - test("pins chat-style store:false turns to the first user message when no thread header exists", () => { + test("does not own a Cursor conversation from the first user message alone", () => { const first = createCursorRequest({ ...base, modelId: "cursor/gpt-5.6-sol", - context: { messages: [{ role: "user", content: "fix the cache in sol", timestamp: 1 }] }, + context: { messages: [{ role: "user", content: "fix the tests", timestamp: 1 }] }, }); - const toolHop = createCursorRequest({ - ...base, - modelId: "cursor/gpt-5.6-sol", - context: { - messages: [ - { role: "user", content: "fix the cache in sol", timestamp: 1 }, - { role: "assistant", content: [{ type: "text", text: "calling" }], timestamp: 2 }, - { - role: "toolResult", - toolCallId: "call-1", - toolName: "read_file", - content: "ok", - isError: false, - timestamp: 3, - }, - ], - }, - }); - const other = createCursorRequest({ - ...base, - modelId: "cursor/gpt-5.6-sol", - context: { messages: [{ role: "user", content: "a different task", timestamp: 1 }] }, - }); - - expect(toolHop.conversationId).toBe(first.conversationId); - expect(other.conversationId).not.toBe(first.conversationId); - }); - - test("reuses a live checkpoint by conversation when chat omits the continuation ref", () => { - clearCursorCheckpointsForTests(); - const first = createCursorRequest({ - ...base, - modelId: "cursor/gpt-5.6-sol", - _cursorIdentityScope: "acct-1", - context: { messages: [{ role: "user", content: "fix the cache in sol", timestamp: 1 }] }, - }); - const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { - pendingToolCalls: ["chat-store-false"], - })); - const checkpointRef = commitCursorCheckpoint({ - conversationId: first.conversationId, - identityScope: "acct-1", - modelId: first.modelId, - checkpointBytes, - coveredMessageCount: 1, - }); - expect(checkpointRef).toBeDefined(); - - const toolHop = createCursorRequest({ + const second = createCursorRequest({ ...base, modelId: "cursor/gpt-5.6-sol", - _cursorIdentityScope: "acct-1", - context: { - messages: [ - { role: "user", content: "fix the cache in sol", timestamp: 1 }, - { role: "assistant", content: [{ type: "text", text: "calling" }], timestamp: 2 }, - { - role: "toolResult", - toolCallId: "call-1", - toolName: "read_file", - content: "ok", - isError: false, - timestamp: 3, - }, - ], - }, + context: { messages: [{ role: "user", content: "fix the tests", timestamp: 1 }] }, }); - - expect(toolHop.conversationId).toBe(first.conversationId); - expect(toolHop.continuationMode).toBe("checkpoint"); - expect(toolHop.checkpointBytes?.byteLength).toBe(checkpointBytes.byteLength); - expect(toolHop.checkpointSuffixStart).toBe(1); - clearCursorCheckpointsForTests(); + expect(second.conversationId).not.toBe(first.conversationId); }); test("isolates client threads even when they share a prompt cache key", () => { @@ -259,12 +195,15 @@ describe("Cursor request builder", () => { const helperBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { pendingToolCalls: ["helper-fixture"], })); + const helperParsed = { ...base, _cursorIdentityScope: "acct-1", context: { messages: [helperPrompt] } }; const helperRef = commitCursorCheckpoint({ conversationId: first.conversationId, identityScope: "acct-1", modelId: first.modelId, checkpointBytes: helperBytes, coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(helperParsed, 1), + systemDigest: cursorInstructionDigest(helperParsed), }); expect(helperRef).toBeDefined(); @@ -292,7 +231,7 @@ describe("Cursor request builder", () => { ], }, }); - expect(second.conversationId).toBe(first.conversationId); + expect(second.conversationId).not.toBe("cursor_parent_real"); expect(second.continuationMode).toBe("checkpoint"); expect(second.checkpointBytes?.byteLength).toBe(helperBytes.byteLength); expect(getCursorCheckpoint(parentRef)?.ref).toBe(parentRef); @@ -862,6 +801,11 @@ describe("Cursor request builder", () => { test("reuses a validated checkpoint and ignores it for isolation or uncovered tool results", () => { clearCursorCheckpointsForTests(); + const covered = [ + { role: "user" as const, content: "read", timestamp: 1 }, + { role: "assistant" as const, content: [{ type: "text" as const, text: "calling" }], timestamp: 2 }, + ]; + const coveredParsed = { ...base, _cursorIdentityScope: "acct-1", context: { messages: covered } }; const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { pendingToolCalls: ["builder-fixture"], })); @@ -871,6 +815,8 @@ describe("Cursor request builder", () => { modelId: "default", checkpointBytes, coveredMessageCount: 2, + prefixDigest: cursorCoveredPrefixDigest(coveredParsed, 2), + systemDigest: cursorInstructionDigest(coveredParsed), }); expect(checkpointRef).toBeDefined(); @@ -881,7 +827,7 @@ describe("Cursor request builder", () => { _providerContinuation: { cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef }, }, - context: { messages: [{ role: "user", content: "continue", timestamp: 1 }] }, + context: { messages: [...covered, { role: "user", content: "continue", timestamp: 3 }] }, }); expect(reused.continuationMode).toBe("checkpoint"); expect(reused.checkpointBytes?.byteLength).toBe(checkpointBytes.byteLength); @@ -943,7 +889,7 @@ describe("Cursor request builder", () => { }, }); expect(uncovered.continuationMode).toBe("full-replay"); - expect(uncovered.checkpointInvalidationReason).toBe("trailing_tool_result"); + expect(uncovered.checkpointInvalidationReason).toBe("lineage_mismatch"); clearCursorCheckpointsForTests(); }); @@ -966,8 +912,8 @@ describe("Cursor request builder", () => { _cursorConversationId: "cursor_stable", _cursorIdentityScope: "acct-1", }); - expect(implied.continuationMode).toBe("checkpoint"); - expect(implied.checkpointBytes?.byteLength).toBe(checkpointBytes.byteLength); + expect(implied.continuationMode).toBe("full-replay"); + expect(implied.checkpointInvalidationReason).toBe("missing_ref"); const missing = createCursorRequest({ ...base, @@ -1048,4 +994,171 @@ describe("Cursor request builder", () => { expect(getCursorCheckpoint(checkpointRef)?.ref).toBe(checkpointRef); clearCursorCheckpointsForTests(); }); + + test("does not substitute another snapshot when an explicit checkpoint ref is missing", () => { + clearCursorCheckpointsForTests(); + const parsed = { + ...base, + modelId: "cursor/grok-4.6", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "same prompt", timestamp: 1 }] }, + }; + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["other-snap"], + })); + const liveRef = commitCursorCheckpoint({ + conversationId: "cursor_stable", + identityScope: "acct-1", + modelId: "grok-4.6", + checkpointBytes, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(parsed, 1), + systemDigest: cursorInstructionDigest(parsed), + }); + expect(liveRef).toBeDefined(); + const missed = createCursorRequest({ + ...parsed, + _cursorConversationId: "cursor_stable", + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef: "missing-ref" }, + }, + }); + expect(missed.continuationMode).toBe("full-replay"); + expect(missed.checkpointInvalidationReason).toBe("expired"); + clearCursorCheckpointsForTests(); + }); + + test("does not share a checkpoint across two chats that start with the same user text", () => { + clearCursorCheckpointsForTests(); + const firstTurn = { + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "fix the tests", timestamp: 1 }] }, + }; + const bytesA = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["chat-a"], + })); + const bytesB = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["chat-b"], + })); + expect(commitCursorCheckpoint({ + conversationId: "cursor_a", + identityScope: "acct-1", + modelId: createCursorRequest(firstTurn).modelId, + checkpointBytes: bytesA, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(firstTurn, 1), + systemDigest: cursorInstructionDigest(firstTurn), + })).toBeDefined(); + expect(commitCursorCheckpoint({ + conversationId: "cursor_b", + identityScope: "acct-1", + modelId: createCursorRequest(firstTurn).modelId, + checkpointBytes: bytesB, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(firstTurn, 1), + systemDigest: cursorInstructionDigest(firstTurn), + })).toBeDefined(); + const followUp = createCursorRequest({ + ...firstTurn, + context: { + messages: [ + { role: "user", content: "fix the tests", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "A reply" }], timestamp: 2 }, + { role: "user", content: "now this file", timestamp: 3 }, + ], + }, + }); + expect(followUp.continuationMode).toBe("full-replay"); + expect(followUp.checkpointInvalidationReason).toBe("missing_ref"); + clearCursorCheckpointsForTests(); + }); + + test("rejects a divergent branch and a changed system prompt", () => { + clearCursorCheckpointsForTests(); + const covered = [ + { role: "user" as const, content: "start here", timestamp: 1 }, + { role: "assistant" as const, content: [{ type: "text" as const, text: "ok" }], timestamp: 2 }, + ]; + const parsed = { ...base, _cursorIdentityScope: "acct-1", context: { messages: covered } }; + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["branch"], + })); + const ref = commitCursorCheckpoint({ + conversationId: "cursor_stable", + identityScope: "acct-1", + modelId: "default", + checkpointBytes, + coveredMessageCount: 2, + prefixDigest: cursorCoveredPrefixDigest(parsed, 2), + systemDigest: cursorInstructionDigest(parsed), + }); + expect(ref).toBeDefined(); + const branched = createCursorRequest({ + ...base, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef: ref }, + }, + context: { + messages: [ + { role: "user", content: "start here", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "different reply" }], timestamp: 2 }, + { role: "user", content: "continue", timestamp: 3 }, + ], + }, + }); + expect(branched.continuationMode).toBe("full-replay"); + expect(branched.checkpointInvalidationReason).toBe("lineage_mismatch"); + const systemChanged = createCursorRequest({ + ...parsed, + _cursorConversationId: "cursor_stable", + _cursorIdentityScope: "acct-1", + context: { systemPrompt: ["new system"], messages: covered }, + _providerContinuation: { + cursor: { conversationId: "cursor_stable", checkpointUsable: true, checkpointRef: ref }, + }, + }); + expect(systemChanged.continuationMode).toBe("full-replay"); + expect(systemChanged.checkpointInvalidationReason).toBe("lineage_mismatch"); + clearCursorCheckpointsForTests(); + }); + + test("reuses a unique covered prefix when chat omits the continuation ref", () => { + clearCursorCheckpointsForTests(); + const firstTurn = { + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "unique sol prompt 7f3c", timestamp: 1 }] }, + }; + const built = createCursorRequest(firstTurn); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["unique-sol"], + })); + expect(commitCursorCheckpoint({ + conversationId: built.conversationId, + identityScope: "acct-1", + modelId: cursorCheckpointModelAffinityId(built.modelId), + checkpointBytes, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(firstTurn, 1), + systemDigest: cursorInstructionDigest(firstTurn), + })).toBeDefined(); + const followUp = createCursorRequest({ + ...firstTurn, + context: { + messages: [ + { role: "user", content: "unique sol prompt 7f3c", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "ack" }], timestamp: 2 }, + { role: "user", content: "go on", timestamp: 3 }, + ], + }, + }); + expect(followUp.continuationMode).toBe("checkpoint"); + expect(followUp.checkpointBytes?.byteLength).toBe(checkpointBytes.byteLength); + clearCursorCheckpointsForTests(); + }); }); From 90f585e0839251fd33c6138d3507263f7c1f52e0 Mon Sep 17 00:00:00 2001 From: keepitmello <71975659+keepitmello@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:02:07 +0900 Subject: [PATCH 5/5] fix(cursor): frame checkpoint digests and drop stale recovery state Length-prefix instruction and prefix hashes so delimiter splits cannot collide. Clear the failed transport before forced-fresh retry so the recovered turn cannot commit the previous attempt. The idle TTL test now pins a real blob and asserts the lease is gone after prune. Refs #1527 --- .../src/content/docs/ko/reference/adapters.md | 6 ++--- .../src/content/docs/reference/adapters.md | 6 ++--- src/adapters/cursor.ts | 1 + src/adapters/cursor/request-builder.ts | 20 +++++++++++------ tests/cursor-blob.test.ts | 10 ++++++++- tests/cursor-request-builder.test.ts | 22 +++++++++++++++++++ 6 files changed, 51 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index a6ed3c8d5c..afd3121473 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -146,9 +146,9 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. store에 보관하고, 검증된 선형 이어말하기에서는 전체 root history를 다시 만들지 않고 그 checkpoint를 재사용합니다. tool-result 턴은 마지막 정상 완료 턴의 checkpoint에 커버되지 않은 suffix만 붙입니다. compaction, helper/shadow 격리, 계정/모델 불일치, 없는 ref, decode 실패, - forced-fresh 복구, invalid_argument 재시도는 기존 full replay로 돌아갑니다. Cursor Connect는 - 권위 있는 cache_read_tokens를 주지 않으므로 OpenCodex usage만 보고 cache hit라고 단정하지 - 않습니다. + forced-fresh 복구, invalid_argument 재시도는 기존 full replay로 돌아갑니다. 프로세스 재시작은 + 메모리 store를 버리고 full replay합니다. Cursor Connect는 권위 있는 cache_read_tokens를 주지 + 않으므로 OpenCodex usage만 보고 cache hit라고 단정하지 않습니다. - `cursor/grok-4.5-fast`는 선택 가능한 모델로 유지하되, Cursor에는 정식 `grok-4.5` 모델을 보내고 별도의 `effort`, `fast=true` 값은 `requested_model.parameters`에 담습니다. - Cursor 네이티브 로컬 파일시스템/shell/network 실행은 기본적으로 거부합니다. 명시적인 diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index ec7635d3e9..43ebb17815 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -200,9 +200,9 @@ advertised effort control on those models as proof of upstream-native reasoning instead of rebuilding the full root history. Tool-result turns reuse the last completed-turn checkpoint plus only the uncovered suffix when the covered message boundary is known. Compaction, helper/shadow isolation, account/model mismatch, missing refs, decode failures, - forced-fresh recovery, and invalid_argument retries fall back to the existing full replay. Cursor - Connect still does not expose authoritative cache_read_tokens, so OpenCodex usage is not a - cache-hit counter. + forced-fresh recovery, and invalid_argument retries fall back to the existing full replay. A + process restart drops the in-memory store and full-replays. Cursor Connect still does not expose + authoritative cache_read_tokens, so OpenCodex usage is not a cache-hit counter. - Exposes Cursor Router as `cursor/auto` plus explicit `cursor/auto-cost`, `cursor/auto-balance`, and `cursor/auto-intelligence` entries. Explicit levels are encoded in `requested_model.parameters` while the legacy `cursor/auto` entry retains the account/team default. diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index ed71ce7fd8..2c1faad5ea 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -241,6 +241,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda throw err; } const failedConversationId = request.conversationId; + lastTransport = undefined; _parsed._cursorConversationId = undefined; request = createCursorRequest(_parsed, { forceFreshConversation: true }); rekeyContextUsage(failedConversationId, request.conversationId); diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index ff11845705..131fbf5152 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -302,24 +302,30 @@ export function resolveCursorConversationId( return generatedCursorConversationId(); } +function updateFramed(hash: ReturnType, value: string): void { + const bytes = Buffer.from(value, "utf8"); + const length = Buffer.allocUnsafe(4); + length.writeUInt32BE(bytes.byteLength); + hash.update(length); + hash.update(bytes); +} + export function cursorInstructionDigest(parsed: OcxParsedRequest): string { const hash = createHash("sha256").update("ocx:cursor:sys:"); - for (const line of parsed.context.systemPrompt ?? []) { - hash.update(line).update("\n"); - } + for (const line of parsed.context.systemPrompt ?? []) updateFramed(hash, line); for (const message of parsed.context.messages) { if (message.role !== "developer") continue; - hash.update(contentToText(message.content)).update("\n"); + updateFramed(hash, contentToText(message.content)); } return hash.digest("hex"); } export function cursorCoveredPrefixDigest(parsed: OcxParsedRequest, coveredMessageCount: number): string { const hash = createHash("sha256").update("ocx:cursor:prefix:"); - hash.update(cursorInstructionDigest(parsed)).update("\0"); + updateFramed(hash, cursorInstructionDigest(parsed)); for (const message of parsed.context.messages.slice(0, coveredMessageCount)) { - hash.update(message.role).update("\0"); - hash.update(contentToText(message.content)).update("\n"); + updateFramed(hash, message.role); + updateFramed(hash, contentToText(message.content)); } return hash.digest("hex"); } diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index dc220e31b3..516b8002be 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -1598,6 +1598,7 @@ describe("Cursor checkpoint request construction", () => { describe("Cursor checkpoint idle TTL", () => { afterEach(() => { clearCursorCheckpointsForTests(); + resetCursorBlobStateForTests(); }); test("releases expired checkpoint leases without another request", () => { @@ -1613,8 +1614,12 @@ describe("Cursor checkpoint idle TTL", () => { scheduled = undefined; }, }); + const requestScope = createCursorBlobRequestScope(); + const data = new TextEncoder().encode('{"role":"system","content":"ttl-lease"}'); + const blobId = storeCursorBlob(data, requestScope); + sealCursorBlobRequestScope(requestScope); const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { - pendingToolCalls: ["ttl"], + rootPromptMessagesJson: [blobId], })); expect(commitCursorCheckpoint({ conversationId: "cursor_ttl", @@ -1622,10 +1627,13 @@ describe("Cursor checkpoint idle TTL", () => { modelId: "grok-4.6", checkpointBytes, })).toBeDefined(); + releaseCursorBlobRequestScope(requestScope); expect(cursorCheckpointStoreMetricsForTests().count).toBe(1); + expect(cursorBlobRetainedStoreSnapshot().pinnedBytes).toBeGreaterThan(0); expect(scheduled).toBeTypeOf("function"); now += CURSOR_CHECKPOINT_TTL_MS + 1; scheduled?.(); expect(cursorCheckpointStoreMetricsForTests().count).toBe(0); + expect(cursorBlobRetainedStoreSnapshot().pinnedBytes).toBe(0); }); }); diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index 43a6afb6b1..b2b45388f6 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -76,6 +76,28 @@ describe("Cursor request builder", () => { expect(continuation.conversationId).toBe(initial.conversationId); }); + test("prefix digests do not collide across delimiter boundaries", () => { + const left = { + ...base, + context: { + messages: [ + { role: "user" as const, content: "ab", timestamp: 1 }, + { role: "user" as const, content: "c", timestamp: 2 }, + ], + }, + }; + const right = { + ...base, + context: { + messages: [ + { role: "user" as const, content: "a", timestamp: 1 }, + { role: "user" as const, content: "bc", timestamp: 2 }, + ], + }, + }; + expect(cursorCoveredPrefixDigest(left, 2)).not.toBe(cursorCoveredPrefixDigest(right, 2)); + }); + test("does not own a Cursor conversation from the first user message alone", () => { const first = createCursorRequest({ ...base,