From f3db164add055468b7c597b0fdd88d91a0605520 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 18:16:29 +0000 Subject: [PATCH 1/3] test(cursor): reproduce steer losing the interrupted prompt (#32568) Cursor drops a cancelled run's prompt from the agent's conversation, so the run that a steer starts after interrupting sees only the steer text. This test interrupts a Cursor turn, steers with new text, and expects the next SDK send to still carry the interrupted prompt. It fails on main. Reproduces pingdotgg/t3code#32568 Co-Authored-By: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index cc8afd31311bb..73f2241b90bab 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -14,6 +14,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -812,6 +813,141 @@ describe("CursorAdapterV2", () => { ), ); + it.effect("carries an interrupted prompt into the steer that restarts the turn", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-steer-" }); + const sentMessages: Array = []; + const instanceId = ProviderInstanceId.make("cursor"); + const threadId = ThreadId.make("cursor-steer-thread"); + const modelSelection = { instanceId, model: "composer-2.5" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const adapter = yield* makeCursorAdapterV2({ + instanceId, + environment: { HOME: workspace }, + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + assertComplete: Effect.void, + open: () => + Effect.succeed({ + agentId: "native-cursor-steer", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.gen(function* () { + sentMessages.push( + typeof input.message === "string" ? input.message : input.message.text, + ); + const runId = `native-cursor-run-${sentMessages.length}`; + const cancelled = yield* Deferred.make(); + return { + agentId: "native-cursor-steer", + runId, + // Cursor drops a cancelled run's prompt from the agent's conversation. + wait: Deferred.await(cancelled).pipe( + Effect.as({ + id: runId, + requestId: `native-request-${sentMessages.length}`, + status: "cancelled" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + }), + ), + cancel: Deferred.succeed(cancelled, undefined), + }; + }), + }), + }), + Effect.provide(TestProviderHost.layer({ cwd: workspace })), + ); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-steer-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + const appThread = { + id: threadId, + projectId: ProjectId.make("cursor-steer-project"), + createdBy: "user" as const, + creationSource: "web" as const, + title: "Cursor steer", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + const turnInput = (runOrdinal: number, text: string) => ({ + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make(`cursor-steer-run-${runOrdinal}`), + runOrdinal, + providerTurnOrdinal: runOrdinal, + attemptId: RunAttemptId.make(`cursor-steer-attempt-${runOrdinal}`), + rootNodeId: NodeId.make(`cursor-steer-root-${runOrdinal}`), + appThread, + message: { + messageId: MessageId.make(`cursor-steer-message-${runOrdinal}`), + createdBy: "user" as const, + creationSource: "web" as const, + text, + attachments: [], + }, + }); + const firstPrompt = "Write me a 1000 word story about a dog."; + const steerText = "No wait, a pig please."; + + yield* runtime.startTurn(turnInput(1, firstPrompt)); + const started = Option.getOrThrow( + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "provider_turn.updated"), + Stream.runHead, + ), + ); + assert(started.type === "provider_turn.updated"); + yield* runtime.interruptTurn({ providerThread, providerTurnId: started.providerTurn.id }); + 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", + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), + ); + it.effect("projects Cursor directory trees and lint diagnostics as file search results", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; From 53b18482fca5fa7b53d4c534f50cb007fe7ffb2a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 18:24:50 +0000 Subject: [PATCH 2/3] fix(cursor): a steer keeps the prompt it interrupted Cursor steers by cancelling the running turn and starting a new one, and Cursor drops a cancelled run from the agent's conversation. The new run saw only the steer text, and lost the T3 orchestration instructions the interrupted run had delivered. When T3 interrupts a Cursor run, the adapter now remembers the user text that run sent and puts it ahead of the next message on the same agent, framed as an interrupted request the new message may continue or change. It is cleared once a real message is sent and ignored for a different agent. Fixes pingdotgg/t3code#32568 Co-Authored-By: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 22 +++++++--- .../provider-cursor/src/server/adapter.ts | 42 ++++++++++++++----- 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 73f2241b90bab..3f04ac7328323 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -818,6 +818,7 @@ describe("CursorAdapterV2", () => { const fileSystem = yield* FileSystem.FileSystem; const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-steer-" }); const sentMessages: Array = []; + const runEnds: Array> = []; const instanceId = ProviderInstanceId.make("cursor"); const threadId = ThreadId.make("cursor-steer-thread"); const modelSelection = { instanceId, model: "composer-2.5" }; @@ -843,21 +844,22 @@ describe("CursorAdapterV2", () => { typeof input.message === "string" ? input.message : input.message.text, ); const runId = `native-cursor-run-${sentMessages.length}`; - const cancelled = yield* Deferred.make(); + const ended = yield* Deferred.make<"finished" | "cancelled">(); + runEnds.push(ended); return { agentId: "native-cursor-steer", runId, // Cursor drops a cancelled run's prompt from the agent's conversation. - wait: Deferred.await(cancelled).pipe( - Effect.as({ + wait: Deferred.await(ended).pipe( + Effect.map((status) => ({ id: runId, requestId: `native-request-${sentMessages.length}`, - status: "cancelled" as const, + status, model: { id: "composer-2.5" }, durationMs: 1, - }), + })), ), - cancel: Deferred.succeed(cancelled, undefined), + cancel: Deferred.succeed(ended, "cancelled"), }; }), }), @@ -940,6 +942,14 @@ describe("CursorAdapterV2", () => { firstPrompt, "the interrupted prompt is missing from the run that replaces it", ); + + yield* Deferred.succeed(runEnds[1]!, "finished"); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal" && event.status === "completed"), + Stream.runHead, + ); + yield* runtime.startTurn(turnInput(3, "Now a cow.")); + assert.notInclude(sentMessages[2]!, firstPrompt, "a finished turn carries nothing forward"); }).pipe( Effect.scoped, Effect.provide( diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index e5b77e1041d4a..f5fbd0630d478 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -946,6 +946,8 @@ interface ActiveCursorTurn { readonly assistant: ActiveCursorTextStream; assistantReply: CursorTransportFailure; readonly reasoning: ActiveCursorTextStream; + /** The user text this run sent, before runtime instructions; absent for /compress. */ + readonly prompt: string | undefined; interrupted: boolean; finalized: boolean; /** The agent's latest turn ended and no new work has started since. */ @@ -1004,6 +1006,11 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( readonly orchestration: boolean; readonly device: boolean; } | null>(null); + // Cursor drops a cancelled run from the conversation, so the next message carries its prompt. + const interruptedPrompt = yield* Ref.make<{ + readonly agent: string; + readonly text: string; + } | null>(null); const warnedUnsupportedSandbox = yield* Ref.make(false); const activeTurn = yield* Ref.make(null); const planIds = yield* Ref.make(new Map()); @@ -2192,6 +2199,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( threadDisposition, }, ); + if (input.status === "interrupted" && input.context.prompt !== undefined) { + yield* Ref.set(interruptedPrompt, { + agent: input.context.run.agentId, + text: input.context.prompt, + }); + } yield* Ref.update(activeTurn, (current) => current?.providerTurnId === input.context.providerTurnId ? null : current, ); @@ -2284,10 +2297,11 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( mcpServers: Record | undefined, hasDeviceTools: boolean, delivered: { readonly orchestration: boolean; readonly device: boolean }, + interrupted: string | undefined, ) { const rawText = turnInput.message.text; if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) { - return "/compress"; + return { message: "/compress", prompt: undefined }; } if (hasCursorSkillMention(rawText) && cursorSkillNames === undefined) { const skills = yield* discoverCursorSkills( @@ -2303,7 +2317,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( .map((skill) => skill.name), ); } - const userText = t3OrchestrationPrompt({ + const newText = t3OrchestrationPrompt({ prompt: yield* providerMessageTextWithAttachmentPaths({ text: cursorSkillNames === undefined @@ -2318,6 +2332,10 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( orchestrationDelivered: delivered.orchestration, deviceInstructionsDelivered: delivered.device, }); + const userText = + interrupted === undefined + ? newText + : `Your previous turn was interrupted and is missing from your history. Some of its work may already be done. Its request was:\n\n${interrupted}\n\nThe new message, which may continue or change that request:\n\n${newText}`; const images = yield* Effect.forEach( turnInput.message.attachments.filter(isProviderNativeImageAttachment), (attachment: ChatAttachment) => @@ -2354,12 +2372,10 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }); } const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`; - return images.length === 0 - ? text - : ({ - text, - images, - } satisfies SDKUserMessage); + return { + message: images.length === 0 ? text : ({ text, images } satisfies SDKUserMessage), + prompt: userText, + }; }); const startTurn = Effect.fn("CursorAdapterV2.startTurn")( @@ -2390,13 +2406,15 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( known?.agent === agent.nativeThreadId ? known : { orchestration: turnInput.runOrdinal > 1, device: false }; - const message = yield* resolveUserMessage( + const interrupted = yield* Ref.get(interruptedPrompt); + const { message, prompt } = yield* resolveUserMessage( turnInput, mcpServers, hasDeviceTools, delivered, + interrupted?.agent === agent.nativeThreadId ? interrupted.text : undefined, ); - const compacted = message === "/compress"; + const compacted = prompt === undefined; const pendingUpdates: Array = []; let context: ActiveCursorTurn | null = null; // Create-time and send-time mcpServers merge with settingSources (SDK >= 1.0.39). @@ -2424,6 +2442,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( orchestration: !compacted && (delivered.orchestration || mcpServers !== undefined), device: !compacted && hasDeviceTools, }); + if (!compacted) { + yield* Ref.set(interruptedPrompt, null); + } const startedAt = yield* DateTime.now; const completed = yield* Deferred.make(); const providerTurnId = idAllocator.derive.providerTurn({ @@ -2448,6 +2469,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( current: null, nextSegment: 0, }, + prompt, interrupted: false, finalized: false, turnEnded: false, From c5ecf6eed2ce271104eb408b57a389e2c3b7eb7f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 18:40:11 +0000 Subject: [PATCH 3/3] test(server): cursor steering replay carries the interrupted prompt The Cursor adapter now restates the interrupted request when a steer restarts the run, so the message_steering fixture's second run.start expects that framing ahead of the steer text. Co-Authored-By: Claude Opus 5.5 --- .../testkit/fixtures/message_steering/cursor_transcript.ndjson | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/cursor_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/cursor_transcript.ndjson index 47838ffc8e35e..fddf2c83c5f33 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/cursor_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/cursor_transcript.ndjson @@ -6,7 +6,7 @@ {"type":"emit_inbound","label":"thinking-delta","frame":{"type":"interaction.update","runId":"run-046488f0-f621-4d59-976c-c4c935ad1282","update":{"type":"thinking-delta","text":"The user requested the"}}} {"type":"expect_outbound","label":"run.cancel:1","frame":{"type":"run.cancel","runId":"run-046488f0-f621-4d59-976c-c4c935ad1282"}} {"type":"emit_inbound","label":"run.completed:1","frame":{"type":"run.completed","result":{"id":"run-046488f0-f621-4d59-976c-c4c935ad1282","requestId":"d3b97820-1cf3-448d-a4b1-5fa7edc21460","status":"cancelled","model":{"id":"composer-2.5"},"durationMs":3177}}} -{"type":"expect_outbound","label":"run.start:2","frame":{"type":"run.start","message":"Actually, respond with exactly: steering fixture observed","options":{"model":{"id":"composer-2.5"},"mode":"agent"}}} +{"type":"expect_outbound","label":"run.start:2","frame":{"type":"run.start","message":"Your previous turn was interrupted and is missing from your history. Some of its work may already be done. Its request was:\n\nRespond with exactly: steering fixture initial response\n\nThe new message, which may continue or change that request:\n\nActually, respond with exactly: steering fixture observed","options":{"model":{"id":"composer-2.5"},"mode":"agent"}}} {"type":"emit_inbound","label":"run.started:2","frame":{"type":"run.started","runId":"run-e3b66d77-b2d4-4791-be2b-1a35c8e997d1","agentId":"agent-575fd75e-3fd7-412e-9b74-36f3a5aa5371"}} {"type":"emit_inbound","label":"thinking-delta","frame":{"type":"interaction.update","runId":"run-e3b66d77-b2d4-4791-be2b-1a35c8e997d1","update":{"type":"thinking-delta","text":"The response will be"}}} {"type":"emit_inbound","label":"token-delta","frame":{"type":"interaction.update","runId":"run-e3b66d77-b2d4-4791-be2b-1a35c8e997d1","update":{"type":"token-delta","tokens":5}}}