diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index 54bd07eef8..17d0630e4a 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -122,12 +122,16 @@ filtered incomplete になります。実際のツール呼び出しを伴わな ## `cursor` -**対象:** `api2.cursor.sh` の HTTP/2 Connect ストリーミング -`agent.v1.AgentService/Run`。 +**対象:** デフォルトでは `api2.cursor.sh` の HTTP/2 Connect ストリーミング +`agent.v1.AgentService/Run`。`upstreamHttpVersion: "http1.1"`(または `"h1"`)では Cursor の +HTTP/1.1 互換トランスポートを使い、サーバー出力を `agent.v1.AgentService/RunSSE`、クライアント +メッセージを `aiserver.v1.BidiService/BidiAppend` で送受信します。この設定は inference と live +model discovery の両方に適用されます。 **認証:** `provider.apiKey` または転送された authorization ヘッダーの Cursor OAuth/access token。 - 通常の fetch/parse 経路の代わりに `runTurn` を使います。リクエスト、サーバーイベント、ツール引数、使用量 checkpoint、クライアントレスポンスは `cursor/gen/agent_pb.ts` の `@bufbuild/protobuf` スキーマでエンコードしたのち Connect メッセージとして framing します。 - content-addressed blob で対話状態を再生し、サーバーツール呼び出しを Codex に再マッピングします。protobuf の `GetUsableModels` RPC でリアルタイム Cursor モデルを探し、run リクエストが wire に commit される前だけリトライします。 +- ツールなしで正常終了したターンでは、返された ConversationStateStructure をプロセスローカルに保持し、検証済みの線形継続で checkpoint を再利用します。tool-result ターンでは、対象メッセージ境界が判明している場合、最後に正常終了したターンの checkpoint に未収録の suffix だけを追加します。ref のない prefix lookup は、記憶済みの Cursor conversation または安定した client thread(制限付きの Desktop session/thread fallback を含む)があり、同じ provider conversation が所有する checkpoint が一意に一致する場合だけ許可します。それ以外は full replay に戻ります。compaction、helper/shadow の分離、account/model の不一致、ref の欠落、decode の失敗、forced-fresh recovery、invalid_argument retry でも full replay を使います。プロセスを再起動するとメモリ内 store は失われ、full replay になります。Cursor Connect は権威ある cache_read_tokens を公開しないため、OpenCodex usage は cache-hit counter ではありません。制限付き Desktop fallback が保存するのはプロセスローカルで HMAC から導出した owner だけで、raw session/thread header や OAuth/authorization material を checkpoint state に書き込みません。OAuth-backed live transport とアカウントで絞り込む live model discovery は実験的です。ログインと transport の設定は [provider guide](/ja/guides/providers/) と [Cursor provider configuration](/ja/reference/configuration/providers/#cursor-provider-adapter-cursor) を参照してください。checkpoint reuse 自体は自動で、ユーザー設定はありません。 - `cursor/grok-4.5-fast` は選択可能なモデルとして維持しつつ、Cursor には正規の `grok-4.5` モデルを送信し、個別の `effort` および `fast=true` 値は `requested_model.parameters` に格納します。 - Cursor ネイティブのローカルファイルシステム/shell/network 実行はデフォルトで拒否します。明示的な `mcpServers` と `desktopExecutor` 統合はそれぞれ別の opt-in です。`unsafeAllowNativeLocalExec` はより広い組み込み executor を有効にし、Codex の承認/サンドボックスルールを迂回します。 diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index abf353b871..2a4a3afccb 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -139,8 +139,11 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. ## `cursor` -**대상:** `api2.cursor.sh`의 HTTP/2 Connect 스트리밍 -`agent.v1.AgentService/Run`. +**대상:** 기본값은 `api2.cursor.sh`의 HTTP/2 Connect 스트리밍 +`agent.v1.AgentService/Run`입니다. `upstreamHttpVersion: "http1.1"` 또는 `"h1"`을 설정하면 +Cursor의 HTTP/1.1 호환 조합을 사용합니다. 서버 출력은 `agent.v1.AgentService/RunSSE`, 클라이언트 +메시지는 `aiserver.v1.BidiService/BidiAppend`로 전송합니다. 이 설정은 추론과 live model +discovery에 모두 적용됩니다. **인증:** `provider.apiKey` 또는 전달된 authorization 헤더의 Cursor OAuth/access token. - 일반 fetch/parse 경로 대신 `runTurn`을 사용합니다. 요청, 서버 이벤트, 툴 인자, 사용량 checkpoint, @@ -151,11 +154,20 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다. 재시도합니다. - 도구 없이 정상 완료된 턴 뒤에는 Cursor가 돌려준 ConversationStateStructure를 프로세스 로컬 store에 보관하고, 검증된 선형 이어말하기에서는 전체 root history를 다시 만들지 않고 그 - checkpoint를 재사용합니다. tool-result 턴은 마지막 정상 완료 턴의 checkpoint에 커버되지 않은 - suffix만 붙입니다. compaction, helper/shadow 격리, 계정/모델 불일치, 없는 ref, decode 실패, + checkpoint를 재사용합니다. tool-result 턴은 커버된 메시지 경계를 알 수 있을 때만 마지막 정상 + 완료 턴의 checkpoint에 커버되지 않은 suffix를 붙입니다. ref 없는 prefix 조회는 기억된 Cursor + 대화 또는 안정적인 클라이언트 스레드 + (범위가 제한된 Desktop session/thread 대체 식별자 포함)와 같은 provider 대화가 소유한 + checkpoint가 있을 때만 허용하며, 그 외에는 full replay합니다. + compaction, helper/shadow 격리, 계정/모델 불일치, 없는 ref, decode 실패, forced-fresh 복구, invalid_argument 재시도는 기존 full replay로 돌아갑니다. 프로세스 재시작은 메모리 store를 버리고 full replay합니다. Cursor Connect는 권위 있는 cache_read_tokens를 주지 않으므로 OpenCodex usage만 보고 cache hit라고 단정하지 않습니다. + 범위가 제한된 Desktop 대체 식별자는 프로세스 로컬 HMAC 파생 소유자만 보관하며, 원본 + session/thread 헤더나 OAuth/authorization 자료를 checkpoint 상태에 쓰지 않습니다. OAuth 기반 + live transport와 계정별 live model discovery는 아직 실험 기능입니다. 로그인과 transport 설정은 + [공급자 가이드](/ko/guides/providers/)와 [Cursor 공급자 설정](/ko/reference/configuration/providers/#cursor-provider-adapter-cursor)을 + 참고하세요. checkpoint 재사용 자체는 자동이며 사용자 설정이 없습니다. - `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 1b96ebac7a..b6e7c6006d 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -226,10 +226,18 @@ compatibility pair: `agent.v1.AgentService/RunSSE` for server output and 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. + Ref-less prefix lookup requires a remembered Cursor conversation or stable client thread + (including the bounded Desktop session/thread fallback) and a checkpoint owned by that same + provider conversation; otherwise it full-replays. 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. + The bounded Desktop fallback stores only a process-local HMAC-derived owner; raw session/thread + headers and OAuth/authorization material are never written to checkpoint state. Cursor's + OAuth-backed live transport and account-filtered model discovery remain experimental; see the + [provider guide](/guides/providers/) and [Cursor provider configuration](/reference/configuration/providers/#cursor-provider-adapter-cursor) + for login and transport settings. Checkpoint reuse itself is automatic and has no user setting. - 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`, diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index eca16ca669..3f6e45c00b 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -144,8 +144,11 @@ incomplete. `TOOL_USE` без фактического вызова инстру ## `cursor` -**Назначение:** `agent.v1.AgentService/Run` Cursor поверх потокового HTTP/2 Connect на -`api2.cursor.sh`. +**Назначение:** по умолчанию `agent.v1.AgentService/Run` Cursor поверх потокового HTTP/2 Connect +на `api2.cursor.sh`. При `upstreamHttpVersion: "http1.1"` (или `"h1"`) используется совместимый +транспорт HTTP/1.1: `agent.v1.AgentService/RunSSE` для вывода сервера и +`aiserver.v1.BidiService/BidiAppend` для сообщений клиента. Эта настройка применяется и к +inference, и к live model discovery. **Аутентификация:** Cursor OAuth/access token из `provider.apiKey` или из переданного заголовка authorization. @@ -155,6 +158,23 @@ authorization. - Воспроизводит состояние диалога через content-addressed blob'ы, отображает серверные вызовы инструментов обратно в Codex, обнаруживает актуальные модели Cursor через protobuf RPC `GetUsableModels` и повторяет попытки только до того, как run-запрос зафиксирован на wire. +- После успешно завершённого хода без инструментов хранит возвращённую ConversationStateStructure + локально в процессе и повторно использует checkpoint для проверенного линейного продолжения. Ходы + с результатом инструмента используют checkpoint последнего завершённого хода и только ещё не + охваченный suffix, когда известна граница охваченных сообщений. Поиск по префиксу без ref разрешён + только при наличии запомненного разговора Cursor или стабильного идентификатора client thread + (включая ограниченный fallback по Desktop session/thread) и единственного совпадающего checkpoint, + принадлежащего тому же разговору provider; иначе выполняется full replay. Compaction, изоляция + helper/shadow, несовпадение account/model, отсутствие ref, ошибки decode, forced-fresh recovery и + повтор после invalid_argument также используют full replay. Перезапуск процесса удаляет хранилище + из памяти и приводит к full replay. Cursor Connect не предоставляет достоверный + cache_read_tokens, поэтому usage OpenCodex не является счётчиком cache hit. Ограниченный Desktop + fallback хранит только владельца, выведенного через HMAC локально в процессе; исходные заголовки + session/thread и данные OAuth/authorization в checkpoint state не записываются. Live transport с + OAuth и фильтрация live model discovery по аккаунту остаются экспериментальными. Настройки входа + и transport описаны в [руководстве по провайдерам](/ru/guides/providers/) и + [конфигурации провайдера Cursor](/ru/reference/configuration/providers/#cursor-provider-adapter-cursor). + Повторное использование checkpoint выполняется автоматически и не имеет пользовательской настройки. - Сохраняет `cursor/grok-4.5-fast` доступной для выбора, но отправляет Cursor каноническую модель `grok-4.5`, помещая отдельные значения `effort` и `fast=true` в `requested_model.parameters`. - Нативное для Cursor локальное выполнение операций с файловой системой/shell/сетью по умолчанию diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index 5786952810..8d56bf77d8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -139,6 +139,19 @@ Cursor 的 HTTP/1.1 兼容传输:通过 `agent.v1.AgentService/RunSSE` 接收 Connect message。 - 经 content-addressed blob 重放对话状态,把 server tool call 映射回 Codex,用 protobuf `GetUsableModels` RPC 发现实时 Cursor 模型,并且只在 run request 尚未 commit 到 wire 前重试。 +- 对不含工具且正常完成的 turn,会在进程本地保存返回的 ConversationStateStructure,并在经过验证的 + 线性 continuation 中复用 checkpoint。tool-result turn 会在已知覆盖消息边界时,复用最后一个已完成 + turn 的 checkpoint,并只追加尚未覆盖的 suffix。无 ref 的 prefix lookup 仅在存在已记忆的 Cursor + conversation 或稳定 client thread(包括受限的 Desktop session/thread fallback),且唯一匹配的 + checkpoint 由同一 provider conversation 所有时才允许;否则执行 full replay。compaction、 + helper/shadow 隔离、account/model 不匹配、ref 缺失、decode 失败、forced-fresh recovery 以及 + invalid_argument 重试也会回退到 full replay。进程重启会丢弃内存 store 并执行 full replay。 + Cursor Connect 不提供权威的 cache_read_tokens,因此 OpenCodex usage 不是 cache hit 计数器。 + 受限的 Desktop fallback 只保存进程本地由 HMAC 派生的 owner;原始 session/thread header 与 + OAuth/authorization 材料不会写入 checkpoint state。基于 OAuth 的 live transport 和按账号过滤的 + live model discovery 仍是实验功能;登录与 transport 设置参见[提供商指南](/zh-cn/guides/providers/) + 和 [Cursor 提供商配置](/zh-cn/reference/configuration/providers/#cursor-provider-adapter-cursor)。 + checkpoint 复用本身是自动的,没有用户设置。 - 模型实时发现和推理都会遵守 `upstreamHttpVersion`。`auto`、`http2` 与 `h2` 保持原有 HTTP/2 transport;只有 `http1.1` 与 `h1` 会选择兼容模式。 - 保留 `cursor/grok-4.5-fast` 作为可选模型,但向 Cursor 发送规范的 `grok-4.5` 模型,并将独立的 diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 4089e81ed3..b0cfeca602 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -9,6 +9,7 @@ import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; import { createCursorRequest, + cursorClientThreadOwner, cursorCoveredPrefixDigest, cursorInstructionDigest, } from "./cursor/request-builder"; @@ -265,12 +266,13 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda request = createCursorRequest(_parsed, { forceFreshConversation: true }); rekeyContextUsage(failedConversationId, request.conversationId); _parsed._cursorConversationId = request.conversationId; - // Persist recovery for store:false clients that only send a parent thread id, so the - // next turn does not recompute the stale deterministic thread hash. Isolated helper / - // compaction turns must not park their throwaway id under the parent thread key. - if (_parsed._clientThreadId && _parsed._cursorIsolateConversation !== true) { + // Persist recovery for store:false clients that send any stable Cursor thread owner, so + // the next turn does not recompute the stale deterministic thread hash. Isolated helper / + // compaction turns must not park their throwaway id under the parent or Desktop owner. + const threadOwner = cursorClientThreadOwner(_parsed); + if (threadOwner && _parsed._cursorIsolateConversation !== true) { rememberCursorThreadConversation( - _parsed._clientThreadId, + threadOwner, request.conversationId, _parsed._cursorIdentityScope, ); diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index 337b07caa5..b9d78d9716 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -236,6 +236,7 @@ export function commitCursorCheckpoint(input: { } export function getCursorCheckpointForPrefix(input: { + conversationId: string; prefixDigest: string; systemDigest: string; coveredMessageCount: number; @@ -244,17 +245,21 @@ export function getCursorCheckpointForPrefix(input: { }): 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; + if (!refs) 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; + let foundRef: string | undefined; + for (const ref of refs) { + const snapshot = store.snapshots.get(ref); + if (!snapshot) continue; + if (snapshot.conversationId !== input.conversationId) continue; + if (snapshot.systemDigest !== input.systemDigest) continue; + if (snapshot.coveredMessageCount !== input.coveredMessageCount) continue; + if (snapshot.identityScope !== identityScope) continue; + if (snapshot.modelId !== input.modelId) continue; + if (foundRef) return undefined; + foundRef = ref; + } + return getCursorCheckpoint(foundRef); } export function getLatestCursorCheckpoint( diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 9d0e83cbbf..858db97b27 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -298,7 +298,7 @@ export function cursorConversationIdFromClientThread(threadId: string, identityS /** * Resolve the Cursor conversation id for this turn. - * Priority: force-fresh → isolate helper → remembered → thread override → client thread → random. + * Priority: force-fresh → isolate helper → remembered → client thread owner → random. * Never use OpenAI Responses `previous_response_id` (resp_*) or shared `prompt_cache_key` * (cache-cohort fingerprint, not conversation ownership). */ @@ -310,7 +310,7 @@ export function resolveCursorConversationId( if (options.forceFreshConversation === true) return generatedCursorConversationId(); if (parsed._cursorIsolateConversation === true) return generatedCursorConversationId(); if (parsed._cursorConversationId) return parsed._cursorConversationId; - const threadId = parsed._clientThreadId?.trim(); + const threadId = cursorClientThreadOwner(parsed); if (threadId) { const recovered = lookupCursorThreadConversation(threadId, parsed._cursorIdentityScope); if (recovered) return recovered; @@ -319,6 +319,10 @@ export function resolveCursorConversationId( return generatedCursorConversationId(); } +export function cursorClientThreadOwner(parsed: OcxParsedRequest): string | undefined { + return parsed._clientThreadId?.trim() || parsed._cursorClientThreadId?.trim() || undefined; +} + function updateFramed(hash: ReturnType, value: string): void { const bytes = Buffer.from(value, "utf8"); const length = Buffer.allocUnsafe(4); @@ -361,6 +365,7 @@ function lookupPrefixSnapshot( const modelId = cursorCheckpointModelAffinityId(request.modelId); for (let covered = parsed.context.messages.length; covered >= 1; covered--) { const snapshot = getCursorCheckpointForPrefix({ + conversationId: request.conversationId, prefixDigest: cursorCoveredPrefixDigest(parsed, covered), systemDigest, coveredMessageCount: covered, @@ -404,10 +409,14 @@ function resolveCursorCheckpoint( snapshot = getCursorCheckpoint(ref); if (!snapshot) return { reason: "expired" }; } else { + if ( + isolated + || (!parsed._cursorConversationId && !cursorClientThreadOwner(parsed)) + ) return { reason: "missing_ref" }; snapshot = lookupPrefixSnapshot(parsed, request, identityScope); if (!snapshot) return { reason: "missing_ref" }; } - if (!isolated && snapshot.conversationId !== request.conversationId && ref) { + if (snapshot.conversationId !== request.conversationId) { return { reason: "conversation_changed" }; } if (snapshot.identityScope !== identityScope) return { reason: "identity_changed" }; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 6557aa7094..21288462fc 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2185,6 +2185,7 @@ async function handleResponsesInner( (body as { input?: unknown } | undefined)?.input, ); const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined; + const cursorClientThreadId = codexPoolAffinityKey(req.headers); const originalBody = body; if (options.comboReplaySnapshot) { copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body); @@ -2254,6 +2255,7 @@ async function handleResponsesInner( parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey }; } } + if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId; } catch (err) { if (isTranslatorBudgetExceededError(err)) { return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", { @@ -2474,6 +2476,7 @@ async function handleResponsesInner( "_providerContinuationOwner", "_cursorConversationId", "_clientThreadId", + "_cursorClientThreadId", "_reasoningReplayScope", "_cursorIsolateConversation", ]; diff --git a/src/types/request.ts b/src/types/request.ts index 56156d8443..efe2164e37 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -68,6 +68,8 @@ export interface OcxParsedRequest { _cursorConversationId?: string; /** Stable upstream client thread identity, used only to derive provider-scoped continuation ids. */ _clientThreadId?: string; + /** Cursor-only thread owner; may be an opaque process-local Desktop session/thread identity. */ + _cursorClientThreadId?: string; /** Conversation/provider/account/model-bound namespace for reasoning replay state. */ _reasoningReplayScope?: OcxReasoningReplayScopeRef; /** diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index f7b9b1859b..9ce5fb08d7 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -734,9 +734,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. 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 +checkpoint plus only the uncovered suffix. A request without checkpointRef may use the prefix index +only when a remembered Cursor conversation or stable client thread owns the resolved conversation id. +The stable owner may be the Codex parent-thread header or the existing bounded process-local HMAC of +the complete Desktop session-id/thread-id pair. The request must also have a covered message prefix +and system/developer digest that match exactly one snapshot for that same +conversation. Headerless requests without a stable owner full-replay. Isolated helper/shadow turns +never join the parent or sibling 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. @@ -746,7 +750,7 @@ does not expose authoritative cache_read_tokens. - 목적과 의도: 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. +- 선택한 방식: Keep an opaque process-local checkpointRef on OcxProviderContinuationState.cursor, bind the snapshot to conversation/account/model affinity, and require a remembered provider conversation or stable client thread before a ref-less prefix lookup. Reuse the bounded process-local Desktop session/thread HMAC when the canonical parent-thread header is absent. Pin referenced blobs for the checkpoint lifetime, and fall back to the existing full-replay path for unowned headerless requests, 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. ``` diff --git a/tests/cursor-adapter.test.ts b/tests/cursor-adapter.test.ts index a53c1e4cb6..ca71ae073c 100644 --- a/tests/cursor-adapter.test.ts +++ b/tests/cursor-adapter.test.ts @@ -357,6 +357,56 @@ describe("Cursor adapter live transport", () => { expect(events.filter(event => event.type === "error")).toHaveLength(0); }); + test("forced-fresh recovery remembers a Cursor-only Desktop owner", async () => { + clearCursorThreadContinuityForTests(); + const seen: string[] = []; + let attempts = 0; + const adapter = createCursorAdapter({ + ...provider, + apiKey: "cursor-token", + }, { + createTransport: () => ({ + async *run(request) { + attempts += 1; + seen.push(request.conversationId); + 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() {}, + }), + }); + + const owner = "app:desktop-recovery-owner"; + const identityScope = "acct-desktop-recovery"; + const body: OcxParsedRequest = { + modelId: "cursor/gpt-5.6-sol", + context: { + messages: [ + { role: "user", content: "first turn", timestamp: 1 }, + { role: "assistant", content: [{ type: "text", text: "ack" }], timestamp: 2 }, + { role: "user", content: "second turn", timestamp: 3 }, + ], + }, + stream: false, + options: { reasoning: "xhigh" }, + _cursorClientThreadId: owner, + _cursorConversationId: "cursor_stale_desktop", + _cursorIdentityScope: identityScope, + }; + + await adapter.runTurn?.(body, { headers: new Headers() }, () => {}); + + expect(attempts).toBe(2); + expect(seen[1]).not.toBe(seen[0]); + expect(lookupCursorThreadConversation(owner, identityScope)).toBe(seen[1]); + clearCursorThreadContinuityForTests(); + }); + test("forced-fresh recovery keeps the new checkpoint instead of deleting it", async () => { clearCursorCheckpointsForTests(); const parentBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index 21548e26f4..754caf2059 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -4,7 +4,9 @@ import { ConversationStateStructureSchema } from "../src/adapters/cursor/gen/age import { clearCursorCheckpointsForTests, commitCursorCheckpoint, + CURSOR_CHECKPOINT_TTL_MS, getCursorCheckpoint, + installCursorCheckpointClockForTests, } from "../src/adapters/cursor/checkpoint-store"; import { applyCursorToolBudget, @@ -76,6 +78,60 @@ describe("Cursor request builder", () => { expect(continuation.conversationId).toBe(initial.conversationId); }); + test("uses a Cursor-only Desktop owner without widening Responses replay scope", () => { + const a = createCursorRequest({ + ...base, + _cursorClientThreadId: "app:desktop-owner-a", + }); + const same = createCursorRequest({ + ...base, + _cursorClientThreadId: "app:desktop-owner-a", + }); + const b = createCursorRequest({ + ...base, + _cursorClientThreadId: "app:desktop-owner-b", + }); + expect(same.conversationId).toBe(a.conversationId); + expect(b.conversationId).not.toBe(a.conversationId); + }); + + test("uses a Cursor-only Desktop owner for ref-less checkpoint admission", () => { + clearCursorCheckpointsForTests(); + const firstTurn = { + ...base, + _cursorClientThreadId: "app:desktop-checkpoint-owner", + _cursorIdentityScope: "acct-desktop", + context: { messages: [{ role: "user" as const, content: "desktop prefix", timestamp: 1 }] }, + }; + const built = createCursorRequest(firstTurn); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["desktop-owned-state"], + })); + expect(commitCursorCheckpoint({ + conversationId: built.conversationId, + identityScope: "acct-desktop", + modelId: cursorCheckpointModelAffinityId(built.modelId), + checkpointBytes, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(firstTurn, 1), + systemDigest: cursorInstructionDigest(firstTurn), + })).toBeDefined(); + + const followUp = createCursorRequest({ + ...firstTurn, + context: { + messages: [ + ...firstTurn.context.messages, + { role: "assistant" as const, content: [{ type: "text" as const, text: "reply" }], timestamp: 2 }, + { role: "user" as const, content: "continue", timestamp: 3 }, + ], + }, + }); + expect(followUp.continuationMode).toBe("checkpoint"); + expect(followUp.checkpointBytes).toEqual(checkpointBytes); + clearCursorCheckpointsForTests(); + }); + test("prefix digests do not collide across delimiter boundaries", () => { const left = { ...base, @@ -184,7 +240,7 @@ 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", () => { + test("isolated helper turns never reuse parent or sibling checkpoints", () => { clearCursorCheckpointsForTests(); const parentBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { pendingToolCalls: ["parent-fixture"], @@ -254,8 +310,9 @@ describe("Cursor request builder", () => { }, }); expect(second.conversationId).not.toBe("cursor_parent_real"); - expect(second.continuationMode).toBe("checkpoint"); - expect(second.checkpointBytes?.byteLength).toBe(helperBytes.byteLength); + expect(second.continuationMode).toBe("full-replay"); + expect(second.checkpointInvalidationReason).toBe("missing_ref"); + expect(second.checkpointBytes).toBeUndefined(); expect(getCursorCheckpoint(parentRef)?.ref).toBe(parentRef); clearCursorCheckpointsForTests(); }); @@ -1201,11 +1258,12 @@ describe("Cursor request builder", () => { clearCursorCheckpointsForTests(); }); - test("reuses a unique covered prefix when chat omits the continuation ref", () => { + test("reuses a unique covered prefix when a stable client thread omits the continuation ref", () => { clearCursorCheckpointsForTests(); const firstTurn = { ...base, modelId: "cursor/gpt-5.6-sol", + _clientThreadId: "thread-prefix-owner", _cursorIdentityScope: "acct-1", context: { messages: [{ role: "user" as const, content: "unique sol prompt 7f3c", timestamp: 1 }] }, }; @@ -1236,4 +1294,187 @@ describe("Cursor request builder", () => { expect(followUp.checkpointBytes?.byteLength).toBe(checkpointBytes.byteLength); clearCursorCheckpointsForTests(); }); + + test("does not reuse a unique covered prefix without a stable conversation owner", () => { + clearCursorCheckpointsForTests(); + const firstTurn = { + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "unowned shared prefix", timestamp: 1 }] }, + }; + const built = createCursorRequest(firstTurn); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["private-state"], + })); + expect(commitCursorCheckpoint({ + conversationId: built.conversationId, + identityScope: "acct-1", + modelId: cursorCheckpointModelAffinityId(built.modelId), + checkpointBytes, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(firstTurn, 1), + systemDigest: cursorInstructionDigest(firstTurn), + })).toBeDefined(); + + const unrelated = createCursorRequest({ + ...firstTurn, + context: { + messages: [ + ...firstTurn.context.messages, + { role: "assistant" as const, content: [{ type: "text" as const, text: "reply" }], timestamp: 2 }, + { role: "user" as const, content: "continue", timestamp: 3 }, + ], + }, + }); + expect(unrelated.conversationId).not.toBe(built.conversationId); + expect(unrelated.continuationMode).toBe("full-replay"); + expect(unrelated.checkpointInvalidationReason).toBe("missing_ref"); + expect(unrelated.checkpointBytes).toBeUndefined(); + clearCursorCheckpointsForTests(); + }); + + test("does not reuse a unique covered prefix from a different stable client thread", () => { + clearCursorCheckpointsForTests(); + const firstTurn = { + ...base, + modelId: "cursor/gpt-5.6-sol", + _clientThreadId: "thread-prefix-a", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "stable shared prefix", timestamp: 1 }] }, + }; + const built = createCursorRequest(firstTurn); + const checkpointBytes = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["thread-a-state"], + })); + expect(commitCursorCheckpoint({ + conversationId: built.conversationId, + identityScope: "acct-1", + modelId: cursorCheckpointModelAffinityId(built.modelId), + checkpointBytes, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(firstTurn, 1), + systemDigest: cursorInstructionDigest(firstTurn), + })).toBeDefined(); + + const unrelated = createCursorRequest({ + ...firstTurn, + _clientThreadId: "thread-prefix-b", + context: { + messages: [ + ...firstTurn.context.messages, + { role: "assistant" as const, content: [{ type: "text" as const, text: "reply" }], timestamp: 2 }, + { role: "user" as const, content: "continue", timestamp: 3 }, + ], + }, + }); + expect(unrelated.conversationId).not.toBe(built.conversationId); + expect(unrelated.continuationMode).toBe("full-replay"); + expect(unrelated.checkpointInvalidationReason).toBe("missing_ref"); + expect(unrelated.checkpointBytes).toBeUndefined(); + clearCursorCheckpointsForTests(); + }); + + test("selects the owned snapshot when different conversations share a prefix", () => { + clearCursorCheckpointsForTests(); + const common = { + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "owned shared prefix", timestamp: 1 }] }, + }; + const parsedA = { ...common, _clientThreadId: "thread-owned-a" }; + const parsedB = { ...common, _clientThreadId: "thread-owned-b" }; + const builtA = createCursorRequest(parsedA); + const builtB = createCursorRequest(parsedB); + const bytesA = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["owned-a"], + })); + const bytesB = toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: ["owned-b"], + })); + for (const [parsed, built, checkpointBytes] of [ + [parsedA, builtA, bytesA], + [parsedB, builtB, bytesB], + ] as const) { + expect(commitCursorCheckpoint({ + conversationId: built.conversationId, + identityScope: "acct-1", + modelId: cursorCheckpointModelAffinityId(built.modelId), + checkpointBytes, + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(parsed, 1), + systemDigest: cursorInstructionDigest(parsed), + })).toBeDefined(); + } + + const history = { + messages: [ + ...common.context.messages, + { role: "assistant" as const, content: [{ type: "text" as const, text: "reply" }], timestamp: 2 }, + { role: "user" as const, content: "continue", timestamp: 3 }, + ], + }; + const followA = createCursorRequest({ ...parsedA, context: history }); + const followB = createCursorRequest({ ...parsedB, context: history }); + expect(followA.continuationMode).toBe("checkpoint"); + expect(followA.checkpointBytes).toEqual(bytesA); + expect(followB.continuationMode).toBe("checkpoint"); + expect(followB.checkpointBytes).toEqual(bytesB); + clearCursorCheckpointsForTests(); + }); + + test("does not refresh an unrelated same-prefix checkpoint", () => { + clearCursorCheckpointsForTests(); + let now = 1_000; + installCursorCheckpointClockForTests({ + now: () => now, + schedule: (() => 0 as unknown as ReturnType), + clear: () => {}, + }); + try { + const common = { + ...base, + modelId: "cursor/gpt-5.6-sol", + _cursorIdentityScope: "acct-1", + context: { messages: [{ role: "user" as const, content: "ttl shared prefix", timestamp: 1 }] }, + }; + const parsedA = { ...common, _clientThreadId: "thread-ttl-a" }; + const parsedB = { ...common, _clientThreadId: "thread-ttl-b" }; + const builtA = createCursorRequest(parsedA); + const builtB = createCursorRequest(parsedB); + const commit = (parsed: typeof parsedA, conversationId: string, marker: string) => commitCursorCheckpoint({ + conversationId, + identityScope: "acct-1", + modelId: cursorCheckpointModelAffinityId(builtA.modelId), + checkpointBytes: toBinary(ConversationStateStructureSchema, create(ConversationStateStructureSchema, { + pendingToolCalls: [marker], + })), + coveredMessageCount: 1, + prefixDigest: cursorCoveredPrefixDigest(parsed, 1), + systemDigest: cursorInstructionDigest(parsed), + }); + expect(commit(parsedA, builtA.conversationId, "ttl-a")).toBeDefined(); + const refB = commit(parsedB, builtB.conversationId, "ttl-b"); + expect(refB).toBeDefined(); + + now += CURSOR_CHECKPOINT_TTL_MS - 1; + const followA = createCursorRequest({ + ...parsedA, + context: { + messages: [ + ...common.context.messages, + { role: "assistant" as const, content: [{ type: "text" as const, text: "reply" }], timestamp: 2 }, + { role: "user" as const, content: "continue", timestamp: 3 }, + ], + }, + }); + expect(followA.continuationMode).toBe("checkpoint"); + + now += 2; + expect(getCursorCheckpoint(refB)).toBeUndefined(); + } finally { + clearCursorCheckpointsForTests(); + } + }); }); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index bfcced0f35..ad4cc46592 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -2886,6 +2886,36 @@ describe("cursor conversation continuity across store:false chains", () => { expect(seen[1]).toBe(seen[0]); }); + test("Desktop session and thread headers retain Cursor ownership without a parent-thread header", async () => { + const seen: string[] = []; + customCursorTransportFactory = fakeCursorTransportFactory(seen); + const config = cursorConfig(); + const postDesktopTurn = (input: unknown) => handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "session-id": "desktop-session-owner", + "thread-id": "desktop-thread-owner", + }, + body: JSON.stringify({ + model: "cursortest/grok-4.5", + input, + stream: false, + store: false, + }), + }), config, { model: "", provider: "" }, {}); + + expect((await postDesktopTurn("start")).status).toBe(200); + expect((await postDesktopTurn([ + { role: "user", content: "start" }, + { role: "assistant", content: "working" }, + { role: "user", content: "continue" }, + ])).status).toBe(200); + + expect(seen).toHaveLength(2); + expect(seen[1]).toBe(seen[0]); + }); + test("native composer reuses conversationId across store:false turns via parent thread id", async () => { const seen: string[] = []; customCursorTransportFactory = fakeCursorTransportFactory(seen);