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 e742eafa72..3a3473c551 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 279e0d7305..4dcd420055 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 015eac1cfc..c493c27296 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( @@ -130,6 +177,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 => { @@ -138,7 +189,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); + } } }, ); @@ -177,6 +249,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 afc411e243..eeac55a5d6 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, @@ -429,9 +430,10 @@ class LiveCursorTransport implements CursorTransport { private turnStartedAt = 0; private framesReceived = 0; private firstFrameAt?: number; - private firstFrameLogged = false; + private firstFrameLogged = false; /** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */ private readonly sessionId = crypto.randomUUID(); + private capturedCheckpointBytes?: Uint8Array; constructor(private readonly input: CursorTransportFactoryInput) { this.translatorBudget = input.translatorBudget; @@ -1046,6 +1048,10 @@ class LiveCursorTransport implements CursorTransport { }, HEARTBEAT_MS); } + capturedConversationCheckpoint(): Uint8Array | undefined { + return this.capturedCheckpointBytes; + } + private async handleServerMessage( message: AgentServerMessage, state: ReturnType, @@ -1053,6 +1059,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; @@ -1258,3 +1271,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 52856b79e1..4065c487e4 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; @@ -565,7 +601,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 4ede0a482f..49dfbe28ab 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -601,9 +601,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); @@ -615,9 +675,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, }); @@ -628,19 +692,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 @@ -687,7 +739,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" ? [text] : []), ...mcpToolDefs.map(modelVisibleToolText), ]; diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 8338080178..a9da7353ef 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; @@ -295,6 +299,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 = {}, @@ -307,7 +342,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 } : {}), @@ -321,4 +356,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 4f7a796e02..d870d120f0 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 24c8faa6aa..e494a9d053 100644 --- a/src/types.ts +++ b/src/types.ts @@ -326,6 +326,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 f3287a65f7..7ecea0e07c 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -508,6 +508,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 @@ -851,7 +871,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 1627d8e607..1760be29ac 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 { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -84,11 +91,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 () => { @@ -502,4 +507,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 e8df19da3e..7304559aeb 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, @@ -33,6 +39,7 @@ import { AgentClientMessageSchema, ConversationStepSchema, ConversationTurnStructureSchema, + ConversationStateStructureSchema, GetBlobArgsSchema, KvServerMessageSchema, SetBlobArgsSchema, @@ -1358,3 +1365,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 be977c2b90..cfcfac4d69 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, @@ -693,4 +700,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([