From 9a10f687f147bc2112250129ca8b5f7af7af9930 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Wed, 4 Nov 2026 02:14:29 -0800 Subject: [PATCH 1/5] feat(cursor): support editing sent messages --- .../Adapters/CursorAdapterV2.testkit.ts | 1 + docs/user/cursor.md | 6 +- .../src/server/CursorAgentSdk.test.ts | 70 ++++++++++++++++++ .../src/server/CursorAgentSdk.ts | 51 +++++++++++-- .../src/server/adapter.test.ts | 7 ++ .../provider-cursor/src/server/adapter.ts | 72 ++++++++++++++++--- .../provider-cursor/src/server/driver.test.ts | 2 + .../provider-cursor/src/server/status.test.ts | 2 +- packages/provider-cursor/src/server/status.ts | 2 +- 9 files changed, 194 insertions(+), 19 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index 7310cc7c81ea0..f64c3ed2e452d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -446,6 +446,7 @@ export function makeCursorAgentSdkReplayRunner( }, catch: (cause) => replayRunnerError(transcript, cause, "replay.session.send"), }), + rollback: () => Effect.die("Cursor replay rollback is not recorded"), listMessages: Effect.try({ try: () => { assertOutbound({ diff --git a/docs/user/cursor.md b/docs/user/cursor.md index 4263ceb14fd8d..564d271952262 100644 --- a/docs/user/cursor.md +++ b/docs/user/cursor.md @@ -50,17 +50,21 @@ The adapter supports: - thread-scoped T3 Code MCP tools alongside ambient Cursor MCP/plugins from disk; - image attachments; - interruption, queued app messages, and orchestrator-owned interrupt/restart steering; +- editing sent messages and reverting conversations through persisted SDK checkpoints; - provider conversation snapshots through `Agent.messages.list()`; - Cursor `task` subagents, projected as read-only child app threads with their tool activity and final result. -The public SDK does not currently expose native agent fork, conversation rollback, active steering, +The public SDK does not currently expose native agent fork, active steering, or interactive approval callbacks. Direct active steering is advertised as unsupported, while V2 steering uses the orchestrator's interrupt-and-restart path and preserves the app run identity across provider turns. Same-provider Cursor forks use the orchestrator's portable full-thread context handoff into a fresh Cursor agent. The SDK has in-process custom callback tools, but the V2 adapter intentionally uses the authenticated, thread-scoped MCP server instead. +Editing a sent message rewinds the conversation to before that message. Rollback requires the +local Cursor checkpoint data to still be available on the environment running the thread. + Portable handoffs summarize eligible timeline items and can omit the end of long messages. Read [Context in portable handoffs](./portable-handoffs.md) before using a fork for work whose exact instructions must carry forward. diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts index 431b4489dfd78..e3508f562bb65 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts @@ -2,6 +2,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import { JsonlLocalAgentStore } from "@cursor/sdk"; import * as Layer from "effect/Layer"; import * as Exit from "effect/Exit"; import * as Cause from "effect/Cause"; @@ -136,6 +138,74 @@ const layerTest = CursorAgentSdk.layer.pipe( ); describe("CursorAgentSdkRunner", () => { + it.effect("restores persisted run boundaries and rejects unavailable or active targets", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "cursor-rollback-" }); + const store = new JsonlLocalAgentStore(root); + const agentId = "agent-cursor-agent-sdk-test"; + const checkpoint = { schemaVersion: 1 as const, rootBlobId: "retained" }; + const document = { + agentId, + cwd: root, + status: "idle" as const, + activeRunId: null, + createdAt: 0, + updatedAt: 0, + latestCheckpoint: { ...checkpoint, rootBlobId: "discarded" }, + sdkMetadata: { preserved: true }, + }; + yield* Effect.promise(async () => { + await store.agents.create({ agent: document }); + await store.checkpoints.create({ + agentId, + blobId: checkpoint.rootBlobId, + data: new Uint8Array([1]), + }); + await store.runs.create({ + run: { + agentId, + runId: "retained-run", + turnNumber: 1, + status: "finished", + createdAt: 0, + updatedAt: 0, + latestCheckpointRef: checkpoint, + }, + }); + }); + cursorSdkMock.setAgentCloseFailure(undefined); + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const session = yield* runner.open({ + operation: "resume", + agentId, + threadId: ThreadId.make("rollback"), + providerSessionId: ProviderSessionId.make("rollback"), + options: { local: { cwd: root, store } }, + }); + yield* session.rollback("retained-run"); + const reopened = new JsonlLocalAgentStore(root); + const restored = yield* Effect.promise(() => reopened.agents.get({ agentId })); + assert.deepStrictEqual(restored?.latestCheckpoint, checkpoint); + assert.deepStrictEqual(restored?.sdkMetadata, document.sdkMetadata); + const missing = yield* session.rollback("missing").pipe(Effect.exit); + assert(Exit.isFailure(missing)); + yield* Effect.promise(() => + store.agents.update({ agent: { ...document, status: "running" } }), + ); + assert(Exit.isFailure(yield* session.rollback(null).pipe(Effect.exit))); + yield* Effect.promise(() => store.agents.update({ agent: document })); + yield* Effect.promise(() => + store.checkpoints.delete({ + filter: { agentIds: [agentId], blobIds: [checkpoint.rootBlobId] }, + }), + ); + assert(Exit.isFailure(yield* session.rollback("retained-run").pipe(Effect.exit))); + yield* session.rollback(null); + assert.isNull((yield* Effect.promise(() => store.agents.get({ agentId })))?.latestCheckpoint); + }).pipe(Effect.provide(Layer.merge(layerTest, NodeServices.layer))), + ); + it.effect("constructs without loading and reports first-use load failures as runner errors", () => Effect.gen(function* () { const failure = new Error("missing SDK chunk"); diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index 92fb764b432d5..3f274eb184fcf 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -105,6 +105,7 @@ export interface CursorAgentSdkSession { readonly send: ( input: CursorAgentSdkSendInput, ) => Effect.Effect; + readonly rollback: (runId: string | null) => Effect.Effect; readonly listMessages: Effect.Effect, CursorAgentSdkRunnerError>; readonly close: Effect.Effect; } @@ -588,6 +589,50 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu ), } satisfies CursorAgentSdkRun; }), + rollback: Effect.fn("CursorAgentSdkSession.rollback")(function* (runId) { + yield* Effect.tryPromise({ + try: async () => { + const ownedStore = + input.options.local?.store === undefined + ? await sdk + .loadSqlite() + .SqliteLocalAgentStore.open({ workspaceRef: cwd ?? process.cwd() }) + : undefined; + const store = input.options.local?.store ?? ownedStore!; + try { + const document = await store.agents.get({ agentId: agent.agentId }); + if (document === null) throw new Error("Cursor agent is unavailable for rollback."); + if (document.status === "running" || document.activeRunId != null) + throw new Error("Cannot roll back an active Cursor agent."); + const run = + runId === null ? null : await store.runs.get({ agentId: agent.agentId, runId }); + if ( + runId !== null && + (run === null || + run.agentId !== agent.agentId || + run.status === "running" || + run.status === "queued" || + run.latestCheckpointRef == null) + ) + throw new Error("Cursor turn checkpoint is unavailable for rollback."); + const checkpoint = run?.latestCheckpointRef ?? null; + if ( + checkpoint !== null && + (await store.checkpoints.get({ + agentId: agent.agentId, + blobId: checkpoint.rootBlobId, + })) === null + ) + throw new Error("Cursor checkpoint blob is unavailable for rollback."); + agent.close(); + await store.agents.update({ agent: { ...document, latestCheckpoint: checkpoint } }); + } finally { + await ownedStore?.dispose(); + } + }, + catch: (cause) => runnerError(cause, "agent.rollback"), + }); + }), listMessages: log({ direction: "outgoing", stage: "decoded", @@ -598,11 +643,7 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu }).pipe( Effect.andThen( Effect.tryPromise({ - try: () => - Agent.messages.list(agent.agentId, { - runtime: "local", - ...(cwd === undefined ? {} : { cwd }), - }), + try: () => Agent.messages.list(agent.agentId, runOptions), catch: (cause) => runnerError(cause, "agent.messages.list"), }), ), diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 3f04ac7328323..853fe81519f75 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -82,6 +82,7 @@ describe("CursorAdapterV2", () => { open: () => Effect.succeed({ agentId: "native-cursor-lifecycle", + rollback: () => Effect.void, listMessages: Effect.succeed([]), close: Effect.void, send: (input) => @@ -415,6 +416,7 @@ describe("CursorAdapterV2", () => { open: () => Effect.succeed({ agentId: "native-cursor-failure", + rollback: () => Effect.void, listMessages: Effect.succeed([]), close: Effect.void, send: (input) => @@ -535,6 +537,7 @@ describe("CursorAdapterV2", () => { open: () => Effect.succeed({ agentId: "native-cursor-error", + rollback: () => Effect.void, listMessages: Effect.succeed([]), close: Effect.void, send: (input) => @@ -695,6 +698,7 @@ describe("CursorAdapterV2", () => { open: () => Effect.succeed({ agentId: "native-cursor-device", + rollback: () => Effect.void, listMessages: Effect.succeed([]), close: Effect.void, send: (input) => @@ -1210,6 +1214,7 @@ describe("CursorAdapterV2", () => { open: () => Effect.succeed({ agentId: "native-cursor-search", + rollback: () => Effect.void, listMessages: Effect.succeed([]), close: Effect.void, send: (input) => @@ -1481,6 +1486,7 @@ describe("CursorAdapterV2", () => { open: () => Effect.succeed({ agentId: "native-cursor-task-ls", + rollback: () => Effect.void, listMessages: Effect.succeed([]), close: Effect.void, send: (input) => @@ -1665,6 +1671,7 @@ describe("CursorAdapterV2", () => { checkModel(`agent.${input.operation}`, input.options.model).pipe( Effect.as({ agentId: "native-cursor-custom-model", + rollback: () => Effect.void, listMessages: Effect.succeed([]), close: Effect.void, send: (sendInput) => diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index f5fbd0630d478..2bcc26bba269e 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -153,8 +153,8 @@ export const CursorProviderCapabilitiesV2 = { checkpointing: { appCanCheckpointFilesystem: true, supportsNestedCheckpointScopes: true, - providerCanRollbackConversation: false, - providerRollbackReturnsSnapshot: false, + providerCanRollbackConversation: true, + providerRollbackReturnsSnapshot: true, providerCanReadConversationSnapshot: true, }, identity: { @@ -2823,15 +2823,65 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ), ), ), - rollbackThread: (rollbackInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerThreadId: rollbackInput.providerThread.id, - checkpointId: rollbackInput.target.checkpointId, - cause: "Cursor Agent SDK does not expose conversation rollback.", - }), - ), + rollbackThread: Effect.fn("CursorAdapterV2.rollbackThread")( + function* (rollbackInput) { + if ((yield* Ref.get(activeTurn)) !== null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: "Cannot roll back while a Cursor turn is active.", + }); + } + const target = rollbackInput.target; + const runId = + target.type === "thread_start" ? null : target.providerTurn.nativeTurnRef?.nativeId; + if ( + (target.type === "provider_turn" && runId == null) || + (target.type === "provider_turn" && + target.providerTurn.providerThreadId !== rollbackInput.providerThread.id) + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: "Cursor rollback target has no matching native turn.", + }); + } + const agent = yield* openAgent({ + operation: "resume", + agentId: nativeThreadId(rollbackInput.providerThread), + threadId: rollbackInput.providerThread.appThreadId ?? input.threadId, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + }); + yield* agent.session.rollback(runId ?? null).pipe( + Effect.onError(() => agent.session.close.pipe(Effect.ignore)), + Effect.ensuring(Ref.set(liveAgent, null)), + ); + yield* Ref.set(told, null); + return { + providerThread: { + ...rollbackInput.providerThread, + nativeConversationHeadRef: + target.type === "thread_start" ? null : target.providerTurn.nativeTurnRef, + status: "idle" as const, + updatedAt: yield* DateTime.now, + }, + providerTurns: [], + messages: [], + runtimeRequests: [], + }; + }, + (effect, rollbackInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause, + }), + ), + ), + ), forkThread: (forkInput) => Effect.fail( new ProviderAdapter.ProviderAdapterForkThreadError({ diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index 164118085d233..c7f0e36af1cf3 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -123,6 +123,7 @@ it.layer(layerTest)("CursorDriver", (it) => { openedKeys.push(request.options.apiKey); return { agentId: "browser-auth-agent", + rollback: () => Effect.void, listMessages: Effect.succeed([]), send: () => Effect.die("This test only opens a session"), close: Effect.sync(() => { @@ -228,6 +229,7 @@ it.layer(layerTest)("CursorDriver", (it) => { openedModels.push(request.options.model); return { agentId: "native-cursor-window", + rollback: () => Effect.void, listMessages: Effect.succeed([]), close: Effect.void, send: (input) => diff --git a/packages/provider-cursor/src/server/status.test.ts b/packages/provider-cursor/src/server/status.test.ts index c3dc7deb120ff..20331ad918d44 100644 --- a/packages/provider-cursor/src/server/status.test.ts +++ b/packages/provider-cursor/src/server/status.test.ts @@ -111,7 +111,7 @@ describe("buildCursorProviderSnapshot", () => { status: "warning", message: "Cursor SDK model discovery returned no built-in models.", models: [], - supportsConversationRollback: false, + supportsConversationRollback: true, }); }); }); diff --git a/packages/provider-cursor/src/server/status.ts b/packages/provider-cursor/src/server/status.ts index ffeca7a1da0b7..3d637291827dd 100644 --- a/packages/provider-cursor/src/server/status.ts +++ b/packages/provider-cursor/src/server/status.ts @@ -25,7 +25,7 @@ import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; const CURSOR_PRESENTATION = { displayName: "Cursor", - supportsConversationRollback: false, + supportsConversationRollback: true, showInteractionModeToggle: true, } as const; const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ From 033308d0b5c1dcfe95f7f00e815dbfaafe46126e Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Thu, 5 Nov 2026 06:18:26 -0800 Subject: [PATCH 2/5] fix(cursor): allow rollback and restore instructions after rewind --- .../orchestration-v2/CommandPolicy.test.ts | 12 +++++++++++ .../src/server/adapter.test.ts | 20 +++++++++++++++++++ .../provider-cursor/src/server/adapter.ts | 13 ++++++++++-- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 8ed4de3e9dea0..ba0d3a409c825 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -247,6 +247,18 @@ it.each(["/compact", " /LOGOUT "])("queues a steer sent while %s runs", (text) = const layer = it.layer(CommandPolicy.layer); layer("CommandPolicyV2", (it) => { + it.effect("allows Cursor conversation rollback", () => + Effect.gen(function* () { + const policy = yield* CommandPolicy.CommandPolicyV2; + yield* policy.ensureRollback({ + commandId, + threadId, + providerInstanceId: ProviderInstanceId.make("cursor"), + capabilities: CursorProviderCapabilitiesV2, + }); + }), + ); + it.effect("falls back to portable context when Cursor cannot fork natively", () => Effect.gen(function* () { const policy = yield* CommandPolicy.CommandPolicyV2; diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 853fe81519f75..1b4a5614937cb 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import type { InteractionUpdate } from "@cursor/sdk"; import { toolOutputImages } from "@t3tools/shared/toolOutput"; import { + CheckpointId, EnvironmentId, MessageId, NodeId, @@ -809,6 +810,25 @@ describe("CursorAdapterV2", () => { yield* setDeviceAccess(true); assert.deepEqual(yield* send(), told(false, true), "device access turned on later"); assert.deepEqual(yield* send(), told(false, false)); + yield* runtime.rollbackThread({ + providerThread, + providerThreadTurns: [], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("cursor-start"), + appRunOrdinal: 0, + }, + }); + assert.deepEqual( + yield* send(), + told(true, true), + "thread-start rollback drops all instructions despite the increasing run ordinal", + ); + assert.deepEqual( + yield* send(), + told(false, false), + "instructions are sent only once after rollback", + ); }).pipe( Effect.scoped, Effect.provide( diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 2bcc26bba269e..7d0041f1898af 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -85,7 +85,7 @@ export const CursorProviderCapabilitiesV2 = { threads: { canCreateEmptyThread: true, canReadThreadSnapshot: true, - canRollbackThread: false, + canRollbackThread: true, canForkThread: false, canForkFromTurn: false, canForkFromSubagentThread: false, @@ -2855,7 +2855,16 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( Effect.onError(() => agent.session.close.pipe(Effect.ignore)), Effect.ensuring(Ref.set(liveAgent, null)), ); - yield* Ref.set(told, null); + yield* Ref.set( + told, + target.type === "thread_start" + ? { + agent: agent.nativeThreadId, + orchestration: false, + device: false, + } + : null, + ); return { providerThread: { ...rollbackInput.providerThread, From e37664e90263d17d7acde2b0a02ff2a8fd424a7e Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sun, 8 Nov 2026 09:51:14 -0800 Subject: [PATCH 3/5] fix(cursor): restore instructions after rollback session restart --- .../src/server/adapter.test.ts | 33 +++++++++++++++++-- .../provider-cursor/src/server/adapter.ts | 7 ++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 1b4a5614937cb..0ba67bb159706 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -725,17 +725,18 @@ describe("CursorAdapterV2", () => { }), Effect.provide(TestProviderHost.layer({ cwd: workspace })), ); - const runtime = yield* adapter.openSession({ + let runtime = yield* adapter.openSession({ threadId, providerSessionId: ProviderSessionId.make("cursor-device-session"), modelSelection, runtimePolicy, }); - const providerThread = yield* runtime.ensureThread({ + let providerThread = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy, }); + providerThread = { ...providerThread, lastRunOrdinal: 4 }; const now = yield* DateTime.now; const appThread = { id: threadId, @@ -829,6 +830,34 @@ describe("CursorAdapterV2", () => { told(false, false), "instructions are sent only once after rollback", ); + const restored = yield* runtime.rollbackThread({ + providerThread, + providerThreadTurns: [], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("cursor-start-again"), + appRunOrdinal: 0, + }, + }); + // CheckpointRollbackService persists null at the thread-start boundary. + providerThread = { ...restored.providerThread, lastRunOrdinal: null }; + runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-device-restarted"), + modelSelection, + runtimePolicy, + }); + providerThread = yield* runtime.resumeThread({ providerThread }); + assert.deepEqual( + yield* send(), + told(true, true), + "a restarted session restores instructions after rollback", + ); + assert.deepEqual( + yield* send(), + told(false, false), + "restarted sessions deliver instructions only once", + ); }).pipe( Effect.scoped, Effect.provide( diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 7d0041f1898af..63092721c132b 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2401,11 +2401,14 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( const hasDeviceTools = mcpServers !== undefined && mcpSession?.capabilities?.has("device") === true; const known = yield* Ref.get(told); - // A conversation this process has not sent to was told on its first run. + // Rollback resets the retained ordinal; app run ordinals keep increasing. const delivered = known?.agent === agent.nativeThreadId ? known - : { orchestration: turnInput.runOrdinal > 1, device: false }; + : { + orchestration: turnInput.providerThread.lastRunOrdinal !== null, + device: false, + }; const interrupted = yield* Ref.get(interruptedPrompt); const { message, prompt } = yield* resolveUserMessage( turnInput, From b15aee2d55230b0ec9223737846cdd106aab7ed4 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Sun, 8 Nov 2026 10:06:32 -0800 Subject: [PATCH 4/5] fix(cursor): resend instructions when retained delivery is unknown --- .../src/server/adapter.test.ts | 55 +++++++++++++++++-- .../provider-cursor/src/server/adapter.ts | 15 +---- 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 0ba67bb159706..f8a43fcc9e4d6 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import type { InteractionUpdate } from "@cursor/sdk"; import { toolOutputImages } from "@t3tools/shared/toolOutput"; import { + type OrchestrationV2ProviderTurn, CheckpointId, EnvironmentId, MessageId, @@ -761,8 +762,9 @@ describe("CursorAdapterV2", () => { lastVisitedAt: null, deletedAt: null, }; - // Starts at run 5: a conversation from before device instructions existed, now resumed. + // Resuming a conversation does not prove it ever received T3 instructions. let runOrdinal = 4; + let latestTurn: OrchestrationV2ProviderTurn | undefined; const send = Effect.fnUntraced(function* (compact = false) { runOrdinal += 1; const turnInput = { @@ -785,10 +787,13 @@ describe("CursorAdapterV2", () => { }, }; yield* compact ? runtime.compactThread!(turnInput) : runtime.startTurn(turnInput); - yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, ); + latestTurn = events.findLast( + (event) => event.type === "provider_turn.updated", + )?.providerTurn; const sent = sentMessages.at(-1)!; return { orchestration: sent.includes("## T3 Code orchestration"), @@ -798,7 +803,7 @@ describe("CursorAdapterV2", () => { const told = (orchestration: boolean, devices: boolean) => ({ orchestration, devices }); yield* setDeviceAccess(true); - assert.deepEqual(yield* send(), told(false, true)); + assert.deepEqual(yield* send(), told(true, true)); assert.deepEqual(yield* send(), told(false, false)); yield* send(true); assert.deepEqual( @@ -858,6 +863,46 @@ describe("CursorAdapterV2", () => { told(false, false), "restarted sessions deliver instructions only once", ); + yield* mcpSessions.clear(threadId); + yield* send(true); + assert.deepEqual(yield* send(), told(false, false)); + const withoutInstructions = latestTurn; + if (withoutInstructions === undefined) throw new Error("Missing retained provider turn"); + const retainedOrdinal = runOrdinal; + yield* setDeviceAccess(true); + assert.deepEqual(yield* send(), told(true, true)); + for (const restart of [false, true]) { + const rolledBack = yield* runtime.rollbackThread({ + providerThread, + providerThreadTurns: [withoutInstructions], + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("cursor-before-mcp"), + appRunOrdinal: retainedOrdinal, + providerTurn: withoutInstructions, + }, + }); + providerThread = { ...rolledBack.providerThread, lastRunOrdinal: retainedOrdinal }; + if (restart) { + runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-retained-restarted"), + modelSelection, + runtimePolicy, + }); + providerThread = yield* runtime.resumeThread({ providerThread }); + } + assert.deepEqual( + yield* send(), + told(true, true), + "retained turns without MCP need instructions after rollback", + ); + assert.deepEqual( + yield* send(), + told(false, false), + "retained-turn rollback delivers instructions only once", + ); + } }).pipe( Effect.scoped, Effect.provide( diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 63092721c132b..c17bf0bf68b87 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2401,12 +2401,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( const hasDeviceTools = mcpServers !== undefined && mcpSession?.capabilities?.has("device") === true; const known = yield* Ref.get(told); - // Rollback resets the retained ordinal; app run ordinals keep increasing. + // Unknown delivery is retried once: retained turns may predate T3 MCP access. const delivered = known?.agent === agent.nativeThreadId ? known : { - orchestration: turnInput.providerThread.lastRunOrdinal !== null, + orchestration: false, device: false, }; const interrupted = yield* Ref.get(interruptedPrompt); @@ -2858,16 +2858,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( Effect.onError(() => agent.session.close.pipe(Effect.ignore)), Effect.ensuring(Ref.set(liveAgent, null)), ); - yield* Ref.set( - told, - target.type === "thread_start" - ? { - agent: agent.nativeThreadId, - orchestration: false, - device: false, - } - : null, - ); + yield* Ref.set(told, null); return { providerThread: { ...rollbackInput.providerThread, From 3881db801ebcf521d7ee3042f9d73de7dd09831d Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Tue, 10 Nov 2026 07:52:39 -0800 Subject: [PATCH 5/5] fix(cursor): discard interrupted prompts when rolling back --- .../src/server/adapter.test.ts | 22 ++++++++++++++----- .../provider-cursor/src/server/adapter.ts | 1 + 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index f8a43fcc9e4d6..4a63f8637e559 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -911,7 +911,7 @@ describe("CursorAdapterV2", () => { ), ); - it.effect("carries an interrupted prompt into the steer that restarts the turn", () => + it.effect.each([false, true])("handles interrupted prompts when rollback is %s", (rollback) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-steer-" }); @@ -934,6 +934,7 @@ describe("CursorAdapterV2", () => { open: () => Effect.succeed({ agentId: "native-cursor-steer", + rollback: () => Effect.void, listMessages: Effect.succeed([]), close: Effect.void, send: (input) => @@ -1030,16 +1031,25 @@ describe("CursorAdapterV2", () => { ); assert(started.type === "provider_turn.updated"); yield* runtime.interruptTurn({ providerThread, providerTurnId: started.providerTurn.id }); + if (rollback) { + yield* runtime.rollbackThread({ + providerThread, + providerThreadTurns: [], + target: { + type: "thread_start", + checkpointId: CheckpointId.make("steer-reset"), + appRunOrdinal: 0, + }, + }); + } yield* runtime.startTurn(turnInput(2, steerText)); assert.lengthOf(sentMessages, 2); const steered = sentMessages[1]!; assert.include(steered, steerText); - assert.include( - steered, - firstPrompt, - "the interrupted prompt is missing from the run that replaces it", - ); + if (rollback) + assert.notInclude(steered, firstPrompt, "rollback must discard the interrupted prompt"); + else assert.include(steered, firstPrompt, "steering preserves the interrupted prompt"); yield* Deferred.succeed(runEnds[1]!, "finished"); yield* runtime.events.pipe( diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index c17bf0bf68b87..9831e05bc718c 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2859,6 +2859,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( Effect.ensuring(Ref.set(liveAgent, null)), ); yield* Ref.set(told, null); + yield* Ref.set(interruptedPrompt, null); return { providerThread: { ...rollbackInput.providerThread,