diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 65f4393877b..17f9fd45fd7 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -254,6 +254,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda coveredMessageCount, prefixDigest: cursorCoveredPrefixDigest(_parsed, coveredMessageCount), systemDigest: cursorInstructionDigest(_parsed), + toolSuspended: toolSuspendedCommit, }); if (!checkpointRef) return; if (previousRef && previousRef !== checkpointRef) invalidateCursorCheckpoint(previousRef); @@ -263,7 +264,8 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda ...(_parsed._providerContinuation?.cursor ?? {}), conversationId: activeRequest.conversationId, // A tool-suspended checkpoint is only usable by the immediate trailing-toolResult - // continuation; the request-builder guard keys on checkpointUsable=false for that. + // continuation; the request-builder guard keys on this checkpointUsable=false and + // the snapshot's persisted toolSuspended flag for that. checkpointUsable: !toolSuspendedCommit, checkpointRef, }, diff --git a/src/adapters/cursor/checkpoint-store.ts b/src/adapters/cursor/checkpoint-store.ts index 758430064f3..b5ddaa60ef8 100644 --- a/src/adapters/cursor/checkpoint-store.ts +++ b/src/adapters/cursor/checkpoint-store.ts @@ -42,6 +42,7 @@ export interface CursorCheckpointSnapshot { coveredMessageCount?: number; prefixDigest?: string; systemDigest?: string; + toolSuspended?: boolean; } interface CursorCheckpointStore { @@ -229,6 +230,7 @@ export function commitCursorCheckpoint(input: { coveredMessageCount?: number; prefixDigest?: string; systemDigest?: string; + toolSuspended?: boolean; }): string | undefined { if (!input.conversationId || !input.modelId || input.checkpointBytes.byteLength === 0) return undefined; if (input.checkpointBytes.byteLength > CURSOR_CHECKPOINT_MAX_TOTAL_BYTES) return undefined; @@ -258,6 +260,7 @@ export function commitCursorCheckpoint(input: { ...(input.coveredMessageCount !== undefined ? { coveredMessageCount: input.coveredMessageCount } : {}), ...(input.prefixDigest ? { prefixDigest: input.prefixDigest } : {}), ...(input.systemDigest ? { systemDigest: input.systemDigest } : {}), + ...(input.toolSuspended ? { toolSuspended: true } : {}), }; const blobIds = collectCheckpointBlobIds(input.checkpointBytes); if (blobIds === undefined) return undefined; diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 3eb922b9689..065f971afa3 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -476,7 +476,13 @@ function resolveCursorCheckpoint( if (cursorCheckpointModelAffinityId(snapshot.modelId) !== cursorCheckpointModelAffinityId(request.modelId)) { return { reason: "model_changed" }; } - if (parsed.context.messages.at(-1)?.role !== "toolResult" && cursorState?.checkpointUsable === false) { + // The continuation state only exists on the ref path; a ref-less prefix hit carries + // the suspension on the snapshot itself, or a non-toolResult request could resume + // bytes upstream serialized mid-tool-call. + if ( + parsed.context.messages.at(-1)?.role !== "toolResult" + && (snapshot.toolSuspended === true || cursorState?.checkpointUsable === false) + ) { return { reason: "trailing_tool_result" }; } const lineage = lineageMismatch(parsed, snapshot); diff --git a/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts b/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts index a365327a067..85c92eb8eb9 100644 --- a/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts +++ b/tests/providers/cursor/cursor-tool-suspended-checkpoint.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { createCursorAdapter as createCursorAdapterProduction } from "../../../src/adapters/cursor"; import { clearCursorCheckpointsForTests, cursorCheckpointShape, getCursorCheckpoint } from "../../../src/adapters/cursor/checkpoint-store"; +import { createCursorRequest } from "../../../src/adapters/cursor/request-builder"; import { create, toBinary } from "@bufbuild/protobuf"; import { ConversationStateStructureSchema } from "../../../src/adapters/cursor/gen/agent_pb"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; @@ -55,7 +56,25 @@ describe("tool-suspended checkpoint commit (devlog 260826 050)", () => { if (done?.type !== "done") throw new Error("expected done"); expect(done.providerState?.cursor?.checkpointRef).toBeDefined(); expect(done.providerState?.cursor?.checkpointUsable).toBe(false); - expect(getCursorCheckpoint(done.providerState?.cursor?.checkpointRef)).toBeDefined(); + expect(getCursorCheckpoint(done.providerState?.cursor?.checkpointRef)?.toolSuspended).toBe(true); + + // A ref-less prefix lookup sees the suspension on the snapshot itself: a request + // whose trailing message is not a toolResult must not resume bytes that upstream + // serialized mid-tool-call. + const retry = createCursorRequest(body("cursor/grok-4.6")); + expect(retry.continuationMode).toBe("full-replay"); + expect(retry.checkpointInvalidationReason).toBe("trailing_tool_result"); + + const continuation = body("cursor/grok-4.6"); + continuation.context.messages.push({ + role: "toolResult", + toolCallId: "call_x", + content: "sunny", + timestamp: 2, + }); + const resumed = createCursorRequest(continuation); + expect(resumed.continuationMode).toBe("checkpoint"); + expect(resumed.checkpointBytes).toEqual(checkpointBytes); clearCursorCheckpointsForTests(); });