Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions devlog/_plan/260814_bug_resolution_campaign/030_wave3_cursor.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/ko/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 실행은 기본적으로 거부합니다. 명시적인
Expand Down
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
104 changes: 102 additions & 2 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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<typeof createCursorRequest>): 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<typeof createCursorRequest>) => {
await runCursorTurnWithRetry(
Expand All @@ -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 => {
Expand All @@ -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);
}
}
},
);
Expand Down Expand Up @@ -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;
Expand Down
181 changes: 181 additions & 0 deletions src/adapters/cursor/checkpoint-store.ts
Original file line number Diff line number Diff line change
@@ -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<string, CursorCheckpointSnapshot>;
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 };
}
7 changes: 7 additions & 0 deletions src/adapters/cursor/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading
Loading