From a39c5a4b578529389e4fb4c267c37ffe2ae69a8d Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Thu, 29 Oct 2026 18:58:48 -0300 Subject: [PATCH 01/12] fix(provider-cursor): finalize turn when SDK wait hangs after turn-ended Cursor runs could stay running in the sidebar after the final assistant message because finalizeTurn only ran when run.wait() resolved. Schedule an idle finalize after turn-ended when no tools are active, and cancel it if new work arrives before the next event loop turn. Co-authored-by: Cursor --- .../src/server/adapter.test.ts | 9 + .../provider-cursor/src/server/adapter.ts | 217 ++++++++++-------- 2 files changed, 135 insertions(+), 91 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 5318e160f610c..8308339c59460 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -375,6 +375,15 @@ describe("CursorAdapterV2", () => { message: "Agent turn stopped after repeated resume attempts made no progress", }, }, + { + reason: "turn-ended when run.wait never resolves", + updates: [ + { type: "text-delta", text: "Done." }, + { type: "turn-ended" }, + ] satisfies ReadonlyArray, + wait: Effect.never, + failure: null, + }, { reason: "no answer from a resume after the turn ended that made no progress", updates: [{ type: "turn-ended" }] satisfies ReadonlyArray, diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index ab6afddb4b899..67c5f2f3d73c3 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -939,6 +939,8 @@ interface ActiveCursorTurn { finalized: boolean; /** The agent's latest turn ended and no new work has started since. */ turnEnded: boolean; + /** Bumps when new work starts so a pending idle finalize is ignored. */ + idleFinalizeGeneration: number; } interface CursorLiveAgent { @@ -1984,97 +1986,6 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( return candidates.filter((value): value is string => typeof value === "string").join(""); }; - const handleInteractionUpdate = Effect.fnUntraced(function* ( - context: ActiveCursorTurn, - update: InteractionUpdate, - ) { - if (context.finalized) { - return; - } - if (update.type === "turn-ended") { - context.turnEnded = true; - } else if ( - update.type === "step-started" || - update.type === "user-message-appended" || - update.type === "text-delta" || - update.type === "thinking-delta" || - update.type === "partial-tool-call" || - update.type === "tool-call-started" - ) { - context.turnEnded = false; - } - switch (update.type) { - case "text-delta": - // Classify each assistant message on its own, so earlier - // commentary does not hide a later standalone transport dump. - if (context.assistant.current === null) { - context.assistantReply = new CursorTransportFailure(); - } - context.assistantReply.push(update.text); - yield* completeReasoning(context); - yield* appendTextSegment({ - context, - stream: context.assistant, - kind: "assistant", - text: update.text, - }); - yield* emitAssistant(context, false); - return; - case "thinking-delta": - yield* completeAssistant(context); - yield* appendTextSegment({ - context, - stream: context.reasoning, - kind: "reasoning", - text: update.text, - }); - yield* emitReasoning(context, false); - return; - case "thinking-completed": - yield* completeReasoning(context); - return; - case "tool-call-started": - case "partial-tool-call": - case "tool-call-completed": - yield* completeAssistant(context); - yield* completeReasoning(context); - yield* handleToolUpdate(context, update); - return; - case "step-completed": - case "turn-ended": - yield* completeAssistant(context); - yield* completeReasoning(context); - return; - case "shell-output-delta": { - const shell = Array.from(context.tools.values()) - .toReversed() - .find((candidate) => candidate.toolCall.type === "shell"); - if (shell === undefined) { - return; - } - const chunk = shellOutputText(update.event); - if (chunk.length === 0) return; - // Persist output once; live hub deltas avoid quadratic event-store growth. - shell.streamedOutput = (shell.streamedOutput + chunk).slice( - -MAX_CURSOR_STREAMED_OUTPUT_CHARS, - ); - yield* emitProviderEvent({ - type: "command_output.delta", - driver: CursorAgentSdk.CURSOR_PROVIDER, - threadId: shell.target.threadId, - itemId: idAllocator.derive.turnItemFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: shell.callId, - }), - chunk, - }); - return; - } - default: - return; - } - }); - const providerTurnPayload = (input: { readonly context: ActiveCursorTurn; readonly status: OrchestrationV2ProviderTurn["status"]; @@ -2187,6 +2098,129 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( yield* Deferred.succeed(input.context.completed, undefined); }); + const cursorTurnHasActiveWork = (context: ActiveCursorTurn) => + context.tools.size > 0 || + Array.from(context.subagents.values()).some((subagent) => + isOrchestrationV2WorkActive(subagent.task.status), + ); + + const scheduleIdleTurnFinalize = Effect.fnUntraced(function* (context: ActiveCursorTurn) { + if (context.finalized || !context.turnEnded || cursorTurnHasActiveWork(context)) { + return; + } + const generation = ++context.idleFinalizeGeneration; + yield* Effect.forkIn( + Effect.gen(function* () { + yield* Effect.yieldNow; + if ( + context.finalized || + context.idleFinalizeGeneration !== generation || + !context.turnEnded || + cursorTurnHasActiveWork(context) + ) { + return; + } + yield* finalizeTurn({ context, status: "completed" }); + }), + sessionScope, + ); + }); + + const handleInteractionUpdate = Effect.fnUntraced(function* ( + context: ActiveCursorTurn, + update: InteractionUpdate, + ) { + if (context.finalized) { + return; + } + if (update.type === "turn-ended") { + context.turnEnded = true; + } else if ( + update.type === "step-started" || + update.type === "user-message-appended" || + update.type === "text-delta" || + update.type === "thinking-delta" || + update.type === "partial-tool-call" || + update.type === "tool-call-started" + ) { + context.turnEnded = false; + context.idleFinalizeGeneration += 1; + } + switch (update.type) { + case "text-delta": + // Classify each assistant message on its own, so earlier + // commentary does not hide a later standalone transport dump. + if (context.assistant.current === null) { + context.assistantReply = new CursorTransportFailure(); + } + context.assistantReply.push(update.text); + yield* completeReasoning(context); + yield* appendTextSegment({ + context, + stream: context.assistant, + kind: "assistant", + text: update.text, + }); + yield* emitAssistant(context, false); + return; + case "thinking-delta": + yield* completeAssistant(context); + yield* appendTextSegment({ + context, + stream: context.reasoning, + kind: "reasoning", + text: update.text, + }); + yield* emitReasoning(context, false); + return; + case "thinking-completed": + yield* completeReasoning(context); + return; + case "tool-call-started": + case "partial-tool-call": + case "tool-call-completed": + yield* completeAssistant(context); + yield* completeReasoning(context); + yield* handleToolUpdate(context, update); + return; + case "step-completed": + case "turn-ended": + yield* completeAssistant(context); + yield* completeReasoning(context); + if (update.type === "turn-ended") { + yield* scheduleIdleTurnFinalize(context); + } + return; + case "shell-output-delta": { + const shell = Array.from(context.tools.values()) + .toReversed() + .find((candidate) => candidate.toolCall.type === "shell"); + if (shell === undefined) { + return; + } + const chunk = shellOutputText(update.event); + if (chunk.length === 0) return; + // Persist output once; live hub deltas avoid quadratic event-store growth. + shell.streamedOutput = (shell.streamedOutput + chunk).slice( + -MAX_CURSOR_STREAMED_OUTPUT_CHARS, + ); + yield* emitProviderEvent({ + type: "command_output.delta", + driver: CursorAgentSdk.CURSOR_PROVIDER, + threadId: shell.target.threadId, + itemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: shell.callId, + }), + chunk, + }); + return; + } + default: + return; + } + }); + const isResumeFailureAfterTurnEnded = (context: ActiveCursorTurn, result: RunResult) => context.turnEnded && result.status === "error" && @@ -2438,6 +2472,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( interrupted: false, finalized: false, turnEnded: false, + idleFinalizeGeneration: 0, }; yield* Ref.set(activeTurn, context); yield* emitProviderEvent({ From f2a271c0e07787e6a835bd97c6df1824a89bf8f6 Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Thu, 29 Oct 2026 19:09:22 -0300 Subject: [PATCH 02/12] fix(provider-cursor): defer idle finalize until wait settles when needed Idle turn finalization now waits for sdkRun.wait when no assistant segments were streamed, applies transport failure classification, and signals wait settlement so hung-wait fixes do not drop RunResult fallbacks or mark transport errors as success. Co-authored-by: Cursor --- .../src/server/adapter.test.ts | 9 ++++++ .../provider-cursor/src/server/adapter.ts | 31 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 8308339c59460..c6a29740071fe 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -384,6 +384,15 @@ describe("CursorAdapterV2", () => { wait: Effect.never, failure: null, }, + { + reason: "a transport failure when run.wait never resolves after turn-ended", + updates: [ + { type: "text-delta", text: "Error: RetriableError: WritableIterable is closed" }, + { type: "turn-ended" }, + ] satisfies ReadonlyArray, + wait: Effect.never, + failure: { class: "transport_error", message: "Provider turn failed." }, + }, { reason: "no answer from a resume after the turn ended that made no progress", updates: [{ type: "turn-ended" }] satisfies ReadonlyArray, diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 67c5f2f3d73c3..9038ad8040192 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -941,6 +941,8 @@ interface ActiveCursorTurn { turnEnded: boolean; /** Bumps when new work starts so a pending idle finalize is ignored. */ idleFinalizeGeneration: number; + /** Set when `sdkRun.wait` has finished (success, failure, or interrupt). */ + readonly runWaitSettled: Deferred.Deferred; } interface CursorLiveAgent { @@ -2120,7 +2122,31 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ) { return; } - yield* finalizeTurn({ context, status: "completed" }); + if (context.assistant.nextSegment === 0) { + yield* Deferred.await(context.runWaitSettled); + if ( + context.finalized || + context.idleFinalizeGeneration !== generation || + !context.turnEnded || + cursorTurnHasActiveWork(context) + ) { + return; + } + } + const transportFailure = context.assistantReply.failure; + const status = transportFailure === undefined ? "completed" : "failed"; + yield* finalizeTurn({ + context, + status, + ...(status === "failed" + ? { + failure: makeProviderFailure({ + cause: transportFailure, + class: "transport_error", + }), + } + : {}), + }); }), sessionScope, ); @@ -2447,6 +2473,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }); const startedAt = yield* DateTime.now; const completed = yield* Deferred.make(); + const runWaitSettled = yield* Deferred.make(); const providerTurnId = idAllocator.derive.providerTurn({ driver: CursorAgentSdk.CURSOR_PROVIDER, nativeTurnId: sdkRun.runId, @@ -2473,6 +2500,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( finalized: false, turnEnded: false, idleFinalizeGeneration: 0, + runWaitSettled, }; yield* Ref.set(activeTurn, context); yield* emitProviderEvent({ @@ -2585,6 +2613,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }); }), ), + Effect.ensuring(Deferred.succeed(runWaitSettled, undefined)), Effect.forkIn(sessionScope), ); }, From 605b8604a22a0723049b4dbb79e5a03ac2661c56 Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Thu, 29 Oct 2026 20:40:21 -0300 Subject: [PATCH 03/12] fix(provider-cursor): cancel hung run.wait and finalize idle turn Co-Authored-By: Claude Sonnet 5.5 --- .../src/server/adapter.test.ts | 157 ++++++++++++++++++ .../provider-cursor/src/server/adapter.ts | 41 +++-- 2 files changed, 187 insertions(+), 11 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index c6a29740071fe..d0a7058d96f04 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -13,6 +13,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"; @@ -384,6 +385,37 @@ describe("CursorAdapterV2", () => { wait: Effect.never, failure: null, }, + { + reason: "turn-ended while a tool is still running until tool-call-completed", + updates: [ + { type: "text-delta", text: "Checking." }, + { + type: "tool-call-started", + modelCallId: "native-model-call", + callId: "read-file", + toolCall: { + type: "read", + args: { path: "src/env.ts" }, + }, + }, + { type: "turn-ended" }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "read-file", + toolCall: { + type: "read", + args: { path: "src/env.ts" }, + result: { + status: "success", + value: { fileSize: 12, content: "ok", totalLines: 1 }, + }, + }, + }, + ] satisfies ReadonlyArray, + wait: Effect.never, + failure: null, + }, { reason: "a transport failure when run.wait never resolves after turn-ended", updates: [ @@ -526,6 +558,131 @@ describe("CursorAdapterV2", () => { ), ); + it.effect( + "cancels a hung run.wait after turn-ended when assistant text was already streamed", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "cursor-v2-idle-cancel-", + }); + const instanceId = ProviderInstanceId.make("cursor"); + const threadId = ThreadId.make("cursor-idle-cancel-thread"); + const modelSelection = { instanceId, model: "claude-fable-5" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + let cancelCalls = 0; + const cancelGate = yield* Deferred.make(); + const adapter = yield* makeCursorAdapterV2({ + instanceId, + environment: { HOME: workspace }, + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + assertComplete: Effect.void, + open: () => + Effect.succeed({ + agentId: "native-cursor-idle-cancel", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.gen(function* () { + yield* input.onDelta!({ type: "text-delta", text: "Done." }).pipe(Effect.orDie); + yield* input.onDelta!({ type: "turn-ended" }).pipe(Effect.orDie); + return { + agentId: "native-cursor-idle-cancel", + runId: "native-cursor-run", + wait: Deferred.await(cancelGate).pipe( + Effect.flatMap(() => + Effect.succeed({ + id: "native-cursor-run", + requestId: "native-request", + status: "cancelled" as const, + model: { id: "claude-fable-5" }, + durationMs: 1, + }), + ), + ), + cancel: Effect.gen(function* () { + cancelCalls += 1; + yield* Deferred.succeed(cancelGate, undefined); + }), + }; + }), + }), + }), + Effect.provide(TestProviderHost.layer({ cwd: workspace })), + ); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-idle-cancel-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn({ + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make("cursor-idle-cancel-run"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("cursor-idle-cancel-attempt"), + rootNodeId: NodeId.make("cursor-idle-cancel-root"), + appThread: { + id: threadId, + projectId: ProjectId.make("cursor-idle-cancel-project"), + createdBy: "user", + creationSource: "web", + title: "Cursor idle cancel", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + 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, + }, + message: { + messageId: MessageId.make("cursor-idle-cancel-message"), + createdBy: "user", + creationSource: "web", + text: "hi", + attachments: [], + }, + }); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const terminal = events.find((event) => event.type === "turn.terminal"); + assert.equal(terminal?.status, "completed"); + assert.equal(cancelCalls, 1); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), + ); + it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => 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 9038ad8040192..886da1ed27c84 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2106,6 +2106,16 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( isOrchestrationV2WorkActive(subagent.task.status), ); + const awaitCursorRunReleaseBeforeIdleFinalize = Effect.fnUntraced(function* ( + context: ActiveCursorTurn, + ) { + if (context.assistant.nextSegment === 0) { + yield* Deferred.await(context.runWaitSettled); + return; + } + yield* context.run.cancel.pipe(Effect.ignore); + }); + const scheduleIdleTurnFinalize = Effect.fnUntraced(function* (context: ActiveCursorTurn) { if (context.finalized || !context.turnEnded || cursorTurnHasActiveWork(context)) { return; @@ -2116,22 +2126,22 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( yield* Effect.yieldNow; if ( context.finalized || + context.interrupted || context.idleFinalizeGeneration !== generation || !context.turnEnded || cursorTurnHasActiveWork(context) ) { return; } - if (context.assistant.nextSegment === 0) { - yield* Deferred.await(context.runWaitSettled); - if ( - context.finalized || - context.idleFinalizeGeneration !== generation || - !context.turnEnded || - cursorTurnHasActiveWork(context) - ) { - return; - } + yield* awaitCursorRunReleaseBeforeIdleFinalize(context); + if ( + context.finalized || + context.interrupted || + context.idleFinalizeGeneration !== generation || + !context.turnEnded || + cursorTurnHasActiveWork(context) + ) { + return; } const transportFailure = context.assistantReply.failure; const status = transportFailure === undefined ? "completed" : "failed"; @@ -2208,6 +2218,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( yield* completeAssistant(context); yield* completeReasoning(context); yield* handleToolUpdate(context, update); + if (update.type === "tool-call-completed") { + yield* scheduleIdleTurnFinalize(context); + } return; case "step-completed": case "turn-ended": @@ -2252,6 +2265,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( result.status === "error" && result.error?.message === CURSOR_RESUME_NO_PROGRESS_MESSAGE; + const shouldDeferWaitFinalizeToIdlePath = (context: ActiveCursorTurn, result: RunResult) => + !context.interrupted && + context.turnEnded && + !cursorTurnHasActiveWork(context) && + result.status === "cancelled"; + const terminalStatus = ( context: ActiveCursorTurn, result: RunResult, @@ -2555,7 +2574,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }, ); } - if (context !== null) { + if (context !== null && !shouldDeferWaitFinalizeToIdlePath(context, result)) { const transportFailure = context.assistantReply.failure; const status = transportFailure === undefined ? terminalStatus(context, result) : "failed"; From 5cd229b0f82260192ba8dccee412552c1d2d109f Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Thu, 29 Oct 2026 20:57:15 -0300 Subject: [PATCH 04/12] fix(provider-cursor): bound idle cancel and keep ownership when it fails Co-Authored-By: Claude Sonnet 5.5 --- packages/provider-cursor/src/server/adapter.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 886da1ed27c84..94b2610826be2 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2111,9 +2111,19 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ) { if (context.assistant.nextSegment === 0) { yield* Deferred.await(context.runWaitSettled); - return; + return true; } - yield* context.run.cancel.pipe(Effect.ignore); + // Without a confirmed cancel the run may still own the agent, so the wait path finalizes. + return yield* context.run.cancel.pipe( + Effect.timeoutOption("10 seconds"), + Effect.map(Option.isSome), + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.cursor-idle-cancel-failed", { + providerTurnId: context.providerTurnId, + cause, + }).pipe(Effect.as(false)), + ), + ); }); const scheduleIdleTurnFinalize = Effect.fnUntraced(function* (context: ActiveCursorTurn) { @@ -2133,8 +2143,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ) { return; } - yield* awaitCursorRunReleaseBeforeIdleFinalize(context); + const released = yield* awaitCursorRunReleaseBeforeIdleFinalize(context); if ( + !released || context.finalized || context.interrupted || context.idleFinalizeGeneration !== generation || From 286e5c0849f670c58caeaa3f235db6cb1c602eb5 Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Thu, 29 Oct 2026 20:59:51 -0300 Subject: [PATCH 05/12] fix(provider-cursor): log idle cancel timeout Co-Authored-By: Claude Sonnet 5.5 --- packages/provider-cursor/src/server/adapter.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 94b2610826be2..263c46c2b5f6f 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2116,7 +2116,13 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( // Without a confirmed cancel the run may still own the agent, so the wait path finalizes. return yield* context.run.cancel.pipe( Effect.timeoutOption("10 seconds"), - Effect.map(Option.isSome), + Effect.flatMap((cancelled) => + Option.isSome(cancelled) + ? Effect.succeed(true) + : Effect.logWarning("orchestration-v2.cursor-idle-cancel-timed-out", { + providerTurnId: context.providerTurnId, + }).pipe(Effect.as(false)), + ), Effect.catch((cause) => Effect.logWarning("orchestration-v2.cursor-idle-cancel-failed", { providerTurnId: context.providerTurnId, From 7984847a5aa86e073d0fa0e65c98c3979f320aad Mon Sep 17 00:00:00 2001 From: Cainan Travizanutto E Silva Date: Thu, 29 Oct 2026 21:22:27 -0300 Subject: [PATCH 06/12] fix(provider-cursor): finalize on cancelled wait after idle cancel fails Co-Authored-By: Claude Sonnet 5.5 --- packages/provider-cursor/src/server/adapter.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 263c46c2b5f6f..52eb108b7270d 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -941,6 +941,7 @@ interface ActiveCursorTurn { turnEnded: boolean; /** Bumps when new work starts so a pending idle finalize is ignored. */ idleFinalizeGeneration: number; + idleCancelFailed: boolean; /** Set when `sdkRun.wait` has finished (success, failure, or interrupt). */ readonly runWaitSettled: Deferred.Deferred; } @@ -2149,7 +2150,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ) { return; } - const released = yield* awaitCursorRunReleaseBeforeIdleFinalize(context); + let released = yield* awaitCursorRunReleaseBeforeIdleFinalize(context); + if (!released) { + // A cancelled wait that settled before the cancel failed deferred to this path. + context.idleCancelFailed = true; + released = yield* Deferred.isDone(context.runWaitSettled); + } if ( !released || context.finalized || @@ -2284,6 +2290,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( const shouldDeferWaitFinalizeToIdlePath = (context: ActiveCursorTurn, result: RunResult) => !context.interrupted && + !context.idleCancelFailed && context.turnEnded && !cursorTurnHasActiveWork(context) && result.status === "cancelled"; @@ -2536,6 +2543,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( finalized: false, turnEnded: false, idleFinalizeGeneration: 0, + idleCancelFailed: false, runWaitSettled, }; yield* Ref.set(activeTurn, context); From 5259f7c37bfc1ab4730371e3178bca2a38c99d2e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 08:20:52 +0000 Subject: [PATCH 07/12] fix(provider-cursor): preserve cancellation outcomes during idle finalization --- .../src/server/adapter.test.ts | 244 +++++++-------- .../provider-cursor/src/server/adapter.ts | 289 +++++++++--------- 2 files changed, 254 insertions(+), 279 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index d0a7058d96f04..919fc24529bd4 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -20,6 +20,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; @@ -378,6 +379,7 @@ describe("CursorAdapterV2", () => { }, { reason: "turn-ended when run.wait never resolves", + cancelCalls: 1, updates: [ { type: "text-delta", text: "Done." }, { type: "turn-ended" }, @@ -425,6 +427,63 @@ describe("CursorAdapterV2", () => { wait: Effect.never, failure: { class: "transport_error", message: "Provider turn failed." }, }, + { + reason: "the SDK fallback answer after turn-ended", + updates: [{ type: "turn-ended" }] satisfies ReadonlyArray, + wait: Effect.succeed({ + id: "native-cursor-run", + status: "finished" as const, + result: "The answer.", + }), + failure: null, + answers: ["The answer."], + cancelCalls: 0, + }, + { + reason: "a cancelled wait without streamed text", + updates: [{ type: "turn-ended" }] satisfies ReadonlyArray, + wait: Effect.succeed({ id: "native-cursor-run", status: "cancelled" as const }), + failure: null, + status: "cancelled", + cancelCalls: 0, + }, + { + reason: "a cancelled wait released by idle cancellation", + updates: [ + { type: "text-delta", text: "Done." }, + { type: "turn-ended" }, + ] satisfies ReadonlyArray, + wait: null, + failure: null, + cancelCalls: 1, + }, + { + reason: "a delayed continuation after turn-ended", + updates: [ + { type: "text-delta", text: "Checking." }, + { type: "turn-ended" }, + ] satisfies ReadonlyArray, + continuation: [ + { type: "text-delta", text: "Done." }, + { type: "turn-ended" }, + ] satisfies ReadonlyArray, + wait: null, + failure: null, + cancelCalls: 1, + answers: ["Checking.", "Done."], + }, + ...(["before-wait", "after-wait", "timeout"] as const).map((cancelOutcome) => ({ + reason: "a cancelled wait when idle cancel fails " + cancelOutcome, + updates: [ + { type: "text-delta", text: "Done." }, + { type: "turn-ended" }, + ] satisfies ReadonlyArray, + wait: null, + failure: null, + status: "cancelled", + cancelCalls: 1, + cancelOutcome, + })), { reason: "no answer from a resume after the turn ended that made no progress", updates: [{ type: "turn-ended" }] satisfies ReadonlyArray, @@ -440,10 +499,16 @@ describe("CursorAdapterV2", () => { failure: null, answers: [], }, - ])("ends the turn with $reason", ({ updates = [], wait, failure, answers }) => + ])("ends the turn with $reason", (testCase) => Effect.gen(function* () { + const { updates = [], wait, failure, status, cancelCalls } = testCase; + const answers = "answers" in testCase ? testCase.answers : undefined; + const continuation = "continuation" in testCase ? testCase.continuation : undefined; + const cancelOutcome = "cancelOutcome" in testCase ? testCase.cancelOutcome : undefined; const fileSystem = yield* FileSystem.FileSystem; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-failure-" }); + const workspace = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "cursor-v2-failure-", + }); const instanceId = ProviderInstanceId.make("cursor"); const threadId = ThreadId.make("cursor-failure-thread"); const modelSelection = { instanceId, model: "claude-fable-5" }; @@ -452,6 +517,10 @@ describe("CursorAdapterV2", () => { interactionMode: "default", cwd: workspace, }); + const cancelled = yield* Deferred.make(); + const waitObserved = yield* Deferred.make(); + let observedCancelCalls = 0; + let onDelta: ((update: InteractionUpdate) => Effect.Effect) | undefined; const adapter = yield* makeCursorAdapterV2({ instanceId, environment: { HOME: workspace }, @@ -465,14 +534,38 @@ describe("CursorAdapterV2", () => { close: Effect.void, send: (input) => Effect.gen(function* () { + onDelta = (update) => input.onDelta!(update).pipe(Effect.orDie); for (const update of updates) { yield* input.onDelta!(update).pipe(Effect.orDie); } return { agentId: "native-cursor-failure", runId: "native-cursor-run", - wait, - cancel: Effect.void, + wait: + wait ?? + Deferred.await(cancelled).pipe( + Effect.andThen(Deferred.succeed(waitObserved, undefined)), + Effect.as({ + id: "native-cursor-run", + status: "cancelled" as const, + }), + ), + cancel: Effect.gen(function* () { + observedCancelCalls += 1; + if (cancelOutcome !== "before-wait") { + yield* Deferred.succeed(cancelled, undefined); + } + if (cancelOutcome === "after-wait" || cancelOutcome === "timeout") { + yield* Deferred.await(waitObserved); + } + if (cancelOutcome === "timeout") return yield* Effect.never; + if (cancelOutcome !== undefined) { + return yield* new CursorAgentSdk.CursorAgentSdkRunnerError({ + method: "run.cancel", + cause: new Error("cancel failed"), + }); + } + }), }; }), }), @@ -532,18 +625,32 @@ describe("CursorAdapterV2", () => { attachments: [], }, }); + if (continuation !== undefined) { + yield* TestClock.adjust("500 millis"); + assert.equal(observedCancelCalls, 0); + for (const update of continuation) { + yield* onDelta!(update); + } + } + yield* TestClock.adjust("1 second"); + if (cancelOutcome === "before-wait") yield* Deferred.succeed(cancelled, undefined); + if (cancelOutcome === "timeout") yield* TestClock.adjust("10 seconds"); const events = yield* runtime.events.pipe( Stream.takeUntil((event) => event.type === "turn.terminal"), Stream.runCollect, ); const terminal = events.find((event) => event.type === "turn.terminal"); - assert.equal(terminal?.status, failure === null ? "completed" : "failed"); + assert.equal(terminal?.status, status ?? (failure === null ? "completed" : "failed")); + if (cancelCalls !== undefined) assert.equal(observedCancelCalls, cancelCalls); + if (cancelOutcome !== undefined) assert.equal(terminal?.threadDisposition, "broken"); assert.equal(terminal?.failure?.class, failure?.class); assert.equal(terminal?.failure?.message, failure?.message); if (answers !== undefined) { assert.deepEqual( events.flatMap((event) => - event.type === "message.updated" && event.message.role === "assistant" + event.type === "message.updated" && + event.message.role === "assistant" && + !event.message.streaming ? [event.message.text] : [], ), @@ -558,131 +665,6 @@ describe("CursorAdapterV2", () => { ), ); - it.effect( - "cancels a hung run.wait after turn-ended when assistant text was already streamed", - () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "cursor-v2-idle-cancel-", - }); - const instanceId = ProviderInstanceId.make("cursor"); - const threadId = ThreadId.make("cursor-idle-cancel-thread"); - const modelSelection = { instanceId, model: "claude-fable-5" }; - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: workspace, - }); - let cancelCalls = 0; - const cancelGate = yield* Deferred.make(); - const adapter = yield* makeCursorAdapterV2({ - instanceId, - environment: { HOME: workspace }, - }).pipe( - Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { - assertComplete: Effect.void, - open: () => - Effect.succeed({ - agentId: "native-cursor-idle-cancel", - listMessages: Effect.succeed([]), - close: Effect.void, - send: (input) => - Effect.gen(function* () { - yield* input.onDelta!({ type: "text-delta", text: "Done." }).pipe(Effect.orDie); - yield* input.onDelta!({ type: "turn-ended" }).pipe(Effect.orDie); - return { - agentId: "native-cursor-idle-cancel", - runId: "native-cursor-run", - wait: Deferred.await(cancelGate).pipe( - Effect.flatMap(() => - Effect.succeed({ - id: "native-cursor-run", - requestId: "native-request", - status: "cancelled" as const, - model: { id: "claude-fable-5" }, - durationMs: 1, - }), - ), - ), - cancel: Effect.gen(function* () { - cancelCalls += 1; - yield* Deferred.succeed(cancelGate, undefined); - }), - }; - }), - }), - }), - Effect.provide(TestProviderHost.layer({ cwd: workspace })), - ); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("cursor-idle-cancel-session"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn({ - threadId, - providerThread, - modelSelection, - runtimePolicy, - runId: RunId.make("cursor-idle-cancel-run"), - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: RunAttemptId.make("cursor-idle-cancel-attempt"), - rootNodeId: NodeId.make("cursor-idle-cancel-root"), - appThread: { - id: threadId, - projectId: ProjectId.make("cursor-idle-cancel-project"), - createdBy: "user", - creationSource: "web", - title: "Cursor idle cancel", - providerInstanceId: instanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - 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, - }, - message: { - messageId: MessageId.make("cursor-idle-cancel-message"), - createdBy: "user", - creationSource: "web", - text: "hi", - attachments: [], - }, - }); - const events = yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - ); - const terminal = events.find((event) => event.type === "turn.terminal"); - assert.equal(terminal?.status, "completed"); - assert.equal(cancelCalls, 1); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), - ), - ), - ); - it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => 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 52eb108b7270d..bf18aecc2828a 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -941,9 +941,7 @@ interface ActiveCursorTurn { turnEnded: boolean; /** Bumps when new work starts so a pending idle finalize is ignored. */ idleFinalizeGeneration: number; - idleCancelFailed: boolean; - /** Set when `sdkRun.wait` has finished (success, failure, or interrupt). */ - readonly runWaitSettled: Deferred.Deferred; + idleCancelResult: Deferred.Deferred | null; } interface CursorLiveAgent { @@ -1989,6 +1987,104 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( return candidates.filter((value): value is string => typeof value === "string").join(""); }; + const handleInteractionUpdate = Effect.fnUntraced(function* ( + context: ActiveCursorTurn, + update: InteractionUpdate, + ) { + if (context.finalized) { + return; + } + if (update.type === "turn-ended") { + context.turnEnded = true; + } else if ( + update.type === "step-started" || + update.type === "user-message-appended" || + update.type === "text-delta" || + update.type === "thinking-delta" || + update.type === "partial-tool-call" || + update.type === "tool-call-started" + ) { + context.turnEnded = false; + context.idleFinalizeGeneration += 1; + } + switch (update.type) { + case "text-delta": + // Classify each assistant message on its own, so earlier + // commentary does not hide a later standalone transport dump. + if (context.assistant.current === null) { + context.assistantReply = new CursorTransportFailure(); + } + context.assistantReply.push(update.text); + yield* completeReasoning(context); + yield* appendTextSegment({ + context, + stream: context.assistant, + kind: "assistant", + text: update.text, + }); + yield* emitAssistant(context, false); + return; + case "thinking-delta": + yield* completeAssistant(context); + yield* appendTextSegment({ + context, + stream: context.reasoning, + kind: "reasoning", + text: update.text, + }); + yield* emitReasoning(context, false); + return; + case "thinking-completed": + yield* completeReasoning(context); + return; + case "tool-call-started": + case "partial-tool-call": + case "tool-call-completed": + yield* completeAssistant(context); + yield* completeReasoning(context); + yield* handleToolUpdate(context, update); + if (update.type === "tool-call-completed") { + yield* scheduleIdleTurnFinalize(context); + } + return; + case "step-completed": + case "turn-ended": + yield* completeAssistant(context); + yield* completeReasoning(context); + if (update.type === "turn-ended") { + yield* scheduleIdleTurnFinalize(context); + } + return; + case "shell-output-delta": { + const shell = Array.from(context.tools.values()) + .toReversed() + .find((candidate) => candidate.toolCall.type === "shell"); + if (shell === undefined) { + return; + } + const chunk = shellOutputText(update.event); + if (chunk.length === 0) return; + // Persist output once; live hub deltas avoid quadratic event-store growth. + shell.streamedOutput = (shell.streamedOutput + chunk).slice( + -MAX_CURSOR_STREAMED_OUTPUT_CHARS, + ); + yield* emitProviderEvent({ + type: "command_output.delta", + driver: CursorAgentSdk.CURSOR_PROVIDER, + threadId: shell.target.threadId, + itemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: shell.callId, + }), + chunk, + }); + return; + } + default: + return; + } + }); + const providerTurnPayload = (input: { readonly context: ActiveCursorTurn; readonly status: OrchestrationV2ProviderTurn["status"]; @@ -2107,40 +2203,20 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( isOrchestrationV2WorkActive(subagent.task.status), ); - const awaitCursorRunReleaseBeforeIdleFinalize = Effect.fnUntraced(function* ( - context: ActiveCursorTurn, - ) { - if (context.assistant.nextSegment === 0) { - yield* Deferred.await(context.runWaitSettled); - return true; - } - // Without a confirmed cancel the run may still own the agent, so the wait path finalizes. - return yield* context.run.cancel.pipe( - Effect.timeoutOption("10 seconds"), - Effect.flatMap((cancelled) => - Option.isSome(cancelled) - ? Effect.succeed(true) - : Effect.logWarning("orchestration-v2.cursor-idle-cancel-timed-out", { - providerTurnId: context.providerTurnId, - }).pipe(Effect.as(false)), - ), - Effect.catch((cause) => - Effect.logWarning("orchestration-v2.cursor-idle-cancel-failed", { - providerTurnId: context.providerTurnId, - cause, - }).pipe(Effect.as(false)), - ), - ); - }); - const scheduleIdleTurnFinalize = Effect.fnUntraced(function* (context: ActiveCursorTurn) { - if (context.finalized || !context.turnEnded || cursorTurnHasActiveWork(context)) { + if ( + context.finalized || + !context.turnEnded || + context.assistant.nextSegment === 0 || + context.idleCancelResult !== null || + cursorTurnHasActiveWork(context) + ) { return; } const generation = ++context.idleFinalizeGeneration; yield* Effect.forkIn( Effect.gen(function* () { - yield* Effect.yieldNow; + yield* Effect.sleep("1 second"); if ( context.finalized || context.interrupted || @@ -2150,12 +2226,21 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ) { return; } - let released = yield* awaitCursorRunReleaseBeforeIdleFinalize(context); - if (!released) { - // A cancelled wait that settled before the cancel failed deferred to this path. - context.idleCancelFailed = true; - released = yield* Deferred.isDone(context.runWaitSettled); - } + if (context.idleCancelResult !== null) return; + const cancelResult = Deferred.makeUnsafe(); + context.idleCancelResult = cancelResult; + // Keep ownership if cancellation fails; a terminal wait still reports its own outcome. + const released = yield* context.run.cancel.pipe( + Effect.timeout("10 seconds"), + Effect.as(true), + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.cursor-idle-cancel-failed", { + providerTurnId: context.providerTurnId, + cause, + }).pipe(Effect.as(false)), + ), + ); + yield* Deferred.succeed(cancelResult, released); if ( !released || context.finalized || @@ -2185,116 +2270,11 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ); }); - const handleInteractionUpdate = Effect.fnUntraced(function* ( - context: ActiveCursorTurn, - update: InteractionUpdate, - ) { - if (context.finalized) { - return; - } - if (update.type === "turn-ended") { - context.turnEnded = true; - } else if ( - update.type === "step-started" || - update.type === "user-message-appended" || - update.type === "text-delta" || - update.type === "thinking-delta" || - update.type === "partial-tool-call" || - update.type === "tool-call-started" - ) { - context.turnEnded = false; - context.idleFinalizeGeneration += 1; - } - switch (update.type) { - case "text-delta": - // Classify each assistant message on its own, so earlier - // commentary does not hide a later standalone transport dump. - if (context.assistant.current === null) { - context.assistantReply = new CursorTransportFailure(); - } - context.assistantReply.push(update.text); - yield* completeReasoning(context); - yield* appendTextSegment({ - context, - stream: context.assistant, - kind: "assistant", - text: update.text, - }); - yield* emitAssistant(context, false); - return; - case "thinking-delta": - yield* completeAssistant(context); - yield* appendTextSegment({ - context, - stream: context.reasoning, - kind: "reasoning", - text: update.text, - }); - yield* emitReasoning(context, false); - return; - case "thinking-completed": - yield* completeReasoning(context); - return; - case "tool-call-started": - case "partial-tool-call": - case "tool-call-completed": - yield* completeAssistant(context); - yield* completeReasoning(context); - yield* handleToolUpdate(context, update); - if (update.type === "tool-call-completed") { - yield* scheduleIdleTurnFinalize(context); - } - return; - case "step-completed": - case "turn-ended": - yield* completeAssistant(context); - yield* completeReasoning(context); - if (update.type === "turn-ended") { - yield* scheduleIdleTurnFinalize(context); - } - return; - case "shell-output-delta": { - const shell = Array.from(context.tools.values()) - .toReversed() - .find((candidate) => candidate.toolCall.type === "shell"); - if (shell === undefined) { - return; - } - const chunk = shellOutputText(update.event); - if (chunk.length === 0) return; - // Persist output once; live hub deltas avoid quadratic event-store growth. - shell.streamedOutput = (shell.streamedOutput + chunk).slice( - -MAX_CURSOR_STREAMED_OUTPUT_CHARS, - ); - yield* emitProviderEvent({ - type: "command_output.delta", - driver: CursorAgentSdk.CURSOR_PROVIDER, - threadId: shell.target.threadId, - itemId: idAllocator.derive.turnItemFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: shell.callId, - }), - chunk, - }); - return; - } - default: - return; - } - }); - const isResumeFailureAfterTurnEnded = (context: ActiveCursorTurn, result: RunResult) => context.turnEnded && result.status === "error" && result.error?.message === CURSOR_RESUME_NO_PROGRESS_MESSAGE; - const shouldDeferWaitFinalizeToIdlePath = (context: ActiveCursorTurn, result: RunResult) => - !context.interrupted && - !context.idleCancelFailed && - context.turnEnded && - !cursorTurnHasActiveWork(context) && - result.status === "cancelled"; - const terminalStatus = ( context: ActiveCursorTurn, result: RunResult, @@ -2516,7 +2496,6 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }); const startedAt = yield* DateTime.now; const completed = yield* Deferred.make(); - const runWaitSettled = yield* Deferred.make(); const providerTurnId = idAllocator.derive.providerTurn({ driver: CursorAgentSdk.CURSOR_PROVIDER, nativeTurnId: sdkRun.runId, @@ -2543,8 +2522,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( finalized: false, turnEnded: false, idleFinalizeGeneration: 0, - idleCancelFailed: false, - runWaitSettled, + idleCancelResult: null, }; yield* Ref.set(activeTurn, context); yield* emitProviderEvent({ @@ -2599,13 +2577,29 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }, ); } - if (context !== null && !shouldDeferWaitFinalizeToIdlePath(context, result)) { + if (context !== null) { + const idleCancel = + result.status === "cancelled" && context.idleCancelResult !== null + ? yield* Deferred.await(context.idleCancelResult) + : false; const transportFailure = context.assistantReply.failure; const status = - transportFailure === undefined ? terminalStatus(context, result) : "failed"; + transportFailure !== undefined + ? "failed" + : idleCancel && + !context.interrupted && + context.turnEnded && + !cursorTurnHasActiveWork(context) + ? "completed" + : terminalStatus(context, result); yield* finalizeTurn({ context, status, + ...(result.status === "cancelled" && + context.idleCancelResult !== null && + !idleCancel + ? { threadDisposition: "broken" as const } + : {}), ...(status === "failed" ? { failure: @@ -2657,7 +2651,6 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }); }), ), - Effect.ensuring(Deferred.succeed(runWaitSettled, undefined)), Effect.forkIn(sessionScope), ); }, From 5f7c6e9e0972528be66adb8c3399533d4d5b8efb Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 08:27:52 +0000 Subject: [PATCH 08/12] fix(provider-cursor): retain finalization across cancellation callbacks --- .../src/server/CursorAgentSdk.test.ts | 40 ++++++++++++++ .../src/server/CursorAgentSdk.ts | 1 + .../src/server/adapter.test.ts | 54 ++++++++++++++++++- .../provider-cursor/src/server/adapter.ts | 27 +++++----- 4 files changed, 109 insertions(+), 13 deletions(-) diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts index d715766a309bc..f95dc434b1bed 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts @@ -2,6 +2,9 @@ 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 Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import * as Layer from "effect/Layer"; import { vi } from "vite-plus/test"; @@ -14,6 +17,7 @@ const cursorSdkMock = vi.hoisted(() => { const runOperations: Array = []; let agentCloseFailure: unknown; let staleRunActive = false; + let onDelta: ((input: { readonly update: unknown }) => Promise) | undefined; const runWait = vi.fn(async () => ({ id: "run-cursor-agent-sdk-test", requestId: "request-cursor-agent-sdk-test", @@ -34,6 +38,7 @@ const cursorSdkMock = vi.hoisted(() => { _message: unknown, options: { readonly onDelta?: (input: { readonly update: unknown }) => Promise }, ) => { + onDelta = options.onDelta; runOperations.push("run.start"); if (staleRunActive) { throw new Error("Agent agent-cursor-agent-sdk-test already has active run"); @@ -75,6 +80,7 @@ const cursorSdkMock = vi.hoisted(() => { return { agentClose, + emitDelta: (update: unknown) => onDelta?.({ update }), cancelRun, closeExecutionOrder, create, @@ -172,4 +178,38 @@ describe("CursorAgentSdkRunner", () => { ]); }).pipe(Effect.provide(layerTest)), ); + it.effect("drains queued callbacks before returning from cancellation", () => + Effect.gen(function* () { + const order: Array = []; + const callbackStarted = yield* Deferred.make(); + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const session = yield* runner.open({ + operation: "create", + options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } }, + threadId: ThreadId.make("thread-cursor-cancel-callback"), + providerSessionId: ProviderSessionId.make("session-cursor-cancel-callback"), + }); + const run = yield* session.send({ + message: "hi", + onDelta: (update) => + Effect.gen(function* () { + if (update.type !== "text-delta") return; + yield* Deferred.succeed(callbackStarted, undefined); + yield* Effect.sleep("1 second"); + order.push(update.text); + }), + }); + cursorSdkMock.runCancel.mockImplementationOnce(async () => { + void cursorSdkMock.emitDelta({ type: "text-delta", text: "callback" }); + }); + const cancelling = yield* run.cancel.pipe( + Effect.tap(() => Effect.sync(() => order.push("cancel returned"))), + Effect.forkScoped, + ); + yield* Deferred.await(callbackStarted); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(cancelling); + assert.deepStrictEqual(order, ["callback", "cancel returned"]); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); }); diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts index 5986d09506d09..44fe9a00388bf 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -563,6 +563,7 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu throw cause; } } + await callbackChain; }, catch: (cause) => runnerError(cause, "run.cancel"), }), diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 919fc24529bd4..bf0e351d06431 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -16,6 +16,7 @@ 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 Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -472,6 +473,34 @@ describe("CursorAdapterV2", () => { cancelCalls: 1, answers: ["Checking.", "Done."], }, + { + reason: "a continuation during idle cancellation with a hung wait", + updates: [ + { type: "text-delta", text: "Checking." }, + { type: "turn-ended" }, + ] satisfies ReadonlyArray, + duringCancel: [ + { type: "text-delta", text: "Done." }, + { type: "turn-ended" }, + ] satisfies ReadonlyArray, + wait: Effect.never, + failure: null, + cancelCalls: 1, + answers: ["Checking.", "Done."], + }, + { + reason: "an interruption after idle cancellation timed out with a hung wait", + updates: [ + { type: "text-delta", text: "Done." }, + { type: "turn-ended" }, + ] satisfies ReadonlyArray, + wait: Effect.never, + failure: null, + status: "interrupted", + cancelOutcome: "timeout" as const, + interrupt: true, + cancelCalls: 2, + }, ...(["before-wait", "after-wait", "timeout"] as const).map((cancelOutcome) => ({ reason: "a cancelled wait when idle cancel fails " + cancelOutcome, updates: [ @@ -504,6 +533,8 @@ describe("CursorAdapterV2", () => { const { updates = [], wait, failure, status, cancelCalls } = testCase; const answers = "answers" in testCase ? testCase.answers : undefined; const continuation = "continuation" in testCase ? testCase.continuation : undefined; + const interrupt = "interrupt" in testCase && testCase.interrupt; + const duringCancel = "duringCancel" in testCase ? testCase.duringCancel : undefined; const cancelOutcome = "cancelOutcome" in testCase ? testCase.cancelOutcome : undefined; const fileSystem = yield* FileSystem.FileSystem; const workspace = yield* fileSystem.makeTempDirectoryScoped({ @@ -552,10 +583,16 @@ describe("CursorAdapterV2", () => { ), cancel: Effect.gen(function* () { observedCancelCalls += 1; + if (observedCancelCalls > 1) return; + for (const update of duringCancel ?? []) + yield* input.onDelta!(update).pipe(Effect.orDie); if (cancelOutcome !== "before-wait") { yield* Deferred.succeed(cancelled, undefined); } - if (cancelOutcome === "after-wait" || cancelOutcome === "timeout") { + if ( + cancelOutcome === "after-wait" || + (cancelOutcome === "timeout" && !interrupt) + ) { yield* Deferred.await(waitObserved); } if (cancelOutcome === "timeout") return yield* Effect.never; @@ -635,6 +672,21 @@ describe("CursorAdapterV2", () => { yield* TestClock.adjust("1 second"); if (cancelOutcome === "before-wait") yield* Deferred.succeed(cancelled, undefined); if (cancelOutcome === "timeout") yield* TestClock.adjust("10 seconds"); + if (interrupt) { + const started = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "provider_turn.updated"), + Stream.take(1), + Stream.runCollect, + ); + const interrupting = yield* runtime + .interruptTurn({ + providerThread, + providerTurnId: started[0]!.providerTurn.id, + }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("10 seconds"); + yield* Fiber.join(interrupting); + } const events = yield* runtime.events.pipe( Stream.takeUntil((event) => event.type === "turn.terminal"), Stream.runCollect, diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index bf18aecc2828a..4a1f1d2da2cd9 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2241,18 +2241,15 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( ), ); yield* Deferred.succeed(cancelResult, released); - if ( - !released || - context.finalized || - context.interrupted || - context.idleFinalizeGeneration !== generation || - !context.turnEnded || - cursorTurnHasActiveWork(context) - ) { - return; - } + if (!released || context.finalized) return; const transportFailure = context.assistantReply.failure; - const status = transportFailure === undefined ? "completed" : "failed"; + const status = context.interrupted + ? "interrupted" + : transportFailure !== undefined + ? "failed" + : context.turnEnded && !cursorTurnHasActiveWork(context) + ? "completed" + : "cancelled"; yield* finalizeTurn({ context, status, @@ -2793,7 +2790,13 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( providerThreadId: turnInput.providerThread.id, providerTurnId: turnInput.providerTurnId, }); - yield* finalizeTurn({ context, status: "interrupted" }); + yield* finalizeTurn({ + context, + status: "interrupted", + ...(context.idleCancelResult !== null + ? { threadDisposition: "broken" as const } + : {}), + }); }, (effect, turnInput) => effect.pipe( From 5d45fa3a8fff2c36b631a1dbe261a61e9921305b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:11:53 +0000 Subject: [PATCH 09/12] fix(provider-cursor): a live steer is not cancelled as an idle turn A steer delivered after turn-ended is new work, so it invalidates the pending idle finalize. A run already being cancelled for idleness declines steers so they become a follow-up turn instead of being lost. Drop a test case the fallback-answer case already covers. AI-assisted; verified with the tests and tools listed above. --- .../src/server/adapter.test.ts | 62 +++++++++---------- .../provider-cursor/src/server/adapter.ts | 5 +- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index bfa91bbeacd8c..d9e58d5f9c357 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -462,14 +462,6 @@ describe("CursorAdapterV2", () => { answers: ["The answer."], cancelCalls: 0, }, - { - reason: "a cancelled wait without streamed text", - updates: [{ type: "turn-ended" }] satisfies ReadonlyArray, - wait: Effect.succeed({ id: "native-cursor-run", status: "cancelled" as const }), - failure: null, - status: "cancelled", - cancelCalls: 0, - }, { reason: "a cancelled wait released by idle cancellation", updates: [ @@ -2307,30 +2299,36 @@ describe("CursorAdapterV2", () => { agentId: "native-cursor-steer", listMessages: Effect.succeed([]), close: Effect.void, - send: () => - Effect.succeed({ - agentId: "native-cursor-steer", - runId: "native-cursor-steer-run", - wait: Deferred.await(finished), - cancel: Effect.sync(() => { - cancelled += 1; - }).pipe( - Effect.andThen( - Deferred.succeed(finished, { - id: "native-cursor-steer-run", - requestId: "native-request", - status: "cancelled" as const, - model: { id: "composer-2.5" }, - durationMs: 1, - }), + send: (input) => + Effect.gen(function* () { + yield* input.onDelta!({ type: "text-delta", text: "Done." }); + yield* input.onDelta!({ type: "turn-ended" }); + }).pipe( + Effect.orDie, + Effect.as({ + agentId: "native-cursor-steer", + runId: "native-cursor-steer-run", + wait: Deferred.await(finished), + cancel: Effect.sync(() => { + cancelled += 1; + }).pipe( + Effect.andThen( + Deferred.succeed(finished, { + id: "native-cursor-steer-run", + requestId: "native-request", + status: "cancelled" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + }), + ), ), - ), - steer: (text) => - Effect.sync(() => { - steered.push(text); - return outcome; - }), - }), + steer: (text) => + Effect.sync(() => { + steered.push(text); + return outcome; + }), + }), + ), }), }), Effect.provide(TestProviderHost.layer({ cwd: workspace })), @@ -2411,6 +2409,8 @@ describe("CursorAdapterV2", () => { }, }); assert.deepEqual(steered, ["/review Look at the tests instead."]); + // The delivered steer is new work, so the ended turn is not cancelled as idle. + yield* TestClock.adjust("1 second"); assert.equal(cancelled, 0); yield* runtime.steerTurn({ threadId, diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index a2d1e9ec47d62..adcbb50f569e3 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2871,11 +2871,14 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( detail: "Cursor steer requires non-empty text.", }); } + // A run being cancelled for idleness cannot take more input. const outcome = - context.run.steer === undefined + context.run.steer === undefined || context.idleCancelResult !== null ? "revert_to_followup" : yield* context.run.steer(text); if (outcome === "complete_delivered") { + context.turnEnded = false; + context.idleFinalizeGeneration += 1; return; } if (context.interrupted) { From 043d50400dab4b43250eb1326b0088371d118ee4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 23:58:39 +0000 Subject: [PATCH 10/12] test(provider-cursor): trim idle-finalize cases to the ones that catch regressions Co-Authored-By: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 43 +++---------------- 1 file changed, 7 insertions(+), 36 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 3004b62e33d5e..32595ab4642a2 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -492,21 +492,6 @@ describe("CursorAdapterV2", () => { cancelCalls: 1, answers: ["Checking.", "Done."], }, - { - reason: "a continuation during idle cancellation with a hung wait", - updates: [ - { type: "text-delta", text: "Checking." }, - { type: "turn-ended" }, - ] satisfies ReadonlyArray, - duringCancel: [ - { type: "text-delta", text: "Done." }, - { type: "turn-ended" }, - ] satisfies ReadonlyArray, - wait: Effect.never, - failure: null, - cancelCalls: 1, - answers: ["Checking.", "Done."], - }, { reason: "an interruption after idle cancellation timed out with a hung wait", updates: [ @@ -520,8 +505,8 @@ describe("CursorAdapterV2", () => { interrupt: true, cancelCalls: 2, }, - ...(["before-wait", "after-wait", "timeout"] as const).map((cancelOutcome) => ({ - reason: "a cancelled wait when idle cancel fails " + cancelOutcome, + { + reason: "a cancelled wait when idle cancel then fails", updates: [ { type: "text-delta", text: "Done." }, { type: "turn-ended" }, @@ -530,8 +515,8 @@ describe("CursorAdapterV2", () => { failure: null, status: "cancelled", cancelCalls: 1, - cancelOutcome, - })), + cancelOutcome: "after-wait" as const, + }, { reason: "no answer from a resume after the turn ended that made no progress", updates: [{ type: "turn-ended" }] satisfies ReadonlyArray, @@ -553,12 +538,9 @@ describe("CursorAdapterV2", () => { const answers = "answers" in testCase ? testCase.answers : undefined; const continuation = "continuation" in testCase ? testCase.continuation : undefined; const interrupt = "interrupt" in testCase && testCase.interrupt; - const duringCancel = "duringCancel" in testCase ? testCase.duringCancel : undefined; const cancelOutcome = "cancelOutcome" in testCase ? testCase.cancelOutcome : undefined; const fileSystem = yield* FileSystem.FileSystem; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "cursor-v2-failure-", - }); + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-failure-" }); const instanceId = ProviderInstanceId.make("cursor"); const threadId = ThreadId.make("cursor-failure-thread"); const modelSelection = { instanceId, model: "claude-fable-5" }; @@ -603,17 +585,8 @@ describe("CursorAdapterV2", () => { cancel: Effect.gen(function* () { observedCancelCalls += 1; if (observedCancelCalls > 1) return; - for (const update of duringCancel ?? []) - yield* input.onDelta!(update).pipe(Effect.orDie); - if (cancelOutcome !== "before-wait") { - yield* Deferred.succeed(cancelled, undefined); - } - if ( - cancelOutcome === "after-wait" || - (cancelOutcome === "timeout" && !interrupt) - ) { - yield* Deferred.await(waitObserved); - } + yield* Deferred.succeed(cancelled, undefined); + if (cancelOutcome === "after-wait") yield* Deferred.await(waitObserved); if (cancelOutcome === "timeout") return yield* Effect.never; if (cancelOutcome !== undefined) { return yield* new CursorAgentSdk.CursorAgentSdkRunnerError({ @@ -689,8 +662,6 @@ describe("CursorAdapterV2", () => { } } yield* TestClock.adjust("1 second"); - if (cancelOutcome === "before-wait") yield* Deferred.succeed(cancelled, undefined); - if (cancelOutcome === "timeout") yield* TestClock.adjust("10 seconds"); if (interrupt) { const started = yield* runtime.events.pipe( Stream.filter((event) => event.type === "provider_turn.updated"), From bca18874b0a71134da364be69a91d24cebc79ecb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 00:08:09 +0000 Subject: [PATCH 11/12] fix(provider-cursor): an in-flight steer holds off idle cancellation Co-Authored-By: Claude Opus 5.5 --- .../src/server/adapter.test.ts | 42 ++++++++------ .../provider-cursor/src/server/adapter.ts | 58 ++++++++++++------- 2 files changed, 63 insertions(+), 37 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 32595ab4642a2..55473d705789a 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -2479,6 +2479,7 @@ describe("CursorAdapterV2", () => { const steered: string[] = []; let cancelled = 0; let outcome: "complete_delivered" | "revert_to_followup" = "complete_delivered"; + const acknowledged = yield* Deferred.make(); const finished = yield* Deferred.make<{ readonly id: string; readonly requestId: string; @@ -2529,10 +2530,10 @@ describe("CursorAdapterV2", () => { ), ), steer: (text) => - Effect.sync(() => { - steered.push(text); - return outcome; - }), + Effect.sync(() => steered.push(text)).pipe( + Effect.andThen(Deferred.await(acknowledged)), + Effect.as(outcome), + ), }), ), }), @@ -2601,19 +2602,26 @@ describe("CursorAdapterV2", () => { nativeTurnId: "native-cursor-steer-run", }); yield* runtime.startTurn(turn); - yield* runtime.steerTurn({ - threadId, - runId: turn.runId, - providerThread, - providerTurnId, - message: { - messageId: MessageId.make("cursor-steer-follow-up"), - createdBy: "user", - creationSource: "web", - text: "$review Look at the tests instead.", - attachments: [], - }, - }); + const steering = yield* runtime + .steerTurn({ + threadId, + runId: turn.runId, + providerThread, + providerTurnId, + message: { + messageId: MessageId.make("cursor-steer-follow-up"), + createdBy: "user", + creationSource: "web", + text: "$review Look at the tests instead.", + attachments: [], + }, + }) + .pipe(Effect.forkScoped); + // An acknowledgment that outlasts the idle window must not lose the run to idle cancel. + yield* TestClock.adjust("1 second"); + assert.equal(cancelled, 0); + yield* Deferred.succeed(acknowledged, undefined); + yield* Fiber.join(steering); assert.deepEqual(steered, ["/review Look at the tests instead."]); // The delivered steer is new work, so the ended turn is not cancelled as idle. yield* TestClock.adjust("1 second"); diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index bf47c35894ed1..8a281042e206e 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -34,6 +34,7 @@ import { import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -966,6 +967,7 @@ interface ActiveCursorTurn { /** Bumps when new work starts so a pending idle finalize is ignored. */ idleFinalizeGeneration: number; idleCancelResult: Deferred.Deferred | null; + steersInFlight: number; } interface CursorLiveAgent { @@ -2234,6 +2236,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }); const cursorTurnHasActiveWork = (context: ActiveCursorTurn) => + context.steersInFlight > 0 || context.tools.size > 0 || Array.from(context.subagents.values()).some((subagent) => isOrchestrationV2WorkActive(subagent.task.status), @@ -2620,6 +2623,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( turnEnded: false, idleFinalizeGeneration: 0, idleCancelResult: null, + steersInFlight: 0, }; yield* Ref.set(activeTurn, context); yield* emitProviderEvent({ @@ -2881,30 +2885,44 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( detail: `Cursor provider turn ${turnInput.providerTurnId} is not active.`, }); } - // The live run already has the runtime instructions from startTurn. - const text = yield* providerMessageTextWithAttachmentPaths({ - text: yield* resolveCursorSkillText( - turnInput.message.text, - context.input.runtimePolicy.cwd ?? undefined, - ), - attachments: turnInput.message.attachments, - resolveAttachmentPath: host.resolveAttachmentPath, - resolveExtractedTextPath: host.resolveExtractedTextPath, - }); - if (text.trim().length === 0) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - detail: "Cursor steer requires non-empty text.", + // Hold idle finalize from the moment a steer arrives until the run acknowledges it. + context.steersInFlight += 1; + context.idleFinalizeGeneration += 1; + const outcome = yield* Effect.gen(function* () { + // The live run already has the runtime instructions from startTurn. + const text = yield* providerMessageTextWithAttachmentPaths({ + text: yield* resolveCursorSkillText( + turnInput.message.text, + context.input.runtimePolicy.cwd ?? undefined, + ), + attachments: turnInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + resolveExtractedTextPath: host.resolveExtractedTextPath, }); - } - // A run being cancelled for idleness cannot take more input. - const outcome = - context.run.steer === undefined || context.idleCancelResult !== null + if (text.trim().length === 0) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: "Cursor steer requires non-empty text.", + }); + } + // A run being cancelled for idleness cannot take more input. + return context.run.steer === undefined || context.idleCancelResult !== null ? "revert_to_followup" : yield* context.run.steer(text); + }).pipe( + Effect.onExit((exit) => + Effect.suspend(() => { + context.steersInFlight -= 1; + if (Exit.isSuccess(exit) && exit.value === "complete_delivered") { + context.turnEnded = false; + context.idleFinalizeGeneration += 1; + return Effect.void; + } + return scheduleIdleTurnFinalize(context); + }), + ), + ); if (outcome === "complete_delivered") { - context.turnEnded = false; - context.idleFinalizeGeneration += 1; return; } if (context.interrupted) { From cbf2059c913c6c11061de351a5e4d99aedff1581 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 00:18:43 +0000 Subject: [PATCH 12/12] fix(provider-cursor): wait past stop hooks before finalizing an idle turn Co-Authored-By: Claude Opus 5.5 --- packages/provider-cursor/src/server/adapter.test.ts | 10 +++++----- packages/provider-cursor/src/server/adapter.ts | 4 +++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 55473d705789a..d515b48cb0f0c 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -478,7 +478,7 @@ describe("CursorAdapterV2", () => { cancelCalls: 1, }, { - reason: "a delayed continuation after turn-ended", + reason: "a stop-hook continuation after turn-ended", updates: [ { type: "text-delta", text: "Checking." }, { type: "turn-ended" }, @@ -655,13 +655,13 @@ describe("CursorAdapterV2", () => { }, }); if (continuation !== undefined) { - yield* TestClock.adjust("500 millis"); + yield* TestClock.adjust("90 seconds"); assert.equal(observedCancelCalls, 0); for (const update of continuation) { yield* onDelta!(update); } } - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("2 minutes"); if (interrupt) { const started = yield* runtime.events.pipe( Stream.filter((event) => event.type === "provider_turn.updated"), @@ -2618,13 +2618,13 @@ describe("CursorAdapterV2", () => { }) .pipe(Effect.forkScoped); // An acknowledgment that outlasts the idle window must not lose the run to idle cancel. - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("2 minutes"); assert.equal(cancelled, 0); yield* Deferred.succeed(acknowledged, undefined); yield* Fiber.join(steering); assert.deepEqual(steered, ["/review Look at the tests instead."]); // The delivered steer is new work, so the ended turn is not cancelled as idle. - yield* TestClock.adjust("1 second"); + yield* TestClock.adjust("2 minutes"); assert.equal(cancelled, 0); yield* runtime.steerTurn({ threadId, diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 8a281042e206e..fd844923c3c58 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -2255,7 +2255,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( const generation = ++context.idleFinalizeGeneration; yield* Effect.forkIn( Effect.gen(function* () { - yield* Effect.sleep("1 second"); + // Stop hooks run after turn-ended (in parallel, 60s default timeout) and a follow-up + // continues the run; wait past them before treating a quiet run as hung. + yield* Effect.sleep("2 minutes"); if ( context.finalized || context.interrupted ||