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}}} diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index cc8afd31311bb..3f04ac7328323 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,151 @@ 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 runEnds: 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 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(ended).pipe( + Effect.map((status) => ({ + id: runId, + requestId: `native-request-${sentMessages.length}`, + status, + model: { id: "composer-2.5" }, + durationMs: 1, + })), + ), + cancel: Deferred.succeed(ended, "cancelled"), + }; + }), + }), + }), + 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", + ); + + 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( + 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; 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,