Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/ko/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,13 @@ 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 실패,
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 실행은 기본적으로 거부합니다. 명시적인
Expand Down
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,14 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and
- 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, decode failures,
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.
- Honors `upstreamHttpVersion` for both live model discovery and inference. `auto`, `http2`, and `h2`
preserve the existing HTTP/2 transport; only `http1.1` and `h1` select compatibility mode.
- Exposes Cursor Router as `cursor/auto` plus explicit `cursor/auto-cost`,
Expand Down
112 changes: 109 additions & 3 deletions src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,27 @@ 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";
import {
createCursorRequest,
cursorCoveredPrefixDigest,
cursorInstructionDigest,
} from "./cursor/request-builder";
import {
createLiveCursorTransport,
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 @@ -96,6 +107,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
Expand All @@ -112,6 +124,48 @@ 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
|| activeRequest.contextUsageStoreCheckpoints === false
|| !lastTransport?.captured
|| lastTransport.captured.byteLength === 0
) return;
const previousRef = _parsed._providerContinuation?.cursor?.checkpointRef;
const coveredMessageCount = _parsed.context.messages.length;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Advance checkpoint coverage past the generated reply

The captured post-turn ConversationStateStructure must already contain the assistant reply—otherwise the next ordinary checkpoint continuation, which sends only the new user action, would lose that reply—but this boundary counts only the request's input messages. If that next turn emits a tool call, the following tool-result request slices its suffix from this stale count and appends the already-checkpointed assistant reply again before the new user/tool exchange, duplicating model-visible history and potentially changing or invalidating the continuation. Record the actual expanded-message boundary including the generated assistant output before using it as checkpointSuffixStart.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

const checkpointRef = commitCursorCheckpoint({
conversationId: activeRequest.conversationId,
identityScope: _parsed._cursorIdentityScope,
modelId: cursorCheckpointModelAffinityId(activeRequest.modelId),
checkpointBytes: lastTransport.captured,
coveredMessageCount,
prefixDigest: cursorCoveredPrefixDigest(_parsed, coveredMessageCount),
systemDigest: cursorInstructionDigest(_parsed),
});
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 @@ -132,6 +186,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 @@ -140,7 +198,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 All @@ -163,6 +242,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);
Expand All @@ -179,6 +259,32 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
}
await runOnce(request);
}
if (
request.checkpointInvalidationReason
&& request.checkpointInvalidationReason !== "missing_ref"
) {
invalidateCursorCheckpoint(inheritedCheckpointRef);
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
Loading
Loading