From aa03575a5380dd813e954cebfdde6bf14e6daaf4 Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:50:39 +0200 Subject: [PATCH 01/11] fix: retry an interrupted Run like Cursor CLI A Run that dropped or stalled after showing only text or reasoning, before Cursor saved a checkpoint, ended the turn with a final error since #97, and its request reached Cursor only with the next message. Cursor CLI's Run retry loop sends the same action again when an attempt saved no checkpoint, resumes the latest checkpoint otherwise, and stops after two resumed attempts that stream without a new one. The provider cannot resend after output the host already shows, so it hands that case to the host: - An output-only failure with no checkpoint in the turn reaches the host as a retryable APICallError (x-should-retry: true, no status). OpenCode 1.x retries the step; OpenCode 2 continues it. The lost-request record carries the request once. Any stateful barrier stays final. - The replay barrier records every reason, so text before a stateful frame is not mistaken for output only. - An attempt without its own checkpoint resumes the turn's latest one, unless a later user message revoked it; two resumes without progress end the turn. Fixes #100 --- AGENTS.md | 9 ++- CHANGELOG.md | 3 +- src/errors.ts | 72 ++++++++++++++++++ src/language-model.ts | 50 +++++++++++-- src/replay-safety.ts | 11 ++- src/session.ts | 5 ++ tasks/lessons/opencode-2.md | 6 +- tasks/lessons/opencode.md | 9 +++ test/errors.test.ts | 31 ++++++++ test/run-error-host.test.ts | 53 ++++++++----- test/run-interruption.test.ts | 136 ++++++++++++++++++++++++++++++---- 11 files changed, 343 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 97acd86c..b6190c9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,7 +140,9 @@ The **1.18 v2** API and **OpenCode 2.0** are source-incompatible: actions, final error codes, `isRetryable: false` and bare `permission_denied` end the turn. Final and auth failures reach both hosts as `APICallError` with `x-should-retry: false`, a status below 500 and a message without OpenCode 1.x - retry triggers (`src/errors.ts`); the Connect code stays on `cause`. + retry triggers (`src/errors.ts`); the Connect code stays on `cause`. A + `hostRetryable` failure is the exception: an `APICallError` with + `isRetryable: true`, `x-should-retry: true` and no status, so both hosts retry. ## Context, identity and persistence @@ -200,6 +202,11 @@ The **1.18 v2** API and **OpenCode 2.0** are source-incompatible: - Drain a held Run before a fresh user turn. Rebuild late tool results from host history; checkpoint resume would request the tool again. Never replay side effects after the replay barrier or treat EOF as successful `stop`. +- Retry like Cursor CLI's Run loop: an attempt without its own checkpoint + resumes the turn's latest one (unless a later user message revoked it), and + two resumed attempts that stream without a new checkpoint end the turn. With + no checkpoint in the turn, a transient failure after only text or reasoning + is `hostRetryable` (the CLI resends the action); any other barrier stays final. Missing-blob recovery is limited to the initial control-only checkpoint path (`isSoleControlFrame`); unknown/mixed frames keep the normal replay barrier. - Carry an uncheckpointed request only on its unchanged checkpoint; persist it diff --git a/CHANGELOG.md b/CHANGELOG.md index f304ef27..981c4442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,8 @@ - Long-running tools keep their Cursor Run alive with the same exec heartbeats Cursor CLI sends - Errors show Cursor's own explanation (for example "Request blocked by Anthropic …"), and a request Cursor marks as not retryable is no longer retried by OpenCode (OpenCode 2 sent it 11 times in testing; OpenCode 1.x resent a used-up usage limit five more times because of its message wording) - Like Cursor CLI, errors that need your action (usage or rate limits, sign-in, pricing, API key, outdated client), a bare `permission_denied`, and authentication failures, including those before a Run opens, end the turn instead of being retried; on OpenCode 1.x this also covers final `internal`/`unknown` errors. Final Cursor details take precedence over token renewal ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) -- Once the provider spends its retry budget or cannot safely replay a Run, OpenCode no longer starts another retry loop ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) +- Once the provider spends its retry budget, or cannot replay a Run without repeating a tool call or other side effect, OpenCode no longer starts another retry loop ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) +- Like Cursor CLI, a Run that drops or stalls after showing only text or reasoning, before Cursor saved a checkpoint, is retried by OpenCode instead of ending the turn, and the request reaches Cursor in that retry instead of with your next message. A retry after a checkpoint resumes the turn's latest checkpoint, and two resumes that save nothing end the turn ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100), reported by [@nkoynov](https://github.com/nkoynov)) - On macOS, a shell command that printed nothing before being moved to the background no longer reports `head: illegal byte count -- 0` - A tool that is still running no longer lets its Cursor Run expire at the inactivity limit. The Run stays open until the tool finishes, for up to four hours from when that tool started - An edit Cursor starts announcing shows its tool row while the arguments are still streaming. If that call never runs, the row closes instead of being left for the host to execute ([#73](https://github.com/oakimov/cursor-opencode-provider/issues/73)) diff --git a/src/errors.ts b/src/errors.ts index 3be8a8ef..24de71a7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,6 +20,7 @@ export type CursorProviderErrorOptions = CursorErrorDiagnostics & { transient: boolean replaySafe: boolean retryable?: boolean + hostRetryable?: boolean cause?: unknown } @@ -41,6 +42,14 @@ export class CursorProviderError extends Error { readonly retryAfterMs?: number /** Cursor's classified verdict, or false once provider retries must stop. */ readonly retryable?: boolean + /** + * The provider stopped, but the host may send the turn again: the failed + * attempt only showed text or reasoning, before any checkpoint. Cursor CLI + * resends the same action in this case. + */ + readonly hostRetryable?: boolean + /** What the failed attempt did that a replay inside the Run would repeat. */ + replayBarrier?: "output-only" | "stateful" constructor(message: string, options: CursorProviderErrorOptions) { super(message, options.cause === undefined ? undefined : { cause: options.cause }) @@ -54,6 +63,7 @@ export class CursorProviderError extends Error { this.code = options.code this.retryAfterMs = options.retryAfterMs this.retryable = options.retryable + this.hostRetryable = options.hostRetryable } } @@ -91,10 +101,29 @@ const NON_RETRYABLE_SERVER_STATUS = 400 * patterns, so the status is the Connect code's HTTP status capped below 500 * (Cursor CLI decides from the verdict, never the code) and the message is * sanitized. The original code stays on `cause`. + * + * A `hostRetryable` failure is the opposite: Cursor CLI would send the same + * action again, but output the host already shows stops the provider from + * doing so. It becomes an `APICallError` with `isRetryable: true`, which + * OpenCode 1.x retries as the same step, and `x-should-retry: true` without a + * status, which OpenCode 2 continues after the shown output. */ export function hostFacingError(error: unknown): Error { if (!(error instanceof Error)) return new Error(String(error)) const auth = error instanceof CursorAuthError + if (!auth && error instanceof CursorProviderError && error.hostRetryable) { + return new APICallError({ + message: error.message, + url: "cursor://agent.v1.AgentService/Run", + requestBodyValues: {}, + responseHeaders: { + "x-should-retry": "true", + ...(error.retryAfterMs === undefined ? {} : { "retry-after-ms": String(error.retryAfterMs) }), + }, + isRetryable: true, + cause: error, + }) + } if (!auth && (!(error instanceof CursorProviderError) || error.retryable !== false)) return error const connectStatus = error.code !== undefined && Object.hasOwn(CONNECT_CODE_HTTP_STATUS, error.code) ? CONNECT_CODE_HTTP_STATUS[error.code]! @@ -394,6 +423,49 @@ export function toCursorProviderError( }) } +/** + * A transient failure after the attempt showed only text or reasoning, before + * any checkpoint of the turn. Cursor CLI sends the same action again; the host + * does that here (see `hostFacingError`), and the lost-request record carries + * the user's request once. + */ +export function hostRetryError(cause: CursorProviderError): CursorProviderError { + return new CursorProviderError( + `${cause.message} after visible output, before Cursor saved a checkpoint; the turn can be sent again`, + { + origin: cause.origin, + transient: false, + replaySafe: false, + hostRetryable: true, + statusCode: cause.statusCode, + grpcStatus: cause.grpcStatus, + rstCode: cause.rstCode, + code: cause.code, + retryAfterMs: cause.retryAfterMs, + cause, + }, + ) +} + +/** + * Cursor CLI stops automatic resumes after two resumed attempts that streamed + * but saved no checkpoint, so the turn does not repeat the same work forever. + */ +export function noProgressResumeError(cause: CursorProviderError, resumes: number): CursorProviderError { + return new CursorProviderError( + `Cursor Run stopped after ${resumes} resume attempts made no progress; ` + + "send a message to continue from the last saved state", + { + origin: cause.origin, + transient: false, + replaySafe: false, + retryable: false, + code: cause.code, + cause, + }, + ) +} + export function retrySuppressedError( cause: CursorProviderError, reason: string, diff --git a/src/language-model.ts b/src/language-model.ts index 4c272232..2df7b0ba 100644 --- a/src/language-model.ts +++ b/src/language-model.ts @@ -251,8 +251,10 @@ import { CursorServerError, CursorTransportError, hostFacingError, + hostRetryError, isRejectedCredentialError, isTransientGrpcStatus, + noProgressResumeError, retrySuppressedError, toCursorProviderError, } from "./errors.js" @@ -1208,10 +1210,19 @@ export async function pumpWithRecovery(input: { } const maxRecoveries = retryPolicy.maxAttempts - 1 let credentialRenewed = false + // As in Cursor CLI's Run retry loop: an attempt without a checkpoint of its + // own resumes the latest one this turn received, and two resumed attempts + // that streamed without saving a new checkpoint stop the automatic retries. + let turnCheckpoint: Uint8Array | undefined + let resumedAttempt = false + let noProgressResumes = 0 input.onSession?.(session) - const reopen = async (pumpedSession: CursorSession, failure: CursorProviderError) => { - const checkpoint = pumpedSession.resumeCheckpoint + const reopen = async ( + pumpedSession: CursorSession, + failure: CursorProviderError, + checkpoint: Uint8Array | undefined, + ) => { const recovery: CursorRunRecovery = failure.checkpointUnusable ? { kind: "rebase", reason: "checkpoint-unusable" } : checkpoint @@ -1221,6 +1232,9 @@ export async function pumpWithRecovery(input: { checkpoint: Uint8Array.from(checkpoint), } : { kind: "rebase" } + resumedAttempt = recovery.kind === "resume" + // A rebase opens another conversation; its checkpoints start over. + if (!resumedAttempt) turnCheckpoint = undefined // Host notes the failed Run never acknowledged belong to this turn. const unackedNotes = takeHostNoteInjections(pumpedSession) const unackedNoteCount = unackedNotes.reduce((count, batch) => count + batch.notes.length, 0) @@ -1332,13 +1346,38 @@ export async function pumpWithRecovery(input: { ) sessionManager.close(pumpedSession, "remote-error", failure) input.renewRejectedCredential() - session = await reopen(pumpedSession, failure) + session = await reopen( + pumpedSession, + failure, + pumpedSession.resumeCheckpoint ?? (pumpedSession.checkpointRevoked ? undefined : turnCheckpoint), + ) attempt-- continue } if (!failure.transient) throw failure - const checkpoint = pumpedSession.resumeCheckpoint + if (pumpedSession.resumeCheckpoint) { + turnCheckpoint = pumpedSession.resumeCheckpoint + noProgressResumes = 0 + } else if (pumpedSession.checkpointRevoked) { + turnCheckpoint = undefined + } else if (resumedAttempt && failure.replayBarrier) { + noProgressResumes++ + if (noProgressResumes >= 2) { + trace( + `Run resume made no progress: sessionId=${pumpedSession.sessionId} resumes=${noProgressResumes} ` + + `err=${failure.message} — stopping automatic resume`, + ) + throw noProgressResumeError(failure, noProgressResumes) + } + } + const checkpoint = turnCheckpoint if (!failure.replaySafe && !checkpoint) { + // Cursor CLI sends the same action again. Shown output keeps the + // provider from doing so; the host can, unless a side effect would repeat. + if (failure.replayBarrier === "output-only" && !pumpedSession.checkpointRevoked) { + trace(`Run interrupted after output only: sessionId=${pumpedSession.sessionId} — leaving the retry to the host`) + throw hostRetryError(failure) + } throw retrySuppressedError( failure, "after visible output or stateful server activity", @@ -1357,7 +1396,7 @@ export async function pumpWithRecovery(input: { const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy) trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`) await sleepForRetry(delayMs, input.abortSignal) - session = await reopen(pumpedSession, failure) + session = await reopen(pumpedSession, failure, checkpoint) } finally { input.abortSignal?.removeEventListener("abort", stopPumpedRun) stopSteers?.() @@ -5026,6 +5065,7 @@ export async function pump( await session.reopenWithUserMessage(steers.undelivered.map((steer) => steer.text).join("\n\n"), abortSignal) // Resuming the checkpoint from before the follow-up would send the steer again. session.resumeCheckpoint = undefined + session.checkpointRevoked = true endingTurns.delete(session) textBreakPending = true continue diff --git a/src/replay-safety.ts b/src/replay-safety.ts index a2b738ec..d70a4878 100644 --- a/src/replay-safety.ts +++ b/src/replay-safety.ts @@ -11,12 +11,17 @@ export type ReplayBarrierReason = | "stateful-interaction" | "unknown-or-malformed-frame" +/** Barriers that only showed the host text or reasoning: sending the turn again repeats no side effect. */ +const OUTPUT_ONLY_BARRIERS: ReadonlySet = new Set(["visible-text", "visible-reasoning"]) + export class AttemptReplaySafety { private barrierReason: ReplayBarrierReason | undefined + private stateful = false constructor(private readonly sessionId: string) {} markBarrier(reason: ReplayBarrierReason): void { + if (!OUTPUT_ONLY_BARRIERS.has(reason)) this.stateful = true if (this.barrierReason) return this.barrierReason = reason trace(`replay barrier: reason=${reason} sessionId=${this.sessionId}`) @@ -25,7 +30,11 @@ export class AttemptReplaySafety { applyTo(failure: CursorProviderError): CursorProviderError { failure.replaySafe = this.barrierReason === undefined && failure.replaySafe if (this.barrierReason) { - trace(`replay suppressed: reason=${this.barrierReason} sessionId=${this.sessionId}`) + failure.replayBarrier = this.stateful ? "stateful" : "output-only" + trace( + `replay suppressed: reason=${this.barrierReason} barrier=${failure.replayBarrier} ` + + `sessionId=${this.sessionId}`, + ) } return failure } diff --git a/src/session.ts b/src/session.ts index 9e46af7e..45f7125a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -205,6 +205,11 @@ export type CursorSession = { cacheDir?: string /** Latest eligible checkpoint emitted by this Run attempt. */ resumeCheckpoint?: Uint8Array + /** + * This Run sent a user message after its last checkpoint: recovery must not + * resume an earlier checkpoint of the turn, which lacks that message. + */ + checkpointRevoked?: boolean /** History images this Run's checkpoints hold or may hold; absent for a Run outside an OpenCode session. */ historyImages?: RunHistoryImages /** Last checkpoint-derived context snapshot, independent of retry eligibility. */ diff --git a/tasks/lessons/opencode-2.md b/tasks/lessons/opencode-2.md index f72ad712..27310601 100644 --- a/tasks/lessons/opencode-2.md +++ b/tasks/lessons/opencode-2.md @@ -27,8 +27,10 @@ Operator setup: [OpenCode 2.0](../../docs/opencode-2.md). `APICallError` by status and retries 408/409/429/5xx; only `x-should-retry: false` overrides it. An unrecognized failure without a status also retries: project pre-stream failures from both public generation methods and mark - spent/unsafe provider retries terminal, alongside final server verdicts - (`hostFacingError`, `test/errors.test.ts`, `test/run-error-host.test.ts`). + spent or side-effecting provider retries terminal, alongside final server + verdicts. An output-only failure needs `x-should-retry: true` and no status: + 2.0 then continues after the shown output (`hostFacingError`, + `test/errors.test.ts`, `test/run-error-host.test.ts`). - **A new plugin header does not prove a new SDK.** Pin the intended binary, isolate config, set the local provider entry before daemon start, and restart after rebuilding ([local setup](../../docs/opencode-2.md#from-a-local-clone-cursor_opencode2_dev_entry)). diff --git a/tasks/lessons/opencode.md b/tasks/lessons/opencode.md index 7d8449a8..6377bf92 100644 --- a/tasks/lessons/opencode.md +++ b/tasks/lessons/opencode.md @@ -131,6 +131,15 @@ These are failure lessons shared by both hosts, not a replacement for eligible checkpoint only behind the replay barrier; a late tool result needs history recovery because `resume_action` re-requests that tool (`pumpWithRecovery`, `test/parallel-pump.test.ts`). +- **A replay the provider must refuse can still be the host's to retry.** + Making every suppressed replay final ended a turn whose Run dropped after + only reasoning; its request reached Cursor only with the next message + ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100)). + Cursor CLI resends the action, resumes the turn's latest checkpoint, and stops + after two resumes without a new one. Record every barrier, not the first: + text before a stateful frame must not read as output-only + (`AttemptReplaySafety`, `test/run-interruption.test.ts`, + `test/run-error-host.test.ts`). - **A helper must not take over its parent.** A smaller catalog was mistaken for a helper despite carrying the parent's pending result. Reconcile that result first; isolate genuine helpers (`test/fresh-turn-drain.test.ts`). An empty diff --git a/test/errors.test.ts b/test/errors.test.ts index 0dfd3a69..50d47bdb 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -9,6 +9,8 @@ import { cursorGrpcError, cursorHttpError, hostFacingError, + hostRetryError, + noProgressResumeError, retrySuppressedError, sanitizeHostTerminalMessage, toCursorProviderError, @@ -290,4 +292,33 @@ describe("Cursor provider errors", () => { expect(host.message).not.toContain("503") } }) + + it("lets the host retry a turn that only showed output, as Cursor CLI resends the action", () => { + const cause = new CursorTransportError("Cursor Run transport interrupted", { + transient: true, + replaySafe: false, + code: "NGHTTP2_INTERNAL_ERROR", + retryAfterMs: 1_500, + }) + const failure = hostRetryError(cause) + expect(failure).toMatchObject({ hostRetryable: true, transient: false, replaySafe: false }) + expect(failure.retryable).toBeUndefined() + const host = hostFacingError(failure) + expect(APICallError.isInstance(host)).toBe(true) + // No status: OpenCode 2 then treats it as a transport failure and honours the header. + expect(host).toMatchObject({ + statusCode: undefined, + isRetryable: true, + responseHeaders: { "x-should-retry": "true", "retry-after-ms": "1500" }, + }) + expect((host as APICallError).cause).toBe(failure) + }) + + it("ends the turn after resumes that made no progress, as Cursor CLI does", () => { + const cause = new CursorTransportError("Cursor Run transport interrupted", { transient: true, replaySafe: false }) + const host = hostFacingError(noProgressResumeError(cause, 2)) + expect(APICallError.isInstance(host)).toBe(true) + expect(host).toMatchObject({ isRetryable: false, responseHeaders: { "x-should-retry": "false" } }) + expect(host.message).toContain("2 resume attempts made no progress") + }) }) diff --git a/test/run-error-host.test.ts b/test/run-error-host.test.ts index c1a06d64..1df2f79f 100644 --- a/test/run-error-host.test.ts +++ b/test/run-error-host.test.ts @@ -188,23 +188,38 @@ describe("Run errors as the host sees them", () => { expect(runs).toBe(1) }, 30_000) - for (const unsafeReplay of [false, true]) { - it(`stops host retries after ${unsafeReplay ? "visible output makes replay unsafe" : "the Run retry budget is spent"}`, async () => { - trailer = { error: { code: "unavailable" } } - partialOutput = unsafeReplay - const error = await streamError(`ses_terminal_${unsafeReplay}`) - - expect(APICallError.isInstance(error)).toBe(true) - expect(error).toMatchObject({ - statusCode: 400, - isRetryable: false, - responseHeaders: { "x-should-retry": "false" }, - }) - const cause = (error as APICallError).cause - expect(cause).toMatchObject({ transient: false, replaySafe: false, code: "unavailable" }) - if (unsafeReplay) expect((error as Error).message).toContain("automatic retry unsafe") - else expect(cause).toBeInstanceOf(CursorRetryExhaustedError) - expect(runs).toBe(unsafeReplay ? 1 : 2) - }, 30_000) - } + it("stops host retries after the Run retry budget is spent", async () => { + trailer = { error: { code: "unavailable" } } + const error = await streamError("ses_terminal_budget") + + expect(APICallError.isInstance(error)).toBe(true) + expect(error).toMatchObject({ + statusCode: 400, + isRetryable: false, + responseHeaders: { "x-should-retry": "false" }, + }) + const cause = (error as APICallError).cause + expect(cause).toBeInstanceOf(CursorRetryExhaustedError) + expect(cause).toMatchObject({ transient: false, replaySafe: false, code: "unavailable" }) + expect(runs).toBe(2) + }, 30_000) + + // Cursor CLI sends the same action again. The provider cannot, because the + // host already shows the reasoning, so the host retries the turn. + it("leaves the retry to the host after output only, before any checkpoint", async () => { + trailer = { error: { code: "unavailable" } } + partialOutput = true + const error = await streamError("ses_host_retry") + + expect(APICallError.isInstance(error)).toBe(true) + expect(error).toMatchObject({ + statusCode: undefined, + isRetryable: true, + responseHeaders: { "x-should-retry": "true" }, + }) + expect((error as Error).message).not.toContain("automatic retry unsafe") + const cause = (error as APICallError).cause + expect(cause).toMatchObject({ hostRetryable: true, transient: false, replaySafe: false, code: "unavailable" }) + expect(runs).toBe(1) + }, 30_000) }) diff --git a/test/run-interruption.test.ts b/test/run-interruption.test.ts index 6281a8cf..ef2277f3 100644 --- a/test/run-interruption.test.ts +++ b/test/run-interruption.test.ts @@ -8,6 +8,7 @@ import { pump, pumpWithRecovery, rememberMirroredTodos, + type CursorRunRecovery, snapshotMirroredTodosBySession, } from "../src/language-model.js" import { decodeMessage, encodeMessage } from "../src/protocol/messages.js" @@ -494,25 +495,134 @@ describe("interrupted Cursor Run handling", () => { expect(recoveries).toBe(2) }) - it("does not recover after visible output because replay could duplicate text", async () => { + it("leaves the retry to the host after output only, before any checkpoint", async () => { let recoveries = 0 const parts: any[] = [] - await expect( - pumpWithRecovery({ - initialSession: fakeSession("partial", [ - serverFrame({ interaction_update: { text_delta: { text: "partial" } } }), - ]), - controller: controller(parts), - recover: async () => { - recoveries++ - return fakeSession("unused", []) - }, - }), - ).rejects.toThrow("automatic retry unsafe") + const failure = await pumpWithRecovery({ + initialSession: fakeSession("partial", [ + serverFrame({ interaction_update: { thinking_delta: { text: "thinking" } } }), + serverFrame({ interaction_update: { text_delta: { text: "partial" } } }), + ]), + controller: controller(parts), + recover: async () => { + recoveries++ + return fakeSession("unused", []) + }, + }).catch((error: unknown) => error) + + // Replaying here would show the text twice; Cursor CLI sends the action again. + expect(failure).toMatchObject({ hostRetryable: true, transient: false, replaySafe: false }) + expect((failure as Error).message).not.toContain("automatic retry unsafe") expect(recoveries).toBe(0) expect(parts.some((part) => part.type === "text-delta" && part.delta === "partial")).toBe(true) }) + it("keeps a failure final when the attempt did more than show output", async () => { + const getBlob = lengthDelimitedField(1, new TextEncoder().encode("inline blob")) + const getWithUnknownField = Uint8Array.from([ + ...varintField(1, 15), + ...lengthDelimitedField(2, getBlob), + ...fixed32Field(20), + ]) + let recoveries = 0 + const failure = await pumpWithRecovery({ + initialSession: fakeSession("output-then-state", [ + // Text first: the later stateful frame must still decide the verdict. + serverFrame({ interaction_update: { text_delta: { text: "partial" } } }), + { flags: 0, payload: lengthDelimitedField(4, getWithUnknownField) }, + ]), + controller: controller([]), + recover: async () => { + recoveries++ + return fakeSession("unused", []) + }, + }).catch((error: unknown) => error) + + expect(failure).toMatchObject({ retryable: false, replaySafe: false }) + expect((failure as { hostRetryable?: boolean }).hostRetryable).toBeUndefined() + expect((failure as Error).message).toContain("automatic retry unsafe") + expect(recoveries).toBe(0) + }) + + it("resumes the turn's latest checkpoint when a resumed attempt saved none, like Cursor CLI", async () => { + const checkpoint = Uint8Array.from([0x0a, 0x01, 0x05]) + const sessions = [ + fakeSession("resume-no-output", []), + fakeSession("resume-output", [serverFrame({ interaction_update: { text_delta: { text: " more" } } })]), + fakeSession("resume-done", [ + serverFrame({ interaction_update: { text_delta: { text: " done" } } }), + serverFrame({ interaction_update: { turn_ended: { input_tokens: 1, output_tokens: 1 } } }), + ]), + ] + const recoveries: CursorRunRecovery[] = [] + const parts: any[] = [] + const finalSession = await pumpWithRecovery({ + initialSession: fakeSession("checkpointed-first", [ + serverFrame({ interaction_update: { text_delta: { text: "partial" } } }), + serverFrame({ conversation_checkpoint_update: checkpoint }), + ]), + controller: controller(parts), + retryPolicy: { maxAttempts: 4, baseDelayMs: 0, maxDelayMs: 0 }, + recover: async (recovery) => { + recoveries.push(recovery) + return sessions[recoveries.length - 1]! + }, + }) + + expect(finalSession).toBe(sessions[2]) + expect(recoveries.map((recovery) => recovery.kind)).toEqual(["resume", "resume", "resume"]) + for (const recovery of recoveries) { + expect(Buffer.from((recovery as { checkpoint: Uint8Array }).checkpoint)).toEqual(Buffer.from(checkpoint)) + } + expect(parts.filter((part) => part.type === "finish")).toHaveLength(1) + }) + + it("stops after two resumed attempts stream without saving a checkpoint, like Cursor CLI", async () => { + const checkpoint = Uint8Array.from([0x0a, 0x01, 0x06]) + let recoveries = 0 + const failure = await pumpWithRecovery({ + initialSession: fakeSession("checkpointed-then-stuck", [ + serverFrame({ conversation_checkpoint_update: checkpoint }), + ]), + controller: controller([]), + retryPolicy: { maxAttempts: 6, baseDelayMs: 0, maxDelayMs: 0 }, + recover: async () => { + recoveries++ + return fakeSession(`stuck-${recoveries}`, [ + serverFrame({ interaction_update: { thinking_delta: { text: "again" } } }), + ]) + }, + }).catch((error: unknown) => error) + + expect(failure).toMatchObject({ retryable: false, transient: false }) + expect((failure as Error).message).toContain("2 resume attempts made no progress") + expect(recoveries).toBe(2) + }) + + it("does not resume an earlier checkpoint once the Run sent a newer user message", async () => { + let recoveries = 0 + const revoked = fakeSession("revoked", [ + serverFrame({ interaction_update: { text_delta: { text: "after steer" } } }), + ]) + revoked.checkpointRevoked = true + const failure = await pumpWithRecovery({ + initialSession: fakeSession("checkpointed-before-steer", [ + serverFrame({ conversation_checkpoint_update: Uint8Array.from([0x0a, 0x01, 0x07]) }), + ]), + controller: controller([]), + retryPolicy: { maxAttempts: 4, baseDelayMs: 0, maxDelayMs: 0 }, + recover: async () => { + recoveries++ + if (recoveries > 1) throw new Error("A revoked checkpoint must not be resumed") + return revoked + }, + }).catch((error: unknown) => error) + + expect(failure).toMatchObject({ retryable: false }) + expect((failure as { hostRetryable?: boolean }).hostRetryable).toBeUndefined() + expect(recoveries).toBe(1) + }) + it("replies to decoded KV requests with unknown fields, then suppresses replay", async () => { const writes: Uint8Array[] = [] const getBlob = lengthDelimitedField(1, new TextEncoder().encode("inline blob")) From 2b16a8417f77b629cbb612bebb01693504d2228c Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:17:59 +0200 Subject: [PATCH 02/11] fix: preserve recovery barriers and eligible checkpoints --- AGENTS.md | 6 ++- CHANGELOG.md | 4 ++ README.md | 2 +- docs/opencode-1.md | 8 ++++ docs/opencode-2.md | 8 ++++ src/language-model.ts | 47 ++++++++++++++----- src/replay-safety.ts | 17 +++++-- src/session.ts | 9 +++- tasks/lessons/opencode.md | 12 +++-- test/display-bridge-pump.test.ts | 14 ++++++ test/parallel-pump.test.ts | 46 +++++++++++++++++++ test/run-error-host.test.ts | 77 +++++++++++++++++++++++++++++++- test/run-interruption.test.ts | 47 +++++++++++++++++++ 13 files changed, 274 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b6190c9c..06c4f258 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,8 +203,10 @@ The **1.18 v2** API and **OpenCode 2.0** are source-incompatible: host history; checkpoint resume would request the tool again. Never replay side effects after the replay barrier or treat EOF as successful `stop`. - Retry like Cursor CLI's Run loop: an attempt without its own checkpoint - resumes the turn's latest one (unless a later user message revoked it), and - two resumed attempts that stream without a new checkpoint end the turn. With + resumes the turn's latest eligible one. Delivered tool results and follow-up + user messages revoke earlier checkpoints; queued frames use receipt order. + Stateful barriers survive host tool steps. Two resumed attempts that stream + without a new checkpoint end the turn. With no checkpoint in the turn, a transient failure after only text or reasoning is `hostRetryable` (the CLI resends the action); any other barrier stays final. Missing-blob recovery is limited to the initial control-only checkpoint path diff --git a/CHANGELOG.md b/CHANGELOG.md index 981c4442..d872c4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Interrupted Runs retain their checkpoint across credential renewal. Completed tools remain replay barriers across host steps, and checkpoints received before a tool result or follow-up message are not used to resume it ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) + ## [0.8.3] - 2026-10-17 ### Added diff --git a/README.md b/README.md index 912c1699..3fcc8bdb 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader. | Local OpenCode 2.0 still runs the published package | Set `CURSOR_OPENCODE2_DEV_ENTRY` to an absolute `…/dist/index.js` path **before** starting the daemon, persist it with `opencode2 service set env CURSOR_OPENCODE2_DEV_ENTRY …`, rebuild (`bun run build`), then `opencode2 service restart`. Loading only `dist/plugin-opencode2.js` is not enough — without the env var, 2.0 still `npm install`s the published package into the host cache. | | “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. | | Empty or stale model list | Delete `/cursor-models.json` (native default `~/.cache/opencode/`) and restart OpenCode. A compatibility layer may supply a different host-cache root through the structural path bridge. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. | -| Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, resumes transient interruptions from the latest eligible Cursor checkpoint, and falls back to a fresh-history rebase only before stateful output when no checkpoint exists. Repeated interruption is surfaced as an error instead of a false successful stop; retry the turn after checking connectivity. Behind an HTTP proxy, set `HTTPS_PROXY` (and `NO_PROXY` as needed): unary `fetch` and the Run HTTP/2 CONNECT tunnel both honor them. With debug logging enabled, look for `Run interrupted`, `resuming … checkpoint`, `rebasing fresh Run`, or `h2 connect via HTTPS proxy`. Restart OpenCode after rebuilding a local `file://` install. | +| Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, resumes transient interruptions from the latest eligible Cursor checkpoint, and falls back to a fresh-history rebase only before stateful output when no checkpoint exists. A transient interruption after only text or reasoning, without a checkpoint from that turn, is left to OpenCode to retry. Completed tools remain replay barriers across host steps; credential renewal retains the latest eligible checkpoint. Repeated resumes without progress end as an error. Behind an HTTP proxy, set `HTTPS_PROXY` (and `NO_PROXY` as needed): unary `fetch` and the Run HTTP/2 CONNECT tunnel both honor them. With debug logging enabled, look for `Run interrupted`, `resuming … checkpoint`, `rebasing fresh Run`, or `h2 connect via HTTPS proxy`. Restart OpenCode after rebuilding a local `file://` install. | | No response / silent 200 + close | HTTP 200 alone is not a successful agent turn: the provider now requires Cursor's explicit `turn_ended`, captures HTTP/2 trailers/GOAWAY, and recovers once from bare EOF. The Run host still comes from in-memory `GetServerConfig` resolution; set `CURSOR_PROVIDER_DEBUG=1` to confirm the host and termination reason. | | Visible `` timeout text | Rebuild and restart a local install. Cursor's shell timeout is carried on its exec request; the provider now removes OpenCode's internal timeout envelope before it is rendered or stored, then returns Cursor's typed timeout or background-handoff event instead of treating the text as successful stdout. | | `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. Known `handling=unsupported` variants (Cursor-native capabilities without a safe OpenCode AI SDK bridge) are now soft-denied on the wire with a populated typed error/`rejected`/`allowlisted:false` result and the Run continues — look for `exec: SOFT-DENIED …` in the debug log and a `Workspace root: …` suffix in the deny text — while `unknown request field` still hard-fails as protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. | diff --git a/docs/opencode-1.md b/docs/opencode-1.md index 3e59b937..23e7b707 100644 --- a/docs/opencode-1.md +++ b/docs/opencode-1.md @@ -137,6 +137,14 @@ output](https://github.com/anomalyco/opencode/blob/v1.18.31/packages/opencode/sr The plugin adjusts only the request's projection of Cursor-owned results; stored messages are preserved. +## Interrupted model requests + +OpenCode retries a transient failure after only text or reasoning when the +turn has no new checkpoint. Stateful activity, including a completed tool from +an earlier host step, stays a replay barrier. Recovery retains the latest +eligible checkpoint across token renewal; two resumed attempts that stream +without saving a checkpoint end the turn. + ## Troubleshooting | Problem | What to try | diff --git a/docs/opencode-2.md b/docs/opencode-2.md index d221a6c1..cfcf9dfe 100644 --- a/docs/opencode-2.md +++ b/docs/opencode-2.md @@ -208,6 +208,14 @@ converts Cursor-native file execs from the current permitted catalog. It refuses patch operations that would lose CRLF bytes before changing a file. Native `edit` / `write` remain unchanged when permitted; see [shared patch behavior](../README.md#apply_patch--patch-on-gpt-models). +## Interrupted model requests + +OpenCode retries a transient failure after only text or reasoning when the +turn has no new checkpoint. Stateful activity, including a completed tool from +an earlier host step, stays a replay barrier. Recovery retains the latest +eligible checkpoint across token renewal; two resumed attempts that stream +without saving a checkpoint end the turn. + ## Mid-turn steers and Stop A plain-text steer without files or skills can reach an actively pumping Cursor diff --git a/src/language-model.ts b/src/language-model.ts index 2df7b0ba..ff73aef4 100644 --- a/src/language-model.ts +++ b/src/language-model.ts @@ -831,6 +831,23 @@ function noteRunImageCheckpoint(session: CursorSession, checkpoint: Uint8Array, ) } +/** A resumed model step cannot replay a result or follow-up absent from its checkpoint. */ +function invalidateResumeCheckpoint(session: CursorSession): void { + session.resumeCheckpoint = undefined + session.checkpointRevoked = true + session.resumeCheckpointAfterFrame = lastFrameArrival() +} + +/** A queued checkpoint predating a delivered result still describes the old step. */ +function acceptResumeCheckpoint(session: CursorSession, bytes: Uint8Array, frame: Frame): void { + const arrived = frameArrival(frame) + if (session.resumeCheckpointAfterFrame !== undefined && arrived !== undefined + && arrived <= session.resumeCheckpointAfterFrame) return + session.resumeCheckpoint = Uint8Array.from(bytes) + session.checkpointRevoked = false + session.resumeCheckpointAfterFrame = undefined +} + /** `resumeCheckpoint` is set only by a checkpoint this Run received. */ function confirmRunImagesAtTurnEnd(session: CursorSession): void { if (!session.historyImages || !session.openCodeSessionId) return @@ -1262,6 +1279,7 @@ export async function pumpWithRecovery(input: { recovery.kind === "resume" ? next.sessionId : undefined, ) if (recovery.kind === "resume") { + next.replaySafety = { ...pumpedSession.replaySafety } next.userTurnHostNotes = pumpedSession.userTurnHostNotes next.usageEstimate = { ...pumpedSession.usageEstimate } next.editToolCalls = new Map(pumpedSession.editToolCalls) @@ -1333,6 +1351,14 @@ export async function pumpWithRecovery(input: { replaySafe: error instanceof CursorProviderError ? error.replaySafe : false, fallback: "Cursor Run interrupted", }) + // A renewable credential error may follow a checkpoint too. Retain it + // before either recovery path so a later attempt can still resume it. + if (pumpedSession.resumeCheckpoint) { + turnCheckpoint = pumpedSession.resumeCheckpoint + noProgressResumes = 0 + } else if (pumpedSession.checkpointRevoked) { + turnCheckpoint = undefined + } if ( input.renewRejectedCredential && !credentialRenewed @@ -1355,12 +1381,8 @@ export async function pumpWithRecovery(input: { continue } if (!failure.transient) throw failure - if (pumpedSession.resumeCheckpoint) { - turnCheckpoint = pumpedSession.resumeCheckpoint - noProgressResumes = 0 - } else if (pumpedSession.checkpointRevoked) { - turnCheckpoint = undefined - } else if (resumedAttempt && failure.replayBarrier) { + if (!pumpedSession.resumeCheckpoint && !pumpedSession.checkpointRevoked + && resumedAttempt && failure.replayBarrier) { noProgressResumes++ if (noProgressResumes >= 2) { trace( @@ -2946,7 +2968,7 @@ export async function drainSessionUntilTurnEnded( if (session.cacheDiagnostics) session.cacheDiagnostics.checkpointUpdates++ setCheckpoint(session.conversationId, bytes) noteCheckpoint(session.openCodeSessionId, bytes) - session.resumeCheckpoint = Uint8Array.from(bytes) + acceptResumeCheckpoint(session, bytes, frame) markEarlySteersCheckpointed(session.openCodeSessionId, session.sessionId) noteRunImageCheckpoint(session, bytes, frame) const tokenDetails = decodeConversationTokenDetails(bytes) @@ -3462,6 +3484,9 @@ async function deliverContinuationResultsNow( return undefined } deliveredResults++ + if (outcome.framesWritten > 0 || (pending.bridged && !pending.aborted)) { + invalidateResumeCheckpoint(session) + } if (session.historyImages) noteHistoryImagesSent(session.historyImages, deliveredImageHashes, lastFrameArrival()) if (deliveredSwitchMode) { const normalized = deliveredSwitchMode.target.toLowerCase() @@ -4156,7 +4181,7 @@ export async function pump( let emittedHostTools = 0 const earlyRows = new EarlyToolRows() let planHandoffCancellationRequested = false - const replaySafety = new AttemptReplaySafety(session.sessionId) + const replaySafety = new AttemptReplaySafety(session.sessionId, session.replaySafety ??= {}) const failRunProtocol = (message: string, code: string): never => { replaySafety.markBarrier("unknown-or-malformed-frame") const error = new CursorProtocolError(message, { code }) @@ -5000,7 +5025,7 @@ export async function pump( cacheDiagnostics.checkpointUpdates++ setCheckpoint(session.conversationId, bytes) noteCheckpoint(session.openCodeSessionId, bytes) - session.resumeCheckpoint = Uint8Array.from(bytes) + acceptResumeCheckpoint(session, bytes, frame) markEarlySteersCheckpointed(session.openCodeSessionId, session.sessionId) noteRunImageCheckpoint(session, bytes, frame) const tokenDetails = decodeConversationTokenDetails(bytes) @@ -5064,8 +5089,7 @@ export async function pump( try { await session.reopenWithUserMessage(steers.undelivered.map((steer) => steer.text).join("\n\n"), abortSignal) // Resuming the checkpoint from before the follow-up would send the steer again. - session.resumeCheckpoint = undefined - session.checkpointRevoked = true + invalidateResumeCheckpoint(session) endingTurns.delete(session) textBreakPending = true continue @@ -5103,6 +5127,7 @@ export async function pump( ), abortSignal, ) + invalidateResumeCheckpoint(session) endingTurns.delete(session) assistantText = "" continue diff --git a/src/replay-safety.ts b/src/replay-safety.ts index d70a4878..fc12e9cc 100644 --- a/src/replay-safety.ts +++ b/src/replay-safety.ts @@ -14,21 +14,32 @@ export type ReplayBarrierReason = /** Barriers that only showed the host text or reasoning: sending the turn again repeats no side effect. */ const OUTPUT_ONLY_BARRIERS: ReadonlySet = new Set(["visible-text", "visible-reasoning"]) +/** Plain Run-owned state retained across host tool-step pump calls. */ +export type ReplaySafetyState = { statefulBarrier?: ReplayBarrierReason } + export class AttemptReplaySafety { private barrierReason: ReplayBarrierReason | undefined private stateful = false - constructor(private readonly sessionId: string) {} + constructor( + private readonly sessionId: string, + private readonly state: ReplaySafetyState = {}, + ) { + this.stateful = state.statefulBarrier !== undefined + } markBarrier(reason: ReplayBarrierReason): void { - if (!OUTPUT_ONLY_BARRIERS.has(reason)) this.stateful = true + if (!OUTPUT_ONLY_BARRIERS.has(reason)) { + this.stateful = true + this.state.statefulBarrier ??= reason + } if (this.barrierReason) return this.barrierReason = reason trace(`replay barrier: reason=${reason} sessionId=${this.sessionId}`) } applyTo(failure: CursorProviderError): CursorProviderError { - failure.replaySafe = this.barrierReason === undefined && failure.replaySafe + failure.replaySafe = this.barrierReason === undefined && !this.stateful && failure.replaySafe if (this.barrierReason) { failure.replayBarrier = this.stateful ? "stateful" : "output-only" trace( diff --git a/src/session.ts b/src/session.ts index 45f7125a..9fb6deba 100644 --- a/src/session.ts +++ b/src/session.ts @@ -16,6 +16,7 @@ import type { RunHistoryImages } from "./protocol/sent-history-images.js" import type { ParallelStepState } from "./parallel-step.js" import type { OpenCodeModelCost } from "./pricing.js" import type { RunCostEstimate } from "./billing.js" +import type { ReplaySafetyState } from "./replay-safety.js" export type Frame = { flags: number; payload: Uint8Array } @@ -206,10 +207,14 @@ export type CursorSession = { /** Latest eligible checkpoint emitted by this Run attempt. */ resumeCheckpoint?: Uint8Array /** - * This Run sent a user message after its last checkpoint: recovery must not - * resume an earlier checkpoint of the turn, which lacks that message. + * This Run sent a user message or tool result after its last checkpoint: + * recovery must not resume earlier state that lacks the delivered input. */ checkpointRevoked?: boolean + /** Ignore queued checkpoints received before the latest message/result delivery. */ + resumeCheckpointAfterFrame?: number + /** Stateful replay barriers survive host tool-step boundaries on this Run. */ + replaySafety?: ReplaySafetyState /** History images this Run's checkpoints hold or may hold; absent for a Run outside an OpenCode session. */ historyImages?: RunHistoryImages /** Last checkpoint-derived context snapshot, independent of retry eligibility. */ diff --git a/tasks/lessons/opencode.md b/tasks/lessons/opencode.md index 6377bf92..55f46652 100644 --- a/tasks/lessons/opencode.md +++ b/tasks/lessons/opencode.md @@ -137,9 +137,15 @@ These are failure lessons shared by both hosts, not a replacement for ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100)). Cursor CLI resends the action, resumes the turn's latest checkpoint, and stops after two resumes without a new one. Record every barrier, not the first: - text before a stateful frame must not read as output-only - (`AttemptReplaySafety`, `test/run-interruption.test.ts`, - `test/run-error-host.test.ts`). + text before a stateful frame must not read as output-only. Keep stateful + barriers on the Run across tool-step pumps, and retain checkpoints before + credential renewal as well as transient recovery; otherwise a completed + command becomes host-retryable or a renewed attempt forgets its saved state. + Result delivery and follow-up user actions invalidate older checkpoints; + compare frame arrival order so a queued pre-result checkpoint cannot become + eligible just because the pump reads it after delivery + (`AttemptReplaySafety`, `test/parallel-pump.test.ts`, + `test/run-interruption.test.ts`, `test/run-error-host.test.ts`). - **A helper must not take over its parent.** A smaller catalog was mistaken for a helper despite carrying the parent's pending result. Reconcile that result first; isolate genuine helpers (`test/fresh-turn-drain.test.ts`). An empty diff --git a/test/display-bridge-pump.test.ts b/test/display-bridge-pump.test.ts index b49c29a7..8360d824 100644 --- a/test/display-bridge-pump.test.ts +++ b/test/display-bridge-pump.test.ts @@ -2013,6 +2013,20 @@ describe("progress-only continuation pump", () => { expect(session.closed).toBe(true) }) + it("revokes the earlier checkpoint when a progress follow-up opens but drops", async () => { + const session = fakeSession([textDeltaPayload("Checking the workspace"), turnEndedPayload()], []) + session.resumeCheckpoint = Uint8Array.of(1, 2, 3) + session.reopenWithUserMessage = async () => { session.frames = iteratorFrom([]) } + const controller = { + enqueue() {}, + error(error: unknown) { throw error }, + } as unknown as ReadableStreamDefaultController + await expect(pump(session, controller, { textId: "text", reasoningId: "reasoning" })) + .rejects.toMatchObject({ transient: true, replaySafe: false }) + expect(session.resumeCheckpoint).toBeUndefined() + expect(session.checkpointRevoked).toBe(true) + }) + it("does not reopen a complete answer that starts with a progress verb", async () => { const writes: Uint8Array[] = [] const session = fakeSession( diff --git a/test/parallel-pump.test.ts b/test/parallel-pump.test.ts index 99610a59..812eecea 100644 --- a/test/parallel-pump.test.ts +++ b/test/parallel-pump.test.ts @@ -545,6 +545,52 @@ describe("parallel tool-call pump", () => { expect(session.closed).toBe(true) }) + it.each([false, true])("keeps a completed host tool as a replay barrier in the next pump (prior checkpoint: %s)", async checkpointed => { + const { session } = fixture([ + ...(checkpointed ? [frame({ conversation_checkpoint_update: Uint8Array.of(10, 1, 1) })] : []), + shell(1), text("after the command"), + ]) + const first = collect() + await pump(session, first.controller, ids) + const call = toolCalls(first.parts)[0]! + expect(call).toBeDefined() + await deliverContinuationResults(session, [{ + toolCallId: call.toolCallId, sessionId: session.sessionId, execId: 1, + toolName: call.toolName, output: "command completed", + }]) + const failure = await pumpWithRecovery({ + initialSession: session, + controller: collect().controller, + recover: async () => { throw new Error("A completed command must not be replayed") }, + }).catch((error: unknown) => error) + expect(failure).toMatchObject({ retryable: false, replaySafe: false }) + expect((failure as { hostRetryable?: boolean }).hostRetryable).toBeUndefined() + }) + + it("resumes a checkpoint received after a completed host tool result", async () => { + const checkpoint = Uint8Array.of(10, 1, 2) + const { session } = fixture([ + shell(1), frame({ conversation_checkpoint_update: checkpoint }), text("after the result"), + ]) + const first = collect() + await pump(session, first.controller, ids) + const call = toolCalls(first.parts)[0]! + await deliverContinuationResults(session, [{ + toolCallId: call.toolCallId, sessionId: session.sessionId, execId: 1, + toolName: call.toolName, output: "command completed", + }]) + const recoveries: unknown[] = [] + await pumpWithRecovery({ + initialSession: session, + controller: collect().controller, + recover: async recovery => { + recoveries.push(recovery) + return fixture([ended()]).session + }, + }) + expect(recoveries).toEqual([{ kind: "resume", conversationId: session.conversationId, checkpoint }]) + }) + it("retains a guard-timed-out iterator read for the continuation", async () => { noteToolRequestsListedSeen() const { session } = fixture([]) diff --git a/test/run-error-host.test.ts b/test/run-error-host.test.ts index 1df2f79f..3a6c6a86 100644 --- a/test/run-error-host.test.ts +++ b/test/run-error-host.test.ts @@ -4,7 +4,7 @@ import http2 from "node:http2" import net from "node:net" import os from "node:os" import path from "node:path" -import { APICallError, type LanguageModelV3CallOptions } from "@ai-sdk/provider" +import { APICallError, type LanguageModelV3CallOptions, type LanguageModelV3StreamPart } from "@ai-sdk/provider" import { createCursor } from "../src/index.js" import { CursorAuthError, CursorRetryExhaustedError, CursorServerError } from "../src/errors.js" import { decodeMessage, encodeMessage } from "../src/protocol/messages.js" @@ -26,6 +26,7 @@ let client: http2.ClientHttp2Session let trailer: Record = {} let runs = 0 let partialOutput = false +let scripted: ((stream: http2.ServerHttp2Stream, message: Record) => void) | undefined const savedProxy: Record = {} function frame(payload: Uint8Array, flags = 0): Buffer { @@ -52,6 +53,7 @@ beforeAll(async () => { if (pending.length < 5 + length) break const message = decodeMessage>("AgentClientMessage", pending.subarray(5, 5 + length)) pending = pending.subarray(5 + length) + if (scripted) { scripted(stream, message); continue } if (!message.run_request) continue runs++ if (partialOutput) { @@ -95,6 +97,7 @@ afterEach(() => { resetCheckpointsForTests() runs = 0 partialOutput = false + scripted = undefined }) async function streamError(sessionKey: string): Promise { @@ -122,6 +125,78 @@ async function streamError(sessionKey: string): Promise { } } +describe("checkpoint eligibility across host results", () => { + for (const afterResult of [false, true]) { + it(`only resumes a checkpoint received after the result (after: ${afterResult})`, async () => { + const checkpoint = Uint8Array.of(10, 1, 7) + let resumed = false + scripted = (stream, message) => { + const send = (message: Record) => stream.write(frame(encodeMessage("AgentServerMessage", message))) + if (message.run_request) { + runs++ + resumed = !!(message.run_request as { action?: { resume_action?: unknown } }).action?.resume_action + if (resumed) { + send({ interaction_update: { turn_ended: {} } }) + stream.end() + } else { + send({ exec_server_message: { id: 1, shell_stream_args: { command: "echo done", tool_call_id: "command" } } }) + // This frame arrives while the host tool is outstanding. The held + // reader may queue it until after the host has delivered its result. + if (!afterResult) send({ conversation_checkpoint_update: checkpoint }) + } + } else if (message.exec_client_message) { + if (afterResult) send({ conversation_checkpoint_update: checkpoint }) + send({ interaction_update: { thinking_delta: { text: "after the command" } } }) + stream.end(frame(Buffer.from('{"error":{"code":"unavailable"}}'), 2)) + } + } + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-result-checkpoint-")) + cacheHttp2SessionForTests(ORIGIN, client) + try { + const model = createCursor({ + name: "cursor", accessToken: "test-token", agentBaseURL: ORIGIN, cacheDir: root, + retry: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, + }).languageModel("composer-2.5") + const prompt: LanguageModelV3CallOptions["prompt"] = [{ role: "user", content: [{ type: "text", text: "Run echo done" }] }] + const options = { + prompt, + tools: [{ type: "function" as const, name: "bash", inputSchema: { type: "object", properties: { command: { type: "string" } } } }], + headers: { "x-opencode-session-id": `ses_result_checkpoint_${afterResult}` }, + } + const read = async (options: LanguageModelV3CallOptions) => { + const parts: LanguageModelV3StreamPart[] = [] + const { stream } = await model.doStream(options) + const reader = stream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) return parts + parts.push(value) + } + } + const first = await read(options) + const call = first.find(part => part.type === "tool-call") + if (!call || call.type !== "tool-call") throw new Error("Expected the shell call") + await new Promise(resolve => setImmediate(resolve)) + prompt.push({ role: "assistant", content: [{ type: "tool-call", toolCallId: call.toolCallId, toolName: call.toolName, input: JSON.parse(call.input) }] }) + prompt.push({ role: "tool", content: [{ type: "tool-result", toolCallId: call.toolCallId, toolName: call.toolName, output: { type: "text", value: "done" } }] }) + if (afterResult) { + const parts = await read(options) + expect(parts.some(part => part.type === "finish" && part.finishReason.unified === "stop")).toBe(true) + expect(resumed).toBe(true) + expect(runs).toBe(2) + } else { + const error = await read(options).catch((error: unknown) => error) + expect(error).toMatchObject({ isRetryable: false, responseHeaders: { "x-should-retry": "false" } }) + expect(resumed).toBe(false) + expect(runs).toBe(1) + } + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }, 30_000) + } +}) + describe("Run errors as the host sees them", () => { for (const method of ["doStream", "doGenerate"] as const) { for (const failure of ["empty token", "rejected login"] as const) { diff --git a/test/run-interruption.test.ts b/test/run-interruption.test.ts index ef2277f3..10c105d3 100644 --- a/test/run-interruption.test.ts +++ b/test/run-interruption.test.ts @@ -447,6 +447,33 @@ describe("interrupted Cursor Run handling", () => { expect(renewals).toBe(0) }) + it("retains a checkpoint across credential renewal and a later output-only failure", async () => { + const checkpoint = Uint8Array.from([0x0a, 0x01, 0x09]) + const recoveries: CursorRunRecovery[] = [] + const finalSession = await pumpWithRecovery({ + initialSession: fakeSession("checkpoint-before-renewal", [ + serverFrame({ conversation_checkpoint_update: checkpoint }), + endStreamError("unauthenticated"), + ]), + controller: controller([]), + retryPolicy: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 }, + renewRejectedCredential: () => {}, + recover: async (recovery) => { + recoveries.push(recovery) + return recoveries.length === 1 + ? fakeSession("renewed-without-checkpoint", [ + serverFrame({ interaction_update: { thinking_delta: { text: "continuing" } } }), + ]) + : fakeSession("renewed-recovered", [turnEnded()]) + }, + }) + expect(finalSession.sessionId).toBe("renewed-recovered") + expect(recoveries).toHaveLength(2) + for (const recovery of recoveries) { + expect(recovery).toMatchObject({ kind: "resume", checkpoint }) + } + }) + it("does not renew when the source cannot, after visible output, or on permission_denied", async () => { const cases = [ { session: fakeSession("static", [endStreamError("unauthenticated")]), renew: false }, @@ -577,6 +604,26 @@ describe("interrupted Cursor Run handling", () => { expect(parts.filter((part) => part.type === "finish")).toHaveLength(1) }) + it("does not count a prior tool-step barrier as streaming from a silent resumed attempt", async () => { + const checkpoint = Uint8Array.from([0x0a, 0x01, 0x08]) + const initial = fakeSession("prior-tool-checkpoint", [ + serverFrame({ conversation_checkpoint_update: checkpoint }), + ]) + initial.replaySafety = { statefulBarrier: "non-control-exec" } + let recoveries = 0 + const finalSession = await pumpWithRecovery({ + initialSession: initial, + controller: controller([]), + retryPolicy: { maxAttempts: 4, baseDelayMs: 0, maxDelayMs: 0 }, + recover: async () => { + recoveries++ + return fakeSession(`silent-resume-${recoveries}`, recoveries === 3 ? [turnEnded()] : []) + }, + }) + expect(finalSession.sessionId).toBe("silent-resume-3") + expect(recoveries).toBe(3) + }) + it("stops after two resumed attempts stream without saving a checkpoint, like Cursor CLI", async () => { const checkpoint = Uint8Array.from([0x0a, 0x01, 0x06]) let recoveries = 0 From f93310e83b20ebc5f8586e4b1710c8c558f4a870 Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:44:54 +0200 Subject: [PATCH 03/11] fix: preserve follow-up recovery and credential renewal --- CHANGELOG.md | 2 +- README.md | 2 +- docs/opencode-1.md | 6 ++- docs/opencode-2.md | 6 ++- src/language-model.ts | 22 ++++++--- tasks/lessons/opencode.md | 6 ++- test/early-steer.test.ts | 29 ++++++++++- test/run-error-host.test.ts | 91 +++++++++++++++++++++++++++++++++-- test/run-interruption.test.ts | 79 ++++++++++++++++++++++++++++++ 9 files changed, 221 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d872c4be..63fe9a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Interrupted Runs retain their checkpoint across credential renewal. Completed tools remain replay barriers across host steps, and checkpoints received before a tool result or follow-up message are not used to resume it ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) +- Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. Completed tools remain replay barriers across host steps. Follow-ups accept checkpoints received while opening the new Run, and older checkpoints stay ineligible after a tool result or follow-up message ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) ## [0.8.3] - 2026-10-17 diff --git a/README.md b/README.md index 3fcc8bdb..d6b07333 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ const model = cursor.languageModel("composer-2.5") // model implements AI SDK LanguageModelV3 (doStream / doGenerate) ``` -Give one credential source. The provider uses the first present and never falls back from one to another: `getAccessToken` (an async function returning the current access token, called for every Run so a long-running process can renew its login; it receives `{ forceRefresh: true }` once after Cursor rejects a token), then `accessToken` (a JWT sent as-is, never renewed), then `apiKey` (a raw `crsr_...` key, exchanged for a JWT and exchanged again near its expiry, the way Cursor CLI renews an API-key login). Optional: `apiBaseURL`, `agentBaseURL`, `cacheDir`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. `cacheDir` pins the host cache root for model/version caches and Cursor project metadata; when omitted, the provider uses an injected structural host path bridge or the native OpenCode XDG cache described in [Paths](#paths-host-cache). Transient failures resume from the latest checkpoint produced by that Run, matching Cursor CLI; without an eligible checkpoint, retries remain limited to replay-safe attempts so completed text or tool work is not duplicated. Once that budget is spent, or replay is unsafe, the terminal error does not re-arm OpenCode's outer retry loop. Pending-tool inactivity is renewed by OpenCode activity from the session or its descendants. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`. +Give one credential source. The provider uses the first present and never falls back from one to another: `getAccessToken` (an async function returning the current access token, called for every Run so a long-running process can renew its login; it receives `{ forceRefresh: true }` once after Cursor rejects a token), then `accessToken` (a JWT sent as-is, never renewed), then `apiKey` (a raw `crsr_...` key, exchanged for a JWT and exchanged again near its expiry, the way Cursor CLI renews an API-key login). Optional: `apiBaseURL`, `agentBaseURL`, `cacheDir`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. `cacheDir` pins the host cache root for model/version caches and Cursor project metadata; when omitted, the provider uses an injected structural host path bridge or the native OpenCode XDG cache described in [Paths](#paths-host-cache). An untouched checkpoint resume may renew a rejected credential once, while keeping completed tools behind the replay barrier. Follow-up Runs retain checkpoints received while opening. Transient failures resume from the latest checkpoint produced by that Run, matching Cursor CLI; without an eligible checkpoint, retries remain limited to replay-safe attempts so completed text or tool work is not duplicated. Once that budget is spent, or replay is unsafe, the terminal error does not re-arm OpenCode's outer retry loop. Pending-tool inactivity is renewed by OpenCode activity from the session or its descendants. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`. ### Shell results diff --git a/docs/opencode-1.md b/docs/opencode-1.md index 23e7b707..8dc8567d 100644 --- a/docs/opencode-1.md +++ b/docs/opencode-1.md @@ -142,8 +142,10 @@ stored messages are preserved. OpenCode retries a transient failure after only text or reasoning when the turn has no new checkpoint. Stateful activity, including a completed tool from an earlier host step, stays a replay barrier. Recovery retains the latest -eligible checkpoint across token renewal; two resumed attempts that stream -without saving a checkpoint end the turn. +eligible checkpoint across token renewal, including a rejected credential on +an untouched resumed attempt. Follow-ups retain checkpoints received while +opening the new Run. Two resumed attempts that stream without saving a +checkpoint end the turn. ## Troubleshooting diff --git a/docs/opencode-2.md b/docs/opencode-2.md index cfcf9dfe..beb6c31d 100644 --- a/docs/opencode-2.md +++ b/docs/opencode-2.md @@ -213,8 +213,10 @@ patch operations that would lose CRLF bytes before changing a file. Native OpenCode retries a transient failure after only text or reasoning when the turn has no new checkpoint. Stateful activity, including a completed tool from an earlier host step, stays a replay barrier. Recovery retains the latest -eligible checkpoint across token renewal; two resumed attempts that stream -without saving a checkpoint end the turn. +eligible checkpoint across token renewal, including a rejected credential on +an untouched resumed attempt. Follow-ups retain checkpoints received while +opening the new Run. Two resumed attempts that stream without saving a +checkpoint end the turn. ## Mid-turn steers and Stop diff --git a/src/language-model.ts b/src/language-model.ts index ff73aef4..03687c5a 100644 --- a/src/language-model.ts +++ b/src/language-model.ts @@ -832,10 +832,10 @@ function noteRunImageCheckpoint(session: CursorSession, checkpoint: Uint8Array, } /** A resumed model step cannot replay a result or follow-up absent from its checkpoint. */ -function invalidateResumeCheckpoint(session: CursorSession): void { +function invalidateResumeCheckpoint(session: CursorSession, afterFrame = lastFrameArrival()): void { session.resumeCheckpoint = undefined session.checkpointRevoked = true - session.resumeCheckpointAfterFrame = lastFrameArrival() + session.resumeCheckpointAfterFrame = afterFrame } /** A queued checkpoint predating a delivered result still describes the old step. */ @@ -1359,11 +1359,16 @@ export async function pumpWithRecovery(input: { } else if (pumpedSession.checkpointRevoked) { turnCheckpoint = undefined } + const checkpoint = turnCheckpoint + // The inherited tool barrier forbids replaying the action, but an untouched + // replacement may renew and resume its checkpoint without replaying tools. + const untouchedCheckpointResume = resumedAttempt && checkpoint !== undefined + && failure.replayBarrier === undefined && !failure.checkpointUnusable if ( input.renewRejectedCredential && !credentialRenewed && isRejectedCredentialError(failure) - && failure.replaySafe + && (failure.replaySafe || untouchedCheckpointResume) ) { credentialRenewed = true trace( @@ -1375,7 +1380,7 @@ export async function pumpWithRecovery(input: { session = await reopen( pumpedSession, failure, - pumpedSession.resumeCheckpoint ?? (pumpedSession.checkpointRevoked ? undefined : turnCheckpoint), + checkpoint, ) attempt-- continue @@ -1392,7 +1397,6 @@ export async function pumpWithRecovery(input: { throw noProgressResumeError(failure, noProgressResumes) } } - const checkpoint = turnCheckpoint if (!failure.replaySafe && !checkpoint) { // Cursor CLI sends the same action again. Shown output keeps the // provider from doing so; the host can, unless a side effect would repeat. @@ -5087,9 +5091,10 @@ export async function pump( trace(`steer: Cursor did not take ${steers.undelivered.length} early steer(s); sending them as a follow-up Run`) if (session.openCodeSessionId) resumeEmittedStep(session.openCodeSessionId, session.conversationId) try { + const beforeFollowup = lastFrameArrival() await session.reopenWithUserMessage(steers.undelivered.map((steer) => steer.text).join("\n\n"), abortSignal) - // Resuming the checkpoint from before the follow-up would send the steer again. - invalidateResumeCheckpoint(session) + // Revoke the old state, admitting checkpoints received while reopening. + invalidateResumeCheckpoint(session, beforeFollowup) endingTurns.delete(session) textBreakPending = true continue @@ -5121,13 +5126,14 @@ export async function pump( trace("progress-only: continuing attempt=1") if (session.openCodeSessionId) resumeEmittedStep(session.openCodeSessionId, session.conversationId) try { + const beforeFollowup = lastFrameArrival() await session.reopenWithUserMessage( progressOnlyContinuationPrompt( workspaceRootFromRequestContext(session.requestContext), ), abortSignal, ) - invalidateResumeCheckpoint(session) + invalidateResumeCheckpoint(session, beforeFollowup) endingTurns.delete(session) assistantText = "" continue diff --git a/tasks/lessons/opencode.md b/tasks/lessons/opencode.md index 55f46652..dcc2788e 100644 --- a/tasks/lessons/opencode.md +++ b/tasks/lessons/opencode.md @@ -143,7 +143,11 @@ These are failure lessons shared by both hosts, not a replacement for command becomes host-retryable or a renewed attempt forgets its saved state. Result delivery and follow-up user actions invalidate older checkpoints; compare frame arrival order so a queued pre-result checkpoint cannot become - eligible just because the pump reads it after delivery + eligible just because the pump reads it after delivery. Capture a follow-up's + arrival cutoff before opening its replacement Run: its checkpoint can arrive + before the old stream finishes draining. An inherited tool barrier still + permits one credential renewal on an untouched resume with an eligible + checkpoint; new output or stateful activity must prevent it (`AttemptReplaySafety`, `test/parallel-pump.test.ts`, `test/run-interruption.test.ts`, `test/run-error-host.test.ts`). - **A helper must not take over its parent.** A smaller catalog was mistaken for diff --git a/test/early-steer.test.ts b/test/early-steer.test.ts index 2cc426de..618f0c65 100644 --- a/test/early-steer.test.ts +++ b/test/early-steer.test.ts @@ -13,7 +13,7 @@ import { createCursor } from "../src/index.js" import { decodeMessage, encodeMessage } from "../src/protocol/messages.js" import { buildHeartbeat } from "../src/protocol/request.js" import { resetToolRequestsListedSeenForTests } from "../src/parallel-step.js" -import { cacheHttp2SessionForTests, closeCachedHttp2SessionsForTests } from "../src/transport/connect.js" +import { cacheHttp2SessionForTests, closeCachedHttp2SessionsForTests, lastFrameArrival } from "../src/transport/connect.js" import { setHostCacheDirOverride } from "../src/context/paths.js" import { sessionManager, type CursorSession } from "../src/session.js" import { resetTurnProvenanceForTests } from "../src/protocol/turn-provenance.js" @@ -830,7 +830,7 @@ describe("a steer sent while Cursor waits inside a step", () => { }) }, 30_000) - for (const mode of ["taken", "not taken", "follow-up fails", "follow-up drops"] as const) { + for (const mode of ["taken", "not taken", "follow-up fails", "follow-up drops", "follow-up checkpoint"] as const) { it(`continues the held Run when OpenCode adds its copy after the step's tool results (${mode})`, async () => { const delivered = mode === "taken" await withModel(`tools-${mode.replaceAll(" ", "-")}`, async (model, cacheDir) => { @@ -846,6 +846,10 @@ describe("a steer sent while Cursor waits inside a step", () => { if (message.exec_client_message?.read_result) { if (delivered) injectionState(run, injectionId, "delivered") endTurn(run, delivered ? "Read it. STEERED" : "Read it.") + } else if (message.run_request && mode === "follow-up checkpoint" && runs.indexOf(run) === 1) { + run.send({ conversation_checkpoint_update: Uint8Array.of(10, 1, 42) }) + run.send({ interaction_update: { thinking_delta: { text: "Taking the follow-up" } } }) + run.end("unavailable") } else if (message.run_request && mode === "follow-up drops" && runs.indexOf(run) === 1) { // The follow-up Run drops before any checkpoint holds the steer. run.send({ interaction_update: { text_delta: { text: "STEER" } } }) @@ -863,6 +867,15 @@ describe("a steer sent while Cursor waits inside a step", () => { throw new Error("connection refused") } } + if (mode === "follow-up checkpoint") { + const session = sessionManager.findOpenByOpenCodeSessionId(sessionKey)! + const reopen = session.reopenWithUserMessage! + session.reopenWithUserMessage = async (text, signal) => { + const before = lastFrameArrival() + await reopen(text, signal) + await waitFor(() => lastFrameArrival() > before, "the follow-up checkpoint before reopen returns") + } + } const step = readStep(call, file, message.text) const continuation = collect(model, step, sessionKey) if (mode === "follow-up fails") { @@ -881,6 +894,18 @@ describe("a steer sent while Cursor waits inside a step", () => { return } const second = await continuation + if (mode === "follow-up checkpoint") { + expect(runs).toHaveLength(3) + expect(userText(runs[1]!)).toBe(message.text) + expect(runs[2]!.received[0]!.run_request).toMatchObject({ + action: { resume_action: {} }, conversation_state: Uint8Array.of(10, 1, 42), + conversation_id: runs[0]!.received[0]!.run_request.conversation_id, + }) + expect(textOf(second)).toStartWith("Read it.") + expect(textOf(second)).toEndWith("STEERED") + expect(runs[0]!.received.filter(m => m.exec_client_message?.read_result)).toHaveLength(1) + return + } expect(runs[0]!.received.filter((m) => m.exec_client_message?.read_result)).toHaveLength(1) expect(runs[0]!.received.filter((m) => m.conversation_action?.cancel_action)).toEqual([]) expect(injectionsOf(runs[0]!)).toHaveLength(1) diff --git a/test/run-error-host.test.ts b/test/run-error-host.test.ts index 3a6c6a86..9e097fa6 100644 --- a/test/run-error-host.test.ts +++ b/test/run-error-host.test.ts @@ -8,7 +8,7 @@ import { APICallError, type LanguageModelV3CallOptions, type LanguageModelV3Stre import { createCursor } from "../src/index.js" import { CursorAuthError, CursorRetryExhaustedError, CursorServerError } from "../src/errors.js" import { decodeMessage, encodeMessage } from "../src/protocol/messages.js" -import { cacheHttp2SessionForTests, closeCachedHttp2SessionsForTests } from "../src/transport/connect.js" +import { cacheHttp2SessionForTests, closeCachedHttp2SessionsForTests, lastFrameArrival } from "../src/transport/connect.js" import { setHostCacheDirOverride } from "../src/context/paths.js" import { sessionManager } from "../src/session.js" import { resetTurnStateForTests } from "../src/language-model.js" @@ -126,16 +126,21 @@ async function streamError(sessionKey: string): Promise { } describe("checkpoint eligibility across host results", () => { - for (const afterResult of [false, true]) { - it(`only resumes a checkpoint received after the result (after: ${afterResult})`, async () => { + for (const [afterResult, rejectReplacement] of [[false, false], [true, false], [true, true]] as const) { + it(`only resumes a checkpoint received after the result (after: ${afterResult}, expired replacement: ${rejectReplacement})`, async () => { const checkpoint = Uint8Array.of(10, 1, 7) let resumed = false + let renewals = 0 scripted = (stream, message) => { const send = (message: Record) => stream.write(frame(encodeMessage("AgentServerMessage", message))) if (message.run_request) { runs++ resumed = !!(message.run_request as { action?: { resume_action?: unknown } }).action?.resume_action if (resumed) { + if (rejectReplacement && runs === 2) { + stream.end(frame(Buffer.from('{"error":{"code":"unauthenticated"}}'), 2)) + return + } send({ interaction_update: { turn_ended: {} } }) stream.end() } else { @@ -154,7 +159,11 @@ describe("checkpoint eligibility across host results", () => { cacheHttp2SessionForTests(ORIGIN, client) try { const model = createCursor({ - name: "cursor", accessToken: "test-token", agentBaseURL: ORIGIN, cacheDir: root, + name: "cursor", agentBaseURL: ORIGIN, cacheDir: root, + getAccessToken: async request => { + if (request?.forceRefresh) renewals++ + return renewals ? "renewed-test-token" : "test-token" + }, retry: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, }).languageModel("composer-2.5") const prompt: LanguageModelV3CallOptions["prompt"] = [{ role: "user", content: [{ type: "text", text: "Run echo done" }] }] @@ -183,7 +192,8 @@ describe("checkpoint eligibility across host results", () => { const parts = await read(options) expect(parts.some(part => part.type === "finish" && part.finishReason.unified === "stop")).toBe(true) expect(resumed).toBe(true) - expect(runs).toBe(2) + expect(runs).toBe(rejectReplacement ? 3 : 2) + expect(renewals).toBe(rejectReplacement ? 1 : 0) } else { const error = await read(options).catch((error: unknown) => error) expect(error).toMatchObject({ isRetryable: false, responseHeaders: { "x-should-retry": "false" } }) @@ -197,6 +207,77 @@ describe("checkpoint eligibility across host results", () => { } }) +describe("follow-up checkpoint arrival", () => { + it("resumes a progress follow-up checkpoint received before reopen returns", async () => { + const checkpoint = Uint8Array.of(10, 1, 8) + let firstRun: http2.ServerHttp2Stream | undefined + const requests: Record[] = [] + scripted = (run, message) => { + if (!message.run_request) return + const request = message.run_request as Record + requests.push(request) + const send = (message: Record) => run.write(frame(encodeMessage("AgentServerMessage", message))) + if (requests.length === 1) { + firstRun = run + send({ conversation_checkpoint_update: Uint8Array.of(10, 1, 7) }) + send({ interaction_update: { text_delta: { text: "Checking the workspace" } } }) + } else if (requests.length === 2) { + send({ conversation_checkpoint_update: checkpoint }) + send({ interaction_update: { thinking_delta: { text: "Continuing the follow-up" } } }) + run.end(frame(Buffer.from('{"error":{"code":"unavailable"}}'), 2)) + } else { + send({ interaction_update: { text_delta: { text: "Finished." } } }) + send({ interaction_update: { turn_ended: {} } }) + run.end() + } + } + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-followup-checkpoint-")) + cacheHttp2SessionForTests(ORIGIN, client) + try { + const key = "ses_progress_early_checkpoint" + const model = createCursor({ + name: "cursor", accessToken: "test-token", agentBaseURL: ORIGIN, cacheDir: root, workspaceRoot: root, + retry: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 }, + }).languageModel("composer-2.5") + const { stream } = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Check the workspace" }] }], + tools: [{ type: "function", name: "bash", inputSchema: { type: "object", properties: { command: { type: "string" } } } }], + headers: { "x-opencode-session-id": key }, + }) + let wrapped = false + let arrivedBeforeReopenReturned = false + const parts: LanguageModelV3StreamPart[] = [] + const reader = stream.getReader() + for (;;) { + const { done, value: part } = await reader.read() + if (done) break + parts.push(part) + if (part.type !== "text-delta" || wrapped) continue + wrapped = true + const session = sessionManager.findOpenByOpenCodeSessionId(key)! + const reopen = session.reopenWithUserMessage! + session.reopenWithUserMessage = async (text, signal) => { + const before = lastFrameArrival() + await reopen(text, signal) + // Force the replacement's frames into the transport queue while the + // caller still awaits reopen (as while draining the previous stream). + for (let i = 0; i < 100 && lastFrameArrival() === before; i++) await Bun.sleep(1) + arrivedBeforeReopenReturned = lastFrameArrival() > before + expect(arrivedBeforeReopenReturned).toBe(true) + } + firstRun!.write(frame(encodeMessage("AgentServerMessage", { interaction_update: { turn_ended: {} } }))) + } + expect(arrivedBeforeReopenReturned).toBe(true) + expect(requests).toHaveLength(3) + expect(requests[2]).toMatchObject({ action: { resume_action: {} }, conversation_state: checkpoint, + conversation_id: requests[0]!.conversation_id }) + expect(parts.some(part => part.type === "text-delta" && part.delta === "Finished.")).toBe(true) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } + }, 30_000) +}) + describe("Run errors as the host sees them", () => { for (const method of ["doStream", "doGenerate"] as const) { for (const failure of ["empty token", "rejected login"] as const) { diff --git a/test/run-interruption.test.ts b/test/run-interruption.test.ts index 10c105d3..8dcf69f4 100644 --- a/test/run-interruption.test.ts +++ b/test/run-interruption.test.ts @@ -474,6 +474,85 @@ describe("interrupted Cursor Run handling", () => { } }) + it.each([false, true])("renews an untouched checkpoint resume after an earlier tool barrier (new checkpoint: %s)", async newCheckpoint => { + const checkpoint = Uint8Array.of(10, 1, 11) + const replacementCheckpoint = Uint8Array.of(10, 1, 12) + const initial = fakeSession("tool-before-renewal", [ + serverFrame({ conversation_checkpoint_update: checkpoint }), + endStreamError("unavailable"), + ]) + initial.replaySafety = { statefulBarrier: "non-control-exec" } + const rejected = fakeSession("untouched-resume", [ + ...(newCheckpoint ? [serverFrame({ conversation_checkpoint_update: replacementCheckpoint })] : []), + endStreamError("unauthenticated"), + ]) + const renewed = fakeSession("renewed-resume", [turnEnded()]) + rejected.conversationId = renewed.conversationId = initial.conversationId + const recoveries: CursorRunRecovery[] = [] + let renewals = 0 + const final = await pumpWithRecovery({ + initialSession: initial, + controller: controller([]), + retryPolicy: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 }, + renewRejectedCredential: () => { renewals++ }, + recover: async recovery => { + recoveries.push(recovery) + return recoveries.length === 1 ? rejected : renewed + }, + }) + expect(final).toBe(renewed) + expect(renewals).toBe(1) + expect(recoveries).toEqual([ + { kind: "resume", conversationId: initial.conversationId, checkpoint }, + { kind: "resume", conversationId: initial.conversationId, checkpoint: newCheckpoint ? replacementCheckpoint : checkpoint }, + ]) + expect(renewed.replaySafety).toEqual(initial.replaySafety) + }) + + it.each(["output", "tool display", "revoked checkpoint"])("does not renew a resumed credential after %s", async activity => { + const initial = fakeSession("before-resume-activity", [ + serverFrame({ conversation_checkpoint_update: Uint8Array.of(10, 1, 13) }), + ]) + initial.replaySafety = { statefulBarrier: "non-control-exec" } + const rejected = fakeSession("resume-activity", [ + ...(activity === "output" ? [serverFrame({ interaction_update: { text_delta: { text: "Visible progress" } } })] : []), + ...(activity === "tool display" ? [serverFrame({ interaction_update: { tool_call_started: { call_id: "new-tool" } } })] : []), + endStreamError("unauthenticated"), + ]) + rejected.checkpointRevoked = activity === "revoked checkpoint" + let renewals = 0 + let recoveries = 0 + const failure = await pumpWithRecovery({ + initialSession: initial, + controller: controller([]), + retryPolicy: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 }, + renewRejectedCredential: () => { renewals++ }, + recover: async () => { recoveries++; return rejected }, + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CursorAuthError) + expect(recoveries).toBe(1) + expect(renewals).toBe(0) + }) + + it("never renews twice after a checkpointed tool result", async () => { + const initial = fakeSession("tool-before-two-rejections", [ + serverFrame({ conversation_checkpoint_update: Uint8Array.of(10, 1, 14) }), + ]) + initial.replaySafety = { statefulBarrier: "non-control-exec" } + let renewals = 0 + let recoveries = 0 + const failure = await pumpWithRecovery({ + initialSession: initial, + controller: controller([]), + retryPolicy: { maxAttempts: 2, baseDelayMs: 0, maxDelayMs: 0 }, + renewRejectedCredential: () => { renewals++ }, + recover: async () => { recoveries++; return fakeSession(`rejection-${recoveries}`, [endStreamError("unauthenticated")]) }, + }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CursorAuthError) + expect(recoveries).toBe(2) + expect(renewals).toBe(1) + }) + it("does not renew when the source cannot, after visible output, or on permission_denied", async () => { const cases = [ { session: fakeSession("static", [endStreamError("unauthenticated")]), renew: false }, From 9cd2fe6cc831636a04e3c390ff27fa085743c5f0 Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:52:41 +0200 Subject: [PATCH 04/11] docs: keep pending changelog entries in 0.8.3 --- CHANGELOG.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63fe9a7a..ed21f212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,6 @@ ## [Unreleased] -### Fixed - -- Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. Completed tools remain replay barriers across host steps. Follow-ups accept checkpoints received while opening the new Run, and older checkpoints stay ineligible after a tool result or follow-up message ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - ## [0.8.3] - 2026-10-17 ### Added @@ -33,6 +29,7 @@ - Like Cursor CLI, errors that need your action (usage or rate limits, sign-in, pricing, API key, outdated client), a bare `permission_denied`, and authentication failures, including those before a Run opens, end the turn instead of being retried; on OpenCode 1.x this also covers final `internal`/`unknown` errors. Final Cursor details take precedence over token renewal ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Once the provider spends its retry budget, or cannot replay a Run without repeating a tool call or other side effect, OpenCode no longer starts another retry loop ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Like Cursor CLI, a Run that drops or stalls after showing only text or reasoning, before Cursor saved a checkpoint, is retried by OpenCode instead of ending the turn, and the request reaches Cursor in that retry instead of with your next message. A retry after a checkpoint resumes the turn's latest checkpoint, and two resumes that save nothing end the turn ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100), reported by [@nkoynov](https://github.com/nkoynov)) +- Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. Completed tools remain replay barriers across host steps. Follow-ups accept checkpoints received while opening the new Run, and older checkpoints stay ineligible after a tool result or follow-up message ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - On macOS, a shell command that printed nothing before being moved to the background no longer reports `head: illegal byte count -- 0` - A tool that is still running no longer lets its Cursor Run expire at the inactivity limit. The Run stays open until the tool finishes, for up to four hours from when that tool started - An edit Cursor starts announcing shows its tool row while the arguments are still streaming. If that call never runs, the row closes instead of being left for the host to execute ([#73](https://github.com/oakimov/cursor-opencode-provider/issues/73)) From dfa32f1942bf4bc2cd6b7f2d243d1152aa3a36f9 Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:31:26 +0200 Subject: [PATCH 05/11] fix: bound Run retries like Cursor CLI and add endless opt-in --- CHANGELOG.md | 1 + README.md | 32 ++++++++++- docs/opencode-1.md | 7 +++ docs/opencode-2.md | 7 +++ src/errors.ts | 6 +- src/index.ts | 4 +- src/language-model.ts | 94 +++++++++++++++++++++++++------ src/retry-budget.ts | 83 +++++++++++++++++++++++++++ src/transport/connect.ts | 1 + tasks/lessons/opencode.md | 10 +++- test/errors.test.ts | 2 +- test/retry-budget.test.ts | 83 +++++++++++++++++++++++++++ test/run-error-host.test.ts | 102 +++++++++++++++++++++++++++++++++- test/run-interruption.test.ts | 62 +++++++++++++++++++-- 14 files changed, 464 insertions(+), 30 deletions(-) create mode 100644 src/retry-budget.ts create mode 100644 test/retry-budget.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ed21f212..17930c54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - Like Cursor CLI, errors that need your action (usage or rate limits, sign-in, pricing, API key, outdated client), a bare `permission_denied`, and authentication failures, including those before a Run opens, end the turn instead of being retried; on OpenCode 1.x this also covers final `internal`/`unknown` errors. Final Cursor details take precedence over token renewal ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Once the provider spends its retry budget, or cannot replay a Run without repeating a tool call or other side effect, OpenCode no longer starts another retry loop ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Like Cursor CLI, a Run that drops or stalls after showing only text or reasoning, before Cursor saved a checkpoint, is retried by OpenCode instead of ending the turn, and the request reaches Cursor in that retry instead of with your next message. A retry after a checkpoint resumes the turn's latest checkpoint, and two resumes that save nothing end the turn ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100), reported by [@nkoynov](https://github.com/nkoynov)) +- Retry counts now follow Cursor CLI: up to 10 transport retries and 3 server retries per user turn, shared across checkpoints and OpenCode retries. `CURSOR_ENDLESS_RETRIES=1` or `true` bypasses count limits while preserving replay guards and the no-progress and stall cutoffs ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. Completed tools remain replay barriers across host steps. Follow-ups accept checkpoints received while opening the new Run, and older checkpoints stay ineligible after a tool result or follow-up message ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - On macOS, a shell command that printed nothing before being moved to the background no longer reports `head: illegal byte count -- 0` - A tool that is still running no longer lets its Cursor Run expire at the inactivity limit. The Run stays open until the tool finishes, for up to four hours from when that tool started diff --git a/README.md b/README.md index d6b07333..0e9504ca 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ const cursor = createCursor({ // agentBaseURL: "https://agentn.us.api5.cursor.sh", // explicit Run host override // telemetryEnabled: true, // opt in to GetServerConfig telemetry // cacheDir: "/path/to/host/cache", // optional; else path bridge or native OpenCode cache - // retry: { maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 8_000 }, + // retry: { maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 8_000 }, // optional stricter total limit // continuation: { heartbeatMs: 5_000, semanticIdleMs: 120_000, hardCapMs: 600_000 }, }) @@ -248,7 +248,7 @@ const model = cursor.languageModel("composer-2.5") // model implements AI SDK LanguageModelV3 (doStream / doGenerate) ``` -Give one credential source. The provider uses the first present and never falls back from one to another: `getAccessToken` (an async function returning the current access token, called for every Run so a long-running process can renew its login; it receives `{ forceRefresh: true }` once after Cursor rejects a token), then `accessToken` (a JWT sent as-is, never renewed), then `apiKey` (a raw `crsr_...` key, exchanged for a JWT and exchanged again near its expiry, the way Cursor CLI renews an API-key login). Optional: `apiBaseURL`, `agentBaseURL`, `cacheDir`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. `cacheDir` pins the host cache root for model/version caches and Cursor project metadata; when omitted, the provider uses an injected structural host path bridge or the native OpenCode XDG cache described in [Paths](#paths-host-cache). An untouched checkpoint resume may renew a rejected credential once, while keeping completed tools behind the replay barrier. Follow-up Runs retain checkpoints received while opening. Transient failures resume from the latest checkpoint produced by that Run, matching Cursor CLI; without an eligible checkpoint, retries remain limited to replay-safe attempts so completed text or tool work is not duplicated. Once that budget is spent, or replay is unsafe, the terminal error does not re-arm OpenCode's outer retry loop. Pending-tool inactivity is renewed by OpenCode activity from the session or its descendants. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`. +Give one credential source. The provider uses the first present and never falls back from one to another: `getAccessToken` (an async function returning the current access token, called for every Run so a long-running process can renew its login; it receives `{ forceRefresh: true }` once after Cursor rejects a token), then `accessToken` (a JWT sent as-is, never renewed), then `apiKey` (a raw `crsr_...` key, exchanged for a JWT and exchanged again near its expiry, the way Cursor CLI renews an API-key login). Optional: `apiBaseURL`, `agentBaseURL`, `cacheDir`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. `cacheDir` pins the host cache root for model/version caches and Cursor project metadata; when omitted, the provider uses an injected structural host path bridge or the native OpenCode XDG cache described in [Paths](#paths-host-cache). An untouched checkpoint resume may renew a rejected credential once, while keeping completed tools behind the replay barrier. Follow-up Runs retain checkpoints received while opening. Transient failures resume from the latest checkpoint produced by that Run, matching Cursor CLI; without an eligible checkpoint, retries remain limited to replay-safe attempts so completed text or tool work is not duplicated. See [retry limits](#retry-limits) for the shared provider/host budget and endless retry option. Once that budget is spent, or replay is unsafe, the terminal error does not re-arm OpenCode's outer retry loop. Pending-tool inactivity is renewed by OpenCode activity from the session or its descendants. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`. ### Shell results @@ -322,6 +322,31 @@ Transcript storage is supplied separately through `CURSOR_AGENT_TRANSCRIPTS_FOLDER`; the provider neither creates that directory nor writes host transcripts into it. +## Retry limits + +Following Cursor CLI, each user turn allows **10 transport retries** and +**3 server retries**, counted separately. Connection failures use the transport +budget; early stream closure and the inbound read-idle deadline use the server +budget. Retries of the same turn share these counts across checkpoints, tool +steps and OpenCode's own retry calls. A new user turn, successful completion or +Stop starts a fresh budget. Counts are held in memory for the running process. +OpenCode may stop earlier under its own retry policy. + +For direct SDK use, `retry.maxAttempts` adds a total attempt ceiling, including +the initial attempt; omitting it uses the two Cursor CLI budgets. The existing +backoff defaults remain 500 ms full jitter, growing exponentially to an 8 s +ceiling (`baseDelayMs` / `maxDelayMs`). OpenCode controls delays when it owns +the retry after partial output. + +Set `CURSOR_ENDLESS_RETRIES=1` or `true` before starting OpenCode (or its daemon) +to bypass both count limits and `retry.maxAttempts`, like Cursor CLI's endless +retry mode. This keeps retrying eligible transient failures; it preserves Stop, +final errors, authentication rules and tool replay guards. It also retains +Cursor CLI's safety cutoffs: two resumed attempts that stream without a new +checkpoint end the turn, and consecutive semantic-stall retries stop once +180 seconds have elapsed since the first stall retry. A non-stall failure resets +that stall window. The setting does not override OpenCode's own retry limits. + ## Environment variables | Variable | Description | @@ -331,6 +356,7 @@ creates that directory nor writes host transcripts into it. | `CURSOR_GET_SERVER_CONFIG_TELEMETRY` | Set to `1` or `true` to opt the `GetServerConfig` lookup into telemetry in OpenCode/plugin usage | | `HTTPS_PROXY` / `https_proxy` | HTTP CONNECT proxy for HTTPS. Bun `fetch` uses this for unary RPCs; the Run HTTP/2 session also tunnels through it (see `NO_PROXY`). The Run tunnel supports only `http://` proxy URLs (Basic auth via userinfo); `https://` and SOCKS proxy URLs leave the Run on a direct connection. `HTTP_PROXY` is not used for HTTPS | | `NO_PROXY` / `no_proxy` | Comma/space hosts that bypass the proxy (`*`, exact host, or `.suffix` / `suffix` domain forms; a `host:port` entry bypasses only that port) | +| `CURSOR_ENDLESS_RETRIES` | Set to `1` or `true` to bypass retry count limits; off by default. Safety cutoffs and host retry limits still apply. See [retry limits](#retry-limits). | | `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging | | `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default: `debug-.log` under `$TMPDIR/cursor-provider-logs-/`) | | `CURSOR_AGENT_TRANSCRIPTS_FOLDER` | Optional folder containing past-chat transcripts, advertised to Cursor on OpenCode 1.x and 2.0. Sent only if it is an existing directory; unset, empty, missing, non-directory, or unstatable paths are omitted. Relative paths resolve against the workspace. The provider does not create the folder or write transcripts. | @@ -480,7 +506,7 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader. | “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. | | Empty or stale model list | Delete `/cursor-models.json` (native default `~/.cache/opencode/`) and restart OpenCode. A compatibility layer may supply a different host-cache root through the structural path bridge. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. | | Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, resumes transient interruptions from the latest eligible Cursor checkpoint, and falls back to a fresh-history rebase only before stateful output when no checkpoint exists. A transient interruption after only text or reasoning, without a checkpoint from that turn, is left to OpenCode to retry. Completed tools remain replay barriers across host steps; credential renewal retains the latest eligible checkpoint. Repeated resumes without progress end as an error. Behind an HTTP proxy, set `HTTPS_PROXY` (and `NO_PROXY` as needed): unary `fetch` and the Run HTTP/2 CONNECT tunnel both honor them. With debug logging enabled, look for `Run interrupted`, `resuming … checkpoint`, `rebasing fresh Run`, or `h2 connect via HTTPS proxy`. Restart OpenCode after rebuilding a local `file://` install. | -| No response / silent 200 + close | HTTP 200 alone is not a successful agent turn: the provider now requires Cursor's explicit `turn_ended`, captures HTTP/2 trailers/GOAWAY, and recovers once from bare EOF. The Run host still comes from in-memory `GetServerConfig` resolution; set `CURSOR_PROVIDER_DEBUG=1` to confirm the host and termination reason. | +| No response / silent 200 + close | HTTP 200 alone is not a successful agent turn: the provider now requires Cursor's explicit `turn_ended`, captures HTTP/2 trailers/GOAWAY, and retries bare EOF within the server retry budget. The Run host still comes from in-memory `GetServerConfig` resolution; set `CURSOR_PROVIDER_DEBUG=1` to confirm the host and termination reason. | | Visible `` timeout text | Rebuild and restart a local install. Cursor's shell timeout is carried on its exec request; the provider now removes OpenCode's internal timeout envelope before it is rendered or stored, then returns Cursor's typed timeout or background-handoff event instead of treating the text as successful stdout. | | `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. Known `handling=unsupported` variants (Cursor-native capabilities without a safe OpenCode AI SDK bridge) are now soft-denied on the wire with a populated typed error/`rejected`/`allowlisted:false` result and the Run continues — look for `exec: SOFT-DENIED …` in the debug log and a `Workspace root: …` suffix in the deny text — while `unknown request field` still hard-fails as protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. | | Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`; the default is `debug-.log` under `$TMPDIR/cursor-provider-logs-/`) and reproduce the issue. Every completed turn emits a `turn usage validation: status=ok\|mismatch` line confirming token-accounting integrity and a `cache diagnosis:` line showing warm/cold checkpoint continuity, raw read/write/uncached tokens, cache-read coverage of the prior context, context/category deltas, stable RequestContext hashes, and Run step/tool counts. Checkpoint lines include Cursor's current category totals. Cursor does not expose per-model-call cache splits, so the diagnostic labels that value unavailable instead of inferring it from aggregate `TurnEnded` counters. | diff --git a/docs/opencode-1.md b/docs/opencode-1.md index 8dc8567d..d38ae46c 100644 --- a/docs/opencode-1.md +++ b/docs/opencode-1.md @@ -147,6 +147,13 @@ an untouched resumed attempt. Follow-ups retain checkpoints received while opening the new Run. Two resumed attempts that stream without saving a checkpoint end the turn. +The provider shares Cursor CLI's limits of 10 transport retries and 3 server +retries across this turn, including OpenCode's retries. OpenCode can stop earlier. +Set `CURSOR_ENDLESS_RETRIES=1` or `true` before starting the host +to bypass count limits while retaining replay guards, Stop, final errors, the +two-resume cutoff and the 180 s consecutive semantic-stall retry window. +See [retry limits](../README.md#retry-limits) for classification and SDK options. + ## Troubleshooting | Problem | What to try | diff --git a/docs/opencode-2.md b/docs/opencode-2.md index beb6c31d..f0604b7c 100644 --- a/docs/opencode-2.md +++ b/docs/opencode-2.md @@ -218,6 +218,13 @@ an untouched resumed attempt. Follow-ups retain checkpoints received while opening the new Run. Two resumed attempts that stream without saving a checkpoint end the turn. +The provider shares Cursor CLI's limits of 10 transport retries and 3 server +retries across this turn, including OpenCode's retries. OpenCode can stop earlier. +Set `CURSOR_ENDLESS_RETRIES=1` or `true` before starting the host (or its daemon) +to bypass count limits while retaining replay guards, Stop, final errors, the +two-resume cutoff and the 180 s consecutive semantic-stall retry window. +See [retry limits](../README.md#retry-limits) for classification and SDK options. + ## Mid-turn steers and Stop A plain-text steer without files or skills can reach an actively pumping Cursor diff --git a/src/errors.ts b/src/errors.ts index 24de71a7..dc8c9ce0 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -429,9 +429,11 @@ export function toCursorProviderError( * does that here (see `hostFacingError`), and the lost-request record carries * the user's request once. */ -export function hostRetryError(cause: CursorProviderError): CursorProviderError { +export function hostRetryError(cause: CursorProviderError, beforeOutput = false): CursorProviderError { return new CursorProviderError( - `${cause.message} after visible output, before Cursor saved a checkpoint; the turn can be sent again`, + beforeOutput + ? `${cause.message}; the Run could not start and the turn can be sent again` + : `${cause.message} after visible output, before Cursor saved a checkpoint; the turn can be sent again`, { origin: cause.origin, transient: false, diff --git a/src/index.ts b/src/index.ts index 111e5a84..7c75e2e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import type { HostToolDialect } from "./protocol/tools.js" import type { AccessTokenProvider } from "./auth-renewal.js" export type CursorRetryOptions = { - /** Total attempts including the initial request. Default: 3. */ + /** Optional total attempt ceiling (including the initial request), across host retries. */ maxAttempts?: number /** Initial full-jitter backoff ceiling. Default: 500ms. */ baseDelayMs?: number @@ -45,7 +45,7 @@ export type CreateCursorOptions = { cacheDir?: string /** Held-stream policy. Defaults: heartbeat 5s, semantic idle 120s, tool inactivity 10m. */ continuation?: CursorContinuationOptions - /** Fresh-turn retry policy. Defaults: 3 attempts, 500ms base, 8000ms cap. */ + /** Run retry policy. Defaults: 10 transport / 3 server retries, 500ms base, 8000ms delay cap. */ retry?: CursorRetryOptions /** Fallback host dialect when schemas are omitted or ambiguous. Default: OpenCode 1.x. */ defaultDialect?: HostToolDialect diff --git a/src/language-model.ts b/src/language-model.ts index 03687c5a..4bdd518c 100644 --- a/src/language-model.ts +++ b/src/language-model.ts @@ -3,6 +3,7 @@ import path from "node:path" import { createHash } from "node:crypto" import { APICallError } from "@ai-sdk/provider" import protobuf from "protobufjs" +import { newRetryBudget, turnRetryBudget, forgetRetryBudget, reserveRetry, resetRetryBudgetsForTests, type RetryBudget } from "./retry-budget.js" import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamResult, LanguageModelV3GenerateResult, LanguageModelV3StreamPart, LanguageModelV3Usage, LanguageModelV3FinishReason } from "@ai-sdk/provider" import type { CreateCursorOptions, CursorRetryOptions } from "./index.js" import { @@ -247,7 +248,6 @@ import { CursorLocalCancellationError, CursorProtocolError, CursorProviderError, - CursorRetryExhaustedError, CursorServerError, CursorTransportError, hostFacingError, @@ -364,7 +364,6 @@ type PromptIdentity = { hostAgent?: string; systemPromptHash?: string } const promptIdentityBySession = new Map() export const MAX_TURN_STATE_SESSIONS = 256 const DEFAULT_RETRY_POLICY = { - maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 8_000, } as const @@ -449,7 +448,7 @@ export function isSoleControlFrame(payload: Uint8Array): boolean { } export type CursorRetryPolicy = { - maxAttempts: number + maxAttempts?: number baseDelayMs: number maxDelayMs: number } @@ -471,10 +470,10 @@ export function resolveRetryPolicy(options: CursorRetryOptions | undefined): Cur throw new CursorProtocolError(`Unknown Cursor retry option: ${key}`) } } - const maxAttempts = retryInteger("maxAttempts", options?.maxAttempts, DEFAULT_RETRY_POLICY.maxAttempts) + const maxAttempts = options?.maxAttempts === undefined ? undefined : retryInteger("maxAttempts", options.maxAttempts, 1) const baseDelayMs = retryInteger("baseDelayMs", options?.baseDelayMs, DEFAULT_RETRY_POLICY.baseDelayMs) const maxDelayMs = retryInteger("maxDelayMs", options?.maxDelayMs, DEFAULT_RETRY_POLICY.maxDelayMs) - if (maxAttempts > MAX_RETRY_ATTEMPTS) { + if (maxAttempts !== undefined && maxAttempts > MAX_RETRY_ATTEMPTS) { throw new CursorProtocolError(`Cursor retry maxAttempts must be no greater than ${MAX_RETRY_ATTEMPTS}`) } if (baseDelayMs > MAX_RETRY_DELAY_MS || maxDelayMs > MAX_RETRY_DELAY_MS) { @@ -887,8 +886,29 @@ export function createCursorLanguageModel( }) // Credential resolution and session setup can fail before a stream exists. // Both public entrypoints must project those failures just like pump errors. - const doStream = (callOptions: LanguageModelV3CallOptions): Promise => - doStreamImpl(modelId, options, callOptions).catch((error) => { throw hostFacingError(error) }) + const doStream = async (callOptions: LanguageModelV3CallOptions): Promise => { + const retryPolicy = resolveRetryPolicy(options.retry) + const retryBudget = turnRetryBudget(opencodeSessionKey(callOptions), retryTurnKey(callOptions.prompt)) + try { + return await doStreamImpl(modelId, options, callOptions, retryPolicy, retryBudget) + } catch (error) { + const failure = toCursorProviderError(error, { replaySafe: true, fallback: "Cursor Run setup failed" }) + try { + if (callOptions.abortSignal?.aborted) { + forgetRetryBudget(opencodeSessionKey(callOptions), retryBudget) + throw new CursorLocalCancellationError("Cursor Run setup cancelled", error) + } + if (!failure.transient) throw failure + if (!failure.replaySafe) { + throw retrySuppressedError(failure, "while opening the Run", retryBudget.retries + 1, retryPolicy.maxAttempts ?? 14) + } + reserveRetry(retryBudget, failure, retryPolicy.maxAttempts) + throw hostRetryError(failure, true) + } catch (terminal) { + throw hostFacingError(terminal) + } + } + } return { specificationVersion: "v3", provider: providerId, @@ -926,13 +946,14 @@ async function doStreamImpl( modelId: string, options: CreateCursorOptions, callOptions: LanguageModelV3CallOptions, + retryPolicy: CursorRetryPolicy, + retryBudget: RetryBudget, ): Promise { await restoreSessionState(opencodeSessionKey(callOptions), opencodeGlobalCacheDir()) const withoutAnswered = await leaveOutAnsweredEarlySteers(callOptions.prompt, opencodeSessionKey(callOptions)) if (withoutAnswered === "all") return { stream: answeredSteerStream() } if (withoutAnswered) callOptions = { ...callOptions, prompt: withoutAnswered } const prompt = callOptions.prompt - const retryPolicy = resolveRetryPolicy(options.retry) // The Bearer token is resolved for every Run this call opens, including // recovery Runs after a long-held tool: a host `getAccessToken` (or the // apiKey exchange) renews it, so a Run never starts on a token that expired @@ -1140,6 +1161,7 @@ async function doStreamImpl( controller, abortSignal: callOptions.abortSignal, retryPolicy, + retryBudget, recover: (recovery) => openSession({ recovery }), onSession: (next) => { activeSession = next }, ...(credentialRenewable @@ -1149,6 +1171,9 @@ async function doStreamImpl( } finally { pumpingThisStream = false } + if (activeSession.closed && activeSession.pending.size === 0) { + forgetRetryBudget(opencodeSessionKey(callOptions), retryBudget) + } try { controller.close() } catch (e) { @@ -1164,6 +1189,9 @@ async function doStreamImpl( pendingExecs: activeSession.pending.size, }) } catch (e) { + if (e instanceof CursorLocalCancellationError || callOptions.abortSignal?.aborted) { + forgetRetryBudget(opencodeSessionKey(callOptions), retryBudget) + } activeSession.pumpActive = false trace(`pull: pump threw (cleaning up): ${(e as Error).message}`) // A Run the host stopped is drained and closed by its cancel. @@ -1209,6 +1237,7 @@ export async function pumpWithRecovery(input: { controller: ReadableStreamDefaultController abortSignal?: AbortSignal retryPolicy?: CursorRetryPolicy + retryBudget?: RetryBudget recover: (recovery: CursorRunRecovery) => Promise onSession?: (session: CursorSession) => void maxRecoveries?: number @@ -1223,9 +1252,10 @@ export async function pumpWithRecovery(input: { let session = input.initialSession const retryPolicy = input.retryPolicy ?? { ...DEFAULT_RETRY_POLICY, - maxAttempts: (input.maxRecoveries ?? 1) + 1, + ...(input.maxRecoveries === undefined ? {} : { maxAttempts: input.maxRecoveries + 1 }), } - const maxRecoveries = retryPolicy.maxAttempts - 1 + const retryBudget = input.retryBudget ?? newRetryBudget() + const maxRecoveries = retryPolicy.maxAttempts === undefined ? 13 : retryPolicy.maxAttempts - 1 let credentialRenewed = false // As in Cursor CLI's Run retry loop: an attempt without a checkpoint of its // own resumes the latest one this turn received, and two resumed attempts @@ -1233,6 +1263,7 @@ export async function pumpWithRecovery(input: { let turnCheckpoint: Uint8Array | undefined let resumedAttempt = false let noProgressResumes = 0 + let openingFailure: CursorProviderError | undefined input.onSession?.(session) const reopen = async ( @@ -1308,10 +1339,22 @@ export async function pumpWithRecovery(input: { return next } + // A replacement can fail before pump() starts (for example while connecting). + // Classify it in this same loop rather than exposing an unbudgeted host retry. + const reopenAttempt = async (pumped: CursorSession, failure: CursorProviderError, checkpoint: Uint8Array | undefined) => { + try { + session = await reopen(pumped, failure, checkpoint) + } catch (error) { + const failure = toCursorProviderError(error, { replaySafe: true, fallback: "Cursor recovery setup failed" }) + if (!failure.transient) throw error + openingFailure = failure + } + } + for (let attempt = 0; ; attempt++) { const pumpedSession = session const pumpOwner = Symbol("cursor-pump") - sessionManager.beginPump(pumpedSession, pumpOwner) + if (!openingFailure) sessionManager.beginPump(pumpedSession, pumpOwner) // OpenCode 1.x stops a turn by aborting the signal, before the AI SDK cancels // the stream. Cancel at Cursor, as the stream's cancel() does, so Cursor ends // the Run on a checkpoint; a reply still waiting for drain would hold the @@ -1331,6 +1374,11 @@ export async function pumpWithRecovery(input: { ? listenForHostSteers(pumpedSession.openCodeSessionId, (steer) => injectHostSteer(pumpedSession, steer)) : undefined try { + if (openingFailure) { + const failure = openingFailure + openingFailure = undefined + throw failure + } await pump( pumpedSession, input.controller, @@ -1340,10 +1388,14 @@ export async function pumpWithRecovery(input: { }, input.abortSignal, ) + if (pumpedSession.closed && pumpedSession.pending.size === 0) { + forgetRetryBudget(pumpedSession.openCodeSessionId, retryBudget) + } return session } catch (error) { // A cancelled Run's stream fails or ends on purpose; recovering would restart the stopped turn. if (pumpedSession.hostInterrupted || input.abortSignal?.aborted) { + forgetRetryBudget(pumpedSession.openCodeSessionId, retryBudget) if (error instanceof CursorLocalCancellationError) throw error throw new CursorLocalCancellationError("Cursor Run cancelled: the host stopped the turn", error) } @@ -1377,7 +1429,7 @@ export async function pumpWithRecovery(input: { ) sessionManager.close(pumpedSession, "remote-error", failure) input.renewRejectedCredential() - session = await reopen( + await reopenAttempt( pumpedSession, failure, checkpoint, @@ -1402,6 +1454,7 @@ export async function pumpWithRecovery(input: { // provider from doing so; the host can, unless a side effect would repeat. if (failure.replayBarrier === "output-only" && !pumpedSession.checkpointRevoked) { trace(`Run interrupted after output only: sessionId=${pumpedSession.sessionId} — leaving the retry to the host`) + reserveRetry(retryBudget, failure, retryPolicy.maxAttempts) throw hostRetryError(failure) } throw retrySuppressedError( @@ -1411,9 +1464,7 @@ export async function pumpWithRecovery(input: { maxRecoveries + 1, ) } - if (attempt >= maxRecoveries) { - throw new CursorRetryExhaustedError(attempt + 1, failure) - } + reserveRetry(retryBudget, failure, retryPolicy.maxAttempts) trace( `Run interrupted: sessionId=${pumpedSession.sessionId} attempt=${attempt + 1}/${maxRecoveries} ` + `err=${failure.message} — ${checkpoint ? `resuming ${checkpoint.length}B checkpoint` : "rebasing fresh Run"}`, @@ -1422,7 +1473,7 @@ export async function pumpWithRecovery(input: { const delayMs = retryDelayMs(failure, attempt + 1, retryPolicy) trace(`Run retry backoff: attempt=${attempt + 1}/${maxRecoveries} delayMs=${delayMs}`) await sleepForRetry(delayMs, input.abortSignal) - session = await reopen(pumpedSession, failure, checkpoint) + await reopenAttempt(pumpedSession, failure, checkpoint) } finally { input.abortSignal?.removeEventListener("abort", stopPumpedRun) stopSteers?.() @@ -2741,6 +2792,7 @@ export function cancelRunForHostInterrupt( async function cancelRun(session: CursorSession, reason: string, opts?: { graceMs?: number }): Promise { session.hostInterrupted = reason + forgetRetryBudget(session.openCodeSessionId) dropLostRequest(session.openCodeSessionId) // Stopped while its turn end was being saved, the host may have kept only part of the step. if (session.openCodeSessionId && sessionManager.isActivelyPumping(session)) { @@ -2840,6 +2892,7 @@ onHostInterrupt(async (openCodeSessionId, reason, at) => { for (const [id, stop] of hostInterrupts) { if (stop.at < at - HOST_INTERRUPT_MEMORY_MS) hostInterrupts.delete(id) } + forgetRetryBudget(openCodeSessionId) hostInterrupts.set(openCodeSessionId, { at, reason }) for (const session of sessionManager.openSessionsStoppedWith(openCodeSessionId)) { if (requestedBefore(session, at)) cancelForHostInterrupt(session, reason) @@ -6822,6 +6875,14 @@ export function cursorTurnEndedProviderMetadata( } } +/** Partial assistant output may grow on a host retry; it is not a new user turn. */ +function retryTurnKey(prompt: LanguageModelV3CallOptions["prompt"]): string { + const messages = prompt.filter(message => message.role !== "system" && !hostTailNote(message)) + let end = messages.length + while (end > 0 && messages[end - 1]!.role !== "user") end-- + return createHash("sha256").update(JSON.stringify(messages.slice(0, end))).digest("hex") +} + /** All user messages after the last assistant/tool boundary form one live user turn. */ export function liveUserTurn(prompt: LanguageModelV3CallOptions["prompt"]): { start: number; text: string } | undefined { let start = prompt.length @@ -7325,6 +7386,7 @@ export function resolveTurnConversationReset(input: { } export function resetTurnStateForTests(): void { + resetRetryBudgetsForTests() toolCatalogBySession.clear() postCompactionRebaseBySession.clear() promptIdentityBySession.clear() diff --git a/src/retry-budget.ts b/src/retry-budget.ts new file mode 100644 index 00000000..68fa520d --- /dev/null +++ b/src/retry-budget.ts @@ -0,0 +1,83 @@ +import { CursorRetryExhaustedError, type CursorProviderError } from "./errors.js" +import { processShared } from "./process-shared.js" + +/** Plain data shared by the plugin and SDK copies, for one logical user turn. */ +export type RetryBudget = { + server: number + transport: number + retries: number + stallStartedAt?: number +} + +const budgets = () => processShared("run-retry-budgets.v1", () => new Map>()) +const MAX_SESSIONS = 1_024 +const MAX_TURNS_PER_SESSION = 16 + +export function newRetryBudget(): RetryBudget { + return { server: 0, transport: 0, retries: 0 } +} + +/** A host retry or tool continuation must not mint a fresh retry allowance. */ +export function turnRetryBudget(sessionKey: string | undefined, turnKey: string): RetryBudget { + if (!sessionKey) return newRetryBudget() + const sessions = budgets() + let turns = sessions.get(sessionKey) + if (!turns) { + turns = new Map() + } + sessions.delete(sessionKey) + sessions.set(sessionKey, turns) + let budget = turns.get(turnKey) + if (!budget) { + budget = newRetryBudget() + } + turns.delete(turnKey) + turns.set(turnKey, budget) + // Abandoned host retries and ephemeral helpers never reach turn_ended. + // Bound their lookup records; live recovery loops retain their budget object. + while (turns.size > MAX_TURNS_PER_SESSION) turns.delete(turns.keys().next().value!) + while (sessions.size > MAX_SESSIONS) sessions.delete(sessions.keys().next().value!) + return budget +} + +export function forgetRetryBudget(sessionKey: string | undefined, budget?: RetryBudget): void { + if (!sessionKey) return + const sessions = budgets() + const turns = sessions.get(sessionKey) + if (!turns) return + if (!budget) sessions.delete(sessionKey) + else { + for (const [key, value] of turns) if (value === budget) turns.delete(key) + if (turns.size === 0) sessions.delete(sessionKey) + } +} + +export function endlessRetriesEnabled(): boolean { + return process.env.CURSOR_ENDLESS_RETRIES === "1" || process.env.CURSOR_ENDLESS_RETRIES === "true" +} + +/** Cursor CLI counts early EOF / read-idle as server failures, stalls as transport. */ +export function retryCategory(failure: CursorProviderError): "server" | "transport" { + return failure.origin === "transport" + && failure.code !== "CURSOR_STREAM_CLOSED_EARLY" + && failure.code !== "CURSOR_READ_IDLE_TIMEOUT" ? "transport" : "server" +} + +/** Reserve one retry before either reopening locally or handing it to OpenCode. */ +export function reserveRetry(budget: RetryBudget, failure: CursorProviderError, maxAttempts?: number, now = Date.now()): void { + const category = retryCategory(failure) + const stalled = failure.code === "CURSOR_SEMANTIC_IDLE_TIMEOUT" + if (!stalled) budget.stallStartedAt = undefined + const stallExpired = stalled && budget.stallStartedAt !== undefined && now - budget.stallStartedAt >= 180_000 + if (stallExpired || (!endlessRetriesEnabled() && ( + budget[category] >= (category === "server" ? 3 : 10) + || (maxAttempts !== undefined && budget.retries >= maxAttempts - 1) + ))) throw new CursorRetryExhaustedError(budget.retries + 1, failure) + if (stalled) budget.stallStartedAt ??= now + budget[category]++ + budget.retries++ +} + +export function resetRetryBudgetsForTests(): void { + budgets().clear() +} diff --git a/src/transport/connect.ts b/src/transport/connect.ts index e51c61ae..618e360f 100644 --- a/src/transport/connect.ts +++ b/src/transport/connect.ts @@ -279,6 +279,7 @@ export class CursorRunInterruptedError extends CursorTransportError { transient: true, replaySafe: true, cause: options?.cause, + code: options?.cause === undefined ? "CURSOR_STREAM_CLOSED_EARLY" : undefined, }) this.name = "CursorRunInterruptedError" } diff --git a/tasks/lessons/opencode.md b/tasks/lessons/opencode.md index dcc2788e..c3e10d1f 100644 --- a/tasks/lessons/opencode.md +++ b/tasks/lessons/opencode.md @@ -136,7 +136,15 @@ These are failure lessons shared by both hosts, not a replacement for only reasoning; its request reached Cursor only with the next message ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100)). Cursor CLI resends the action, resumes the turn's latest checkpoint, and stops - after two resumes without a new one. Record every barrier, not the first: + after two resumes without a new one. Its separate 10-transport / 3-server + retry budgets belong to the user turn, not one provider call: an output-only + failure handed to an older host with unbounded retries otherwise restarts the + allowance forever. Share counts across module copies and host tool steps; + distinguish partial assistant output and host reminders from a new user turn. + Endless mode bypasses counts only, preserving replay guards and CLI's 180 s + consecutive-stall retry window (`src/retry-budget.ts`, + `test/retry-budget.test.ts`, `test/run-error-host.test.ts`). + Record every barrier, not the first: text before a stateful frame must not read as output-only. Keep stateful barriers on the Run across tool-step pumps, and retain checkpoints before credential renewal as well as transient recovery; otherwise a completed diff --git a/test/errors.test.ts b/test/errors.test.ts index 50d47bdb..5b3c6c0f 100644 --- a/test/errors.test.ts +++ b/test/errors.test.ts @@ -42,7 +42,7 @@ describe("Cursor provider errors", () => { it("strictly bounds retry policy options", () => { expect(resolveRetryPolicy(undefined)).toEqual({ - maxAttempts: 3, + maxAttempts: undefined, baseDelayMs: 500, maxDelayMs: 8_000, }) diff --git a/test/retry-budget.test.ts b/test/retry-budget.test.ts new file mode 100644 index 00000000..c2544428 --- /dev/null +++ b/test/retry-budget.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, it } from "bun:test" +import { CursorRetryExhaustedError, CursorServerError, CursorTransportError, hostFacingError } from "../src/errors.js" +import { CursorRunInterruptedError } from "../src/transport/connect.js" +import { endlessRetriesEnabled, forgetRetryBudget, newRetryBudget, reserveRetry, resetRetryBudgetsForTests, retryCategory, turnRetryBudget } from "../src/retry-budget.js" + +const savedEndless = process.env.CURSOR_ENDLESS_RETRIES +afterEach(() => { + if (savedEndless === undefined) delete process.env.CURSOR_ENDLESS_RETRIES + else process.env.CURSOR_ENDLESS_RETRIES = savedEndless + resetRetryBudgetsForTests() +}) + +const transport = () => new CursorTransportError("Connection reset", { transient: true, replaySafe: true, code: "ECONNRESET" }) +const server = () => new CursorServerError("unavailable", { transient: true, replaySafe: true }) +const stall = () => new CursorTransportError("Stalled", { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" }) + +describe("Cursor CLI retry budgets", () => { + it("allows ten transport retries and three server retries independently", () => { + delete process.env.CURSOR_ENDLESS_RETRIES + const budget = newRetryBudget() + for (let i = 0; i < 10; i++) reserveRetry(budget, transport()) + for (let i = 0; i < 3; i++) reserveRetry(budget, server()) + for (const failure of [transport(), server()]) { + expect(() => reserveRetry(budget, failure)).toThrow(CursorRetryExhaustedError) + } + expect(budget.retries).toBe(13) + }) + + it("uses the server budget for early EOF and read-idle, and transport for stalls", () => { + expect(retryCategory(new CursorRunInterruptedError())).toBe("server") + expect(retryCategory(new CursorTransportError("idle", { code: "CURSOR_READ_IDLE_TIMEOUT", transient: true, replaySafe: true }))).toBe("server") + expect(retryCategory(stall())).toBe("transport") + }) + + it("retains the allowance across module copies, host retries and tool steps, until completion or Stop", async () => { + const first = turnRetryBudget("session", "turn") + reserveRetry(first, server()) + const copy = await import(`../src/retry-budget.js?copy=${crypto.randomUUID()}`) + expect(copy.turnRetryBudget("session", "turn")).toBe(first) + expect(turnRetryBudget("other", "turn").retries).toBe(0) + const helper = turnRetryBudget("session", "helper") + forgetRetryBudget("session", helper) + expect(turnRetryBudget("session", "turn")).toBe(first) + forgetRetryBudget("session") + expect(turnRetryBudget("session", "turn").retries).toBe(0) + }) + + it("enforces an explicit total attempt ceiling across categories", () => { + delete process.env.CURSOR_ENDLESS_RETRIES + const budget = newRetryBudget() + reserveRetry(budget, transport(), 2) + let failure: unknown + try { reserveRetry(budget, server(), 2) } catch (error) { failure = error } + expect(hostFacingError(failure)).toMatchObject({ isRetryable: false, statusCode: 400, responseHeaders: { "x-should-retry": "false" } }) + }) + + it("opts into endless counts only with 1 or true", () => { + for (const value of ["", "0", "false", "TRUE", "yes"]) { + process.env.CURSOR_ENDLESS_RETRIES = value + expect(endlessRetriesEnabled()).toBe(false) + } + for (const value of ["1", "true"]) { + process.env.CURSOR_ENDLESS_RETRIES = value + const budget = newRetryBudget() + for (let i = 0; i < 25; i++) { + reserveRetry(budget, server(), 1) + reserveRetry(budget, transport(), 1) + } + expect(budget.retries).toBe(50) + } + }) + + it("keeps the 180 second consecutive-stall retry window even in endless mode", () => { + process.env.CURSOR_ENDLESS_RETRIES = "true" + const budget = newRetryBudget() + reserveRetry(budget, stall(), undefined, 100) + reserveRetry(budget, stall(), undefined, 180_099) + expect(() => reserveRetry(budget, stall(), undefined, 180_100)).toThrow(CursorRetryExhaustedError) + reserveRetry(budget, server(), undefined, 180_101) + reserveRetry(budget, stall(), undefined, 180_102) + expect(budget.stallStartedAt).toBe(180_102) + }) +}) diff --git a/test/run-error-host.test.ts b/test/run-error-host.test.ts index 9e097fa6..c06da33a 100644 --- a/test/run-error-host.test.ts +++ b/test/run-error-host.test.ts @@ -6,7 +6,7 @@ import os from "node:os" import path from "node:path" import { APICallError, type LanguageModelV3CallOptions, type LanguageModelV3StreamPart } from "@ai-sdk/provider" import { createCursor } from "../src/index.js" -import { CursorAuthError, CursorRetryExhaustedError, CursorServerError } from "../src/errors.js" +import { CursorAuthError, CursorRetryExhaustedError, CursorServerError, CursorTransportError } from "../src/errors.js" import { decodeMessage, encodeMessage } from "../src/protocol/messages.js" import { cacheHttp2SessionForTests, closeCachedHttp2SessionsForTests, lastFrameArrival } from "../src/transport/connect.js" import { setHostCacheDirOverride } from "../src/context/paths.js" @@ -15,6 +15,7 @@ import { resetTurnStateForTests } from "../src/language-model.js" import { resetConversationBindingsForTests } from "../src/protocol/conversation-bind.js" import { resetCheckpointsForTests } from "../src/protocol/checkpoint.js" import { resetConversationPersistenceForTests } from "../src/protocol/conversation-persistence.js" +import { notifyHostInterrupt } from "../src/host-interrupt.js" const ORIGIN = "https://agentn.run-error-test.cursor.sh" const PROXY_KEYS = ["HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"] as const @@ -379,3 +380,102 @@ describe("Run errors as the host sees them", () => { expect(runs).toBe(1) }, 30_000) }) + +describe("retry limits across OpenCode calls", () => { + it("also budgets transient setup errors and refuses ambiguous uploads", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-setup-retry-budget-")) + let renewals = 0 + let ambiguous = false + const model = createCursor({ + name: "cursor", agentBaseURL: ORIGIN, cacheDir: root, + getAccessToken: async () => { + renewals++ + throw new CursorTransportError("connection reset", { transient: true, replaySafe: !ambiguous, code: "ECONNRESET" }) + }, + }).languageModel("composer-2.5") + const attempt = async (): Promise => { + try { + return await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + headers: { "x-opencode-session-id": "ses_setup_retries" }, + }) + } catch (error) { return error } + } + try { + for (let i = 0; i < 10; i++) { + const error = await attempt() + expect(error).toMatchObject({ isRetryable: true }) + expect(error).toHaveProperty("message", expect.not.stringContaining("after visible output")) + } + expect(await attempt()).toMatchObject({ isRetryable: false, responseHeaders: { "x-should-retry": "false" } }) + expect(renewals).toBe(11) + await notifyHostInterrupt("ses_setup_retries", "test-stop") + ambiguous = true + expect(await attempt()).toMatchObject({ isRetryable: false, responseHeaders: { "x-should-retry": "false" } }) + expect(runs).toBe(0) + } finally { + setHostCacheDirOverride(undefined) + fs.rmSync(root, { recursive: true, force: true }) + } + }) + + for (const [endless, maxAttempts, retries] of [[false, undefined, 3], [false, 2, 1], [true, 2, 14]] as const) { + it(`bounds output-only host retries (endless=${endless}, maxAttempts=${maxAttempts})`, async () => { + const saved = process.env.CURSOR_ENDLESS_RETRIES + if (endless) process.env.CURSOR_ENDLESS_RETRIES = "true" + else delete process.env.CURSOR_ENDLESS_RETRIES + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-host-retry-budget-")) + cacheHttp2SessionForTests(ORIGIN, client) + trailer = { error: { code: "unavailable" } } + partialOutput = true + const prompt: LanguageModelV3CallOptions["prompt"] = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] + const attempt = async () => { + // Hosts may reload the SDK model between retries. State belongs to the turn. + const model = createCursor({ + name: "cursor", accessToken: "test-token", agentBaseURL: ORIGIN, cacheDir: root, + retry: { maxAttempts, baseDelayMs: 1, maxDelayMs: 1 }, + }).languageModel("composer-2.5") + try { + const { stream } = await model.doStream({ prompt, headers: { "x-opencode-session-id": "ses_repeated_output" } }) + const reader = stream.getReader() + while (!(await reader.read()).done) { /* drain host-visible output */ } + } catch (error) { return error } + } + try { + for (let i = 0; i < retries; i++) { + expect(await attempt()).toMatchObject({ isRetryable: true, responseHeaders: { "x-should-retry": "true" } }) + // OpenCode 2 keeps partial assistant output. A host system reminder is not a new request. + prompt.push({ role: "assistant", content: [{ type: "text", text: `partial ${i}` }] }) + prompt.push({ role: "user", content: [{ type: "text", text: "Retry the interrupted step." }] }) + } + expect(runs).toBe(retries) + if (endless) { + trailer = { error: { code: "permission_denied" } } + expect(await attempt()).toMatchObject({ isRetryable: false, responseHeaders: { "x-should-retry": "false" } }) + } else { + expect(await attempt()).toMatchObject({ isRetryable: false, statusCode: 400, responseHeaders: { "x-should-retry": "false" } }) + expect(runs).toBe(retries + 1) + // A later identical user message is a new turn, even in the same session. + prompt.push({ role: "user", content: [{ type: "text", text: "Hello" }] }) + expect(await attempt()).toMatchObject({ isRetryable: true }) + // Stop also resets a failed turn when there is no longer an open Run. + await notifyHostInterrupt("ses_repeated_output", "test-stop") + expect(await attempt()).toMatchObject({ isRetryable: true }) + scripted = (stream, message) => { + if (!message.run_request) return + stream.end(frame(encodeMessage("AgentServerMessage", { interaction_update: { turn_ended: {} } }))) + } + expect(await attempt()).toBeUndefined() + scripted = undefined + expect(await attempt()).toMatchObject({ isRetryable: true }) + } + } finally { + if (saved === undefined) delete process.env.CURSOR_ENDLESS_RETRIES + else process.env.CURSOR_ENDLESS_RETRIES = saved + sessionManager.dispose() + setHostCacheDirOverride(undefined) + fs.rmSync(root, { recursive: true, force: true }) + } + }, 30_000) + } +}) diff --git a/test/run-interruption.test.ts b/test/run-interruption.test.ts index 8dcf69f4..9273085c 100644 --- a/test/run-interruption.test.ts +++ b/test/run-interruption.test.ts @@ -14,7 +14,7 @@ import { import { decodeMessage, encodeMessage } from "../src/protocol/messages.js" import { sessionManager, type CursorSession, type Frame } from "../src/session.js" import { CursorRunInterruptedError } from "../src/transport/connect.js" -import { CursorAuthError, CursorRetryExhaustedError, CursorServerError } from "../src/errors.js" +import { CursorAuthError, CursorRetryExhaustedError, CursorServerError, CursorTransportError } from "../src/errors.js" import { sessionFixture } from "./session-fixture.js" function fakeSession(id: string, frames: Frame[], writes: Uint8Array[] = []): CursorSession { @@ -339,19 +339,20 @@ describe("interrupted Cursor Run handling", () => { expect(writes).toHaveLength(4) }) - it("surfaces a second interruption instead of retrying forever", async () => { + it("stops early EOF after three server retries, like Cursor CLI", async () => { let recoveries = 0 await expect( pumpWithRecovery({ initialSession: fakeSession("first-eof", []), controller: controller([]), + retryPolicy: { baseDelayMs: 0, maxDelayMs: 0 }, recover: async () => { recoveries++ return fakeSession("second-eof", []) }, }), ).rejects.toBeInstanceOf(CursorRetryExhaustedError) - expect(recoveries).toBe(1) + expect(recoveries).toBe(3) }) it("does not leak OpenCode SessionRetry triggers after unavailable retries finish", async () => { @@ -584,6 +585,54 @@ describe("interrupted Cursor Run handling", () => { } }) + it.each([false, true])("keeps transport counts across replacement checkpoints (endless=%s)", async endless => { + const saved = process.env.CURSOR_ENDLESS_RETRIES + if (endless) process.env.CURSOR_ENDLESS_RETRIES = "1" + else delete process.env.CURSOR_ENDLESS_RETRIES + let attempts = 0 + const checkpointedFailure = () => { + attempts++ + const session = fakeSession(`transport-budget-${attempts}`, [ + serverFrame({ conversation_checkpoint_update: Uint8Array.of(10, 1, attempts) }), + ...(endless && attempts === 15 ? [turnEnded()] : []), + ]) + const next = session.frames.next.bind(session.frames) + session.frames.next = async () => { + const frame = await next() + if (frame.done) throw new CursorTransportError("reset", { transient: true, replaySafe: true, code: "ECONNRESET" }) + return frame + } + return session + } + try { + const pumping = pumpWithRecovery({ + initialSession: checkpointedFailure(), controller: controller([]), + retryPolicy: { baseDelayMs: 0, maxDelayMs: 0 }, + recover: async () => checkpointedFailure(), + }) + if (endless) await pumping + else await expect(pumping).rejects.toBeInstanceOf(CursorRetryExhaustedError) + expect(attempts).toBe(endless ? 15 : 11) + } finally { + if (saved === undefined) delete process.env.CURSOR_ENDLESS_RETRIES + else process.env.CURSOR_ENDLESS_RETRIES = saved + } + }) + + it("budgets replacement connection failures in the same loop", async () => { + let openings = 0 + await expect(pumpWithRecovery({ + initialSession: fakeSession("before-replacement-connect-failures", []), + controller: controller([]), retryPolicy: { baseDelayMs: 0, maxDelayMs: 0 }, + recover: async () => { + openings++ + throw new CursorTransportError("connect reset", { transient: true, replaySafe: true, code: "ECONNRESET" }) + }, + })).rejects.toBeInstanceOf(CursorRetryExhaustedError) + // First EOF spends one server retry; failed opens then spend ten transport retries. + expect(openings).toBe(11) + }) + it("uses retry.maxAttempts as the total Run attempt budget", async () => { let recoveries = 0 await expect( @@ -703,7 +752,10 @@ describe("interrupted Cursor Run handling", () => { expect(recoveries).toBe(3) }) - it("stops after two resumed attempts stream without saving a checkpoint, like Cursor CLI", async () => { + it.each([false, true])("stops after two resumed attempts stream without saving a checkpoint (endless=%s)", async endless => { + const saved = process.env.CURSOR_ENDLESS_RETRIES + if (endless) process.env.CURSOR_ENDLESS_RETRIES = "true" + else delete process.env.CURSOR_ENDLESS_RETRIES const checkpoint = Uint8Array.from([0x0a, 0x01, 0x06]) let recoveries = 0 const failure = await pumpWithRecovery({ @@ -723,6 +775,8 @@ describe("interrupted Cursor Run handling", () => { expect(failure).toMatchObject({ retryable: false, transient: false }) expect((failure as Error).message).toContain("2 resume attempts made no progress") expect(recoveries).toBe(2) + if (saved === undefined) delete process.env.CURSOR_ENDLESS_RETRIES + else process.env.CURSOR_ENDLESS_RETRIES = saved }) it("does not resume an earlier checkpoint once the Run sent a newer user message", async () => { From 23824d1972eb8f7c53d63a9120f481c92e137e91 Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:46:21 +0200 Subject: [PATCH 06/11] fix: stop unsafe replacement uploads before checkpoint recovery --- CHANGELOG.md | 2 +- README.md | 7 +++-- src/language-model.ts | 9 ++++-- tasks/lessons/opencode.md | 4 +++ test/run-error-host.test.ts | 5 +++- test/run-interruption.test.ts | 52 ++++++++++++++++++++++++++++++++++- 6 files changed, 71 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17930c54..45953f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ - Once the provider spends its retry budget, or cannot replay a Run without repeating a tool call or other side effect, OpenCode no longer starts another retry loop ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Like Cursor CLI, a Run that drops or stalls after showing only text or reasoning, before Cursor saved a checkpoint, is retried by OpenCode instead of ending the turn, and the request reaches Cursor in that retry instead of with your next message. A retry after a checkpoint resumes the turn's latest checkpoint, and two resumes that save nothing end the turn ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100), reported by [@nkoynov](https://github.com/nkoynov)) - Retry counts now follow Cursor CLI: up to 10 transport retries and 3 server retries per user turn, shared across checkpoints and OpenCode retries. `CURSOR_ENDLESS_RETRIES=1` or `true` bypasses count limits while preserving replay guards and the no-progress and stall cutoffs ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) -- Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. Completed tools remain replay barriers across host steps. Follow-ups accept checkpoints received while opening the new Run, and older checkpoints stay ineligible after a tool result or follow-up message ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) +- Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. Completed tools remain replay barriers across host steps. Follow-ups accept checkpoints received while opening the new Run, and older checkpoints stay ineligible after a tool result or follow-up message. An ambiguous replacement upload ends the turn instead of retrying through an older checkpoint, including in endless mode ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - On macOS, a shell command that printed nothing before being moved to the background no longer reports `head: illegal byte count -- 0` - A tool that is still running no longer lets its Cursor Run expire at the inactivity limit. The Run stays open until the tool finishes, for up to four hours from when that tool started - An edit Cursor starts announcing shows its tool row while the arguments are still streaming. If that call never runs, the row closes instead of being left for the host to execute ([#73](https://github.com/oakimov/cursor-opencode-provider/issues/73)) diff --git a/README.md b/README.md index 0e9504ca..1d4d4472 100644 --- a/README.md +++ b/README.md @@ -341,9 +341,10 @@ the retry after partial output. Set `CURSOR_ENDLESS_RETRIES=1` or `true` before starting OpenCode (or its daemon) to bypass both count limits and `retry.maxAttempts`, like Cursor CLI's endless retry mode. This keeps retrying eligible transient failures; it preserves Stop, -final errors, authentication rules and tool replay guards. It also retains -Cursor CLI's safety cutoffs: two resumed attempts that stream without a new -checkpoint end the turn, and consecutive semantic-stall retries stop once +final errors, authentication rules and tool replay guards. An upload with +uncertain delivery ends the turn even when an older checkpoint exists. +Cursor CLI's safety cutoffs remain: two resumed attempts that stream without a +new checkpoint end the turn, and consecutive semantic-stall retries stop once 180 seconds have elapsed since the first stall retry. A non-stall failure resets that stall window. The setting does not override OpenCode's own retry limits. diff --git a/src/language-model.ts b/src/language-model.ts index 4bdd518c..670f822f 100644 --- a/src/language-model.ts +++ b/src/language-model.ts @@ -898,10 +898,10 @@ export function createCursorLanguageModel( forgetRetryBudget(opencodeSessionKey(callOptions), retryBudget) throw new CursorLocalCancellationError("Cursor Run setup cancelled", error) } - if (!failure.transient) throw failure - if (!failure.replaySafe) { + if (!failure.replaySafe && (failure.transient || failure.origin === "transport")) { throw retrySuppressedError(failure, "while opening the Run", retryBudget.retries + 1, retryPolicy.maxAttempts ?? 14) } + if (!failure.transient) throw failure reserveRetry(retryBudget, failure, retryPolicy.maxAttempts) throw hostRetryError(failure, true) } catch (terminal) { @@ -1346,6 +1346,11 @@ export async function pumpWithRecovery(input: { session = await reopen(pumped, failure, checkpoint) } catch (error) { const failure = toCursorProviderError(error, { replaySafe: true, fallback: "Cursor recovery setup failed" }) + // The failed upload belongs to the replacement Run. The preceding Run's + // checkpoint cannot prove that the replacement did not already start work. + if (!failure.replaySafe && (failure.transient || failure.origin === "transport")) { + throw retrySuppressedError(failure, "while opening the replacement Run", retryBudget.retries + 1, maxRecoveries + 1) + } if (!failure.transient) throw error openingFailure = failure } diff --git a/tasks/lessons/opencode.md b/tasks/lessons/opencode.md index c3e10d1f..3711216a 100644 --- a/tasks/lessons/opencode.md +++ b/tasks/lessons/opencode.md @@ -141,6 +141,10 @@ These are failure lessons shared by both hosts, not a replacement for failure handed to an older host with unbounded retries otherwise restarts the allowance forever. Share counts across module copies and host tool steps; distinguish partial assistant output and host reminders from a new user turn. + Reject an unsafe replacement upload before consulting the preceding Run's + checkpoint: that checkpoint says nothing about work the replacement may have + started. Failed drains must also project as terminal to both hosts, including + during credential renewal and endless mode (`test/run-interruption.test.ts`). Endless mode bypasses counts only, preserving replay guards and CLI's 180 s consecutive-stall retry window (`src/retry-budget.ts`, `test/retry-budget.test.ts`, `test/run-error-host.test.ts`). diff --git a/test/run-error-host.test.ts b/test/run-error-host.test.ts index c06da33a..b06e70f0 100644 --- a/test/run-error-host.test.ts +++ b/test/run-error-host.test.ts @@ -386,11 +386,12 @@ describe("retry limits across OpenCode calls", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-setup-retry-budget-")) let renewals = 0 let ambiguous = false + let transient = true const model = createCursor({ name: "cursor", agentBaseURL: ORIGIN, cacheDir: root, getAccessToken: async () => { renewals++ - throw new CursorTransportError("connection reset", { transient: true, replaySafe: !ambiguous, code: "ECONNRESET" }) + throw new CursorTransportError("connection reset", { transient, replaySafe: !ambiguous, code: "ECONNRESET" }) }, }).languageModel("composer-2.5") const attempt = async (): Promise => { @@ -412,6 +413,8 @@ describe("retry limits across OpenCode calls", () => { await notifyHostInterrupt("ses_setup_retries", "test-stop") ambiguous = true expect(await attempt()).toMatchObject({ isRetryable: false, responseHeaders: { "x-should-retry": "false" } }) + transient = false + expect(await attempt()).toMatchObject({ isRetryable: false, responseHeaders: { "x-should-retry": "false" } }) expect(runs).toBe(0) } finally { setHostCacheDirOverride(undefined) diff --git a/test/run-interruption.test.ts b/test/run-interruption.test.ts index 9273085c..f84196e8 100644 --- a/test/run-interruption.test.ts +++ b/test/run-interruption.test.ts @@ -14,7 +14,8 @@ import { import { decodeMessage, encodeMessage } from "../src/protocol/messages.js" import { sessionManager, type CursorSession, type Frame } from "../src/session.js" import { CursorRunInterruptedError } from "../src/transport/connect.js" -import { CursorAuthError, CursorRetryExhaustedError, CursorServerError, CursorTransportError } from "../src/errors.js" +import { CursorAuthError, CursorRetryExhaustedError, CursorServerError, CursorTransportError, hostFacingError } from "../src/errors.js" +import { writeStreamMessage } from "../src/stream-write.js" import { sessionFixture } from "./session-fixture.js" function fakeSession(id: string, frames: Frame[], writes: Uint8Array[] = []): CursorSession { @@ -633,6 +634,55 @@ describe("interrupted Cursor Run handling", () => { expect(openings).toBe(11) }) + for (const endless of [false, true]) { + for (const renewal of [false, true]) { + for (const upload of ["throw", "drain"] as const) { + it(`ends an unsafe replacement ${upload} with an older checkpoint (renewal=${renewal}, endless=${endless})`, async () => { + const saved = process.env.CURSOR_ENDLESS_RETRIES + if (endless) process.env.CURSOR_ENDLESS_RETRIES = "true" + else delete process.env.CURSOR_ENDLESS_RETRIES + const checkpoint = Uint8Array.of(10, 1, 21) + let openings = 0 + let renewals = 0 + let uploadFailure: unknown + try { + const failure = await pumpWithRecovery({ + initialSession: fakeSession("before-unsafe-replacement", [ + serverFrame({ conversation_checkpoint_update: checkpoint }), + ...(renewal ? [endStreamError("unauthenticated")] : []), + ]), + controller: controller([]), retryPolicy: { baseDelayMs: 0, maxDelayMs: 0 }, + renewRejectedCredential: () => { renewals++ }, + recover: async recovery => { + openings++ + expect(recovery).toMatchObject({ kind: "resume", checkpoint }) + // A second opening makes the buggy path finish instead of looping. + if (openings > 1) return fakeSession("unexpected-replay", [turnEnded()]) + const stream = fakeSession("ambiguous-upload", []).stream + const reset = Object.assign(new Error("upload connection reset"), { code: "ECONNRESET" }) + stream.write = () => { if (upload === "throw") throw reset; return false } + stream.waitForDrain = async () => { throw reset } + try { await writeStreamMessage(stream, Uint8Array.of(1), "replacement Run request") } + catch (error) { uploadFailure = error; throw error } + throw new Error("The replacement upload must fail") + }, + }).catch((error: unknown) => error) + + expect(hostFacingError(failure)).toMatchObject({ + statusCode: 400, isRetryable: false, responseHeaders: { "x-should-retry": "false" }, + }) + expect(failure).toMatchObject({ retryable: false, replaySafe: false, cause: uploadFailure }) + expect(openings).toBe(1) + expect(renewals).toBe(renewal ? 1 : 0) + } finally { + if (saved === undefined) delete process.env.CURSOR_ENDLESS_RETRIES + else process.env.CURSOR_ENDLESS_RETRIES = saved + } + }) + } + } + } + it("uses retry.maxAttempts as the total Run attempt budget", async () => { let recoveries = 0 await expect( From 3d23261eaa5a353ae94f77a11cad9d1b24cfa202 Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:41:28 +0200 Subject: [PATCH 07/11] fix: preserve host retry budgets and recover completed tool results --- AGENTS.md | 6 ++- CHANGELOG.md | 4 +- README.md | 7 +++ docs/opencode-1.md | 6 ++- docs/opencode-2.md | 7 ++- src/language-model.ts | 50 ++++++++++++++--- src/replay-safety.ts | 18 ++++++- src/retry-budget.ts | 24 ++++++++- src/session.ts | 2 + tasks/lessons/opencode.md | 9 ++++ test/retry-budget.test.ts | 31 +++++++++++ test/run-error-host.test.ts | 103 ++++++++++++++++++++++++++++++++++-- 12 files changed, 246 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 06c4f258..3da91319 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -208,7 +208,11 @@ The **1.18 v2** API and **OpenCode 2.0** are source-incompatible: Stateful barriers survive host tool steps. Two resumed attempts that stream without a new checkpoint end the turn. With no checkpoint in the turn, a transient failure after only text or reasoning - is `hostRetryable` (the CLI resends the action); any other barrier stays final. + is `hostRetryable` (the CLI resends the action). A silent drop immediately + after delivered host results may rebase from history only when every observed + exec has a completed result and no opaque activity occurred. Include all + completed tool steps; never resume their earlier checkpoint. Other barriers + stay final. Missing-blob recovery is limited to the initial control-only checkpoint path (`isSoleControlFrame`); unknown/mixed frames keep the normal replay barrier. - Carry an uncheckpointed request only on its unchanged checkpoint; persist it diff --git a/CHANGELOG.md b/CHANGELOG.md index 45953f9c..e2a7ee82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,8 +29,8 @@ - Like Cursor CLI, errors that need your action (usage or rate limits, sign-in, pricing, API key, outdated client), a bare `permission_denied`, and authentication failures, including those before a Run opens, end the turn instead of being retried; on OpenCode 1.x this also covers final `internal`/`unknown` errors. Final Cursor details take precedence over token renewal ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Once the provider spends its retry budget, or cannot replay a Run without repeating a tool call or other side effect, OpenCode no longer starts another retry loop ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Like Cursor CLI, a Run that drops or stalls after showing only text or reasoning, before Cursor saved a checkpoint, is retried by OpenCode instead of ending the turn, and the request reaches Cursor in that retry instead of with your next message. A retry after a checkpoint resumes the turn's latest checkpoint, and two resumes that save nothing end the turn ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100), reported by [@nkoynov](https://github.com/nkoynov)) -- Retry counts now follow Cursor CLI: up to 10 transport retries and 3 server retries per user turn, shared across checkpoints and OpenCode retries. `CURSOR_ENDLESS_RETRIES=1` or `true` bypasses count limits while preserving replay guards and the no-progress and stall cutoffs ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) -- Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. Completed tools remain replay barriers across host steps. Follow-ups accept checkpoints received while opening the new Run, and older checkpoints stay ineligible after a tool result or follow-up message. An ambiguous replacement upload ends the turn instead of retrying through an older checkpoint, including in endless mode ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) +- Retry counts now follow Cursor CLI: up to 10 transport retries and 3 server retries per user turn, shared across checkpoints and OpenCode retries, including OpenCode 2's interruption notices. `CURSOR_ENDLESS_RETRIES=1` or `true` bypasses count limits while preserving replay guards and the no-progress and stall cutoffs ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) +- Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. A drop immediately after completed tools can recover from complete host history before new output, without resuming a checkpoint predating their results. Other stateful activity remains a replay barrier. Follow-ups accept checkpoints received while opening the new Run. An ambiguous replacement upload ends the turn instead of retrying through an older checkpoint, including in endless mode ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - On macOS, a shell command that printed nothing before being moved to the background no longer reports `head: illegal byte count -- 0` - A tool that is still running no longer lets its Cursor Run expire at the inactivity limit. The Run stays open until the tool finishes, for up to four hours from when that tool started - An edit Cursor starts announcing shows its tool row while the arguments are still streaming. If that call never runs, the row closes instead of being left for the host to execute ([#73](https://github.com/oakimov/cursor-opencode-provider/issues/73)) diff --git a/README.md b/README.md index 1d4d4472..3cd0d03f 100644 --- a/README.md +++ b/README.md @@ -330,8 +330,15 @@ budget; early stream closure and the inbound read-idle deadline use the server budget. Retries of the same turn share these counts across checkpoints, tool steps and OpenCode's own retry calls. A new user turn, successful completion or Stop starts a fresh budget. Counts are held in memory for the running process. +OpenCode 2's interruption notices keep the same budget as the original request. OpenCode may stop earlier under its own retry policy. +If a Run drops immediately after completed tool results, before new output, +the provider can rebuild from host history containing every observed exec's +result. It sends the completed results once and asks Cursor to continue. A +checkpoint predating those results is never resumed. Missing results, unknown +activity or a new interaction keep the failure final. + For direct SDK use, `retry.maxAttempts` adds a total attempt ceiling, including the initial attempt; omitting it uses the two Cursor CLI budgets. The existing backoff defaults remain 500 ms full jitter, growing exponentially to an 8 s diff --git a/docs/opencode-1.md b/docs/opencode-1.md index d38ae46c..49f8ad3b 100644 --- a/docs/opencode-1.md +++ b/docs/opencode-1.md @@ -140,8 +140,10 @@ stored messages are preserved. ## Interrupted model requests OpenCode retries a transient failure after only text or reasoning when the -turn has no new checkpoint. Stateful activity, including a completed tool from -an earlier host step, stays a replay barrier. Recovery retains the latest +turn has no new checkpoint. Stateful activity stays a replay barrier. A drop +immediately after completed tool results can rebuild from complete host history +before new output; missing results or unrepresented activity remain final. +Recovery retains the latest eligible checkpoint across token renewal, including a rejected credential on an untouched resumed attempt. Follow-ups retain checkpoints received while opening the new Run. Two resumed attempts that stream without saving a diff --git a/docs/opencode-2.md b/docs/opencode-2.md index f0604b7c..9ef72d0b 100644 --- a/docs/opencode-2.md +++ b/docs/opencode-2.md @@ -211,8 +211,10 @@ patch operations that would lose CRLF bytes before changing a file. Native ## Interrupted model requests OpenCode retries a transient failure after only text or reasoning when the -turn has no new checkpoint. Stateful activity, including a completed tool from -an earlier host step, stays a replay barrier. Recovery retains the latest +turn has no new checkpoint. Stateful activity stays a replay barrier. A drop +immediately after completed tool results can rebuild from complete host history +before new output; missing results or unrepresented activity remain final. +Recovery retains the latest eligible checkpoint across token renewal, including a rejected credential on an untouched resumed attempt. Follow-ups retain checkpoints received while opening the new Run. Two resumed attempts that stream without saving a @@ -220,6 +222,7 @@ checkpoint end the turn. The provider shares Cursor CLI's limits of 10 transport retries and 3 server retries across this turn, including OpenCode's retries. OpenCode can stop earlier. +The host's plain-text interruption notice does not reset the provider's budget. Set `CURSOR_ENDLESS_RETRIES=1` or `true` before starting the host (or its daemon) to bypass count limits while retaining replay guards, Stop, final errors, the two-resume cutoff and the 180 s consecutive semantic-stall retry window. diff --git a/src/language-model.ts b/src/language-model.ts index 670f822f..f84bf2a1 100644 --- a/src/language-model.ts +++ b/src/language-model.ts @@ -831,9 +831,10 @@ function noteRunImageCheckpoint(session: CursorSession, checkpoint: Uint8Array, } /** A resumed model step cannot replay a result or follow-up absent from its checkpoint. */ -function invalidateResumeCheckpoint(session: CursorSession, afterFrame = lastFrameArrival()): void { +function invalidateResumeCheckpoint(session: CursorSession, afterFrame = lastFrameArrival(), hostResult = false): void { session.resumeCheckpoint = undefined session.checkpointRevoked = true + session.checkpointRevokedByHostResult = hostResult session.resumeCheckpointAfterFrame = afterFrame } @@ -844,6 +845,7 @@ function acceptResumeCheckpoint(session: CursorSession, bytes: Uint8Array, frame && arrived <= session.resumeCheckpointAfterFrame) return session.resumeCheckpoint = Uint8Array.from(bytes) session.checkpointRevoked = false + session.checkpointRevokedByHostResult = false session.resumeCheckpointAfterFrame = undefined } @@ -888,7 +890,8 @@ export function createCursorLanguageModel( // Both public entrypoints must project those failures just like pump errors. const doStream = async (callOptions: LanguageModelV3CallOptions): Promise => { const retryPolicy = resolveRetryPolicy(options.retry) - const retryBudget = turnRetryBudget(opencodeSessionKey(callOptions), retryTurnKey(callOptions.prompt)) + const retryBudget = turnRetryBudget(opencodeSessionKey(callOptions), + retryTurnKey(callOptions.prompt), retryTurnKey(callOptions.prompt, true)) try { return await doStreamImpl(modelId, options, callOptions, retryPolicy, retryBudget) } catch (error) { @@ -903,6 +906,7 @@ export function createCursorLanguageModel( } if (!failure.transient) throw failure reserveRetry(retryBudget, failure, retryPolicy.maxAttempts) + retryBudget.hostRetryPending = true throw hostRetryError(failure, true) } catch (terminal) { throw hostFacingError(terminal) @@ -1162,6 +1166,15 @@ async function doStreamImpl( abortSignal: callOptions.abortSignal, retryPolicy, retryBudget, + canRebaseFromHistory: (failed) => { + if (failed !== session || trailingToolResults.length === 0 + || !failed.checkpointRevokedByHostResult || failed.pending.size > 0 + || failed.replaySafety?.historyRebaseUnsafe) return false + const completed = new Set(extractToolResults(prompt) + .filter(result => result.sessionId === failed.sessionId).map(result => result.execId)) + const execIds = failed.replaySafety?.execIds + return !!execIds?.length && execIds.every(id => completed.has(id)) + }, recover: (recovery) => openSession({ recovery }), onSession: (next) => { activeSession = next }, ...(credentialRenewable @@ -1238,6 +1251,8 @@ export async function pumpWithRecovery(input: { abortSignal?: AbortSignal retryPolicy?: CursorRetryPolicy retryBudget?: RetryBudget + /** Proven completed host results can seed history without replaying their calls. */ + canRebaseFromHistory?: (session: CursorSession) => boolean recover: (recovery: CursorRunRecovery) => Promise onSession?: (session: CursorSession) => void maxRecoveries?: number @@ -1279,7 +1294,7 @@ export async function pumpWithRecovery(input: { conversationId: pumpedSession.conversationId, checkpoint: Uint8Array.from(checkpoint), } - : { kind: "rebase" } + : { kind: "rebase", ...(input.canRebaseFromHistory?.(pumpedSession) ? { reason: "completed-tool-results" as const } : {}) } resumedAttempt = recovery.kind === "resume" // A rebase opens another conversation; its checkpoints start over. if (!resumedAttempt) turnCheckpoint = undefined @@ -1454,12 +1469,14 @@ export async function pumpWithRecovery(input: { throw noProgressResumeError(failure, noProgressResumes) } } - if (!failure.replaySafe && !checkpoint) { + const historyRecovery = failure.replayBarrier === undefined && input.canRebaseFromHistory?.(pumpedSession) + if (!failure.replaySafe && !checkpoint && !historyRecovery) { // Cursor CLI sends the same action again. Shown output keeps the // provider from doing so; the host can, unless a side effect would repeat. if (failure.replayBarrier === "output-only" && !pumpedSession.checkpointRevoked) { trace(`Run interrupted after output only: sessionId=${pumpedSession.sessionId} — leaving the retry to the host`) reserveRetry(retryBudget, failure, retryPolicy.maxAttempts) + retryBudget.hostRetryPending = true throw hostRetryError(failure) } throw retrySuppressedError( @@ -1496,7 +1513,7 @@ export async function pumpWithRecovery(input: { } export type CursorRunRecovery = - | { kind: "rebase"; reason?: "checkpoint-unusable" | "unfinished-prior-run" } + | { kind: "rebase"; reason?: "checkpoint-unusable" | "unfinished-prior-run" | "completed-tool-results" } | { kind: "resume"; conversationId: string; checkpoint: Uint8Array } const heartbeatWritePendingBySession = new WeakMap() @@ -1891,7 +1908,9 @@ async function startSession( liveTurnStart: liveTurn?.start, preserveTrailingUser: recovery?.kind === "rebase" && !checkpointUnusable && !unfinishedPriorRun, // A foreign-history rebase replays the other model's tool results too. - toolResults: isCompaction || foreignHistory || checkpointUnusable || unfinishedPriorRun ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"), + toolResults: isCompaction || foreignHistory || checkpointUnusable || unfinishedPriorRun + || (recovery?.kind === "rebase" && recovery.reason === "completed-tool-results") + ? "all" : (recovery?.kind === "rebase" ? "trailing" : "omit"), }) const userTurnHostNotes = prepareUserTurnHostNotes({ sessionKey, @@ -3547,7 +3566,7 @@ async function deliverContinuationResultsNow( } deliveredResults++ if (outcome.framesWritten > 0 || (pending.bridged && !pending.aborted)) { - invalidateResumeCheckpoint(session) + invalidateResumeCheckpoint(session, lastFrameArrival(), true) } if (session.historyImages) noteHistoryImagesSent(session.historyImages, deliveredImageHashes, lastFrameArrival()) if (deliveredSwitchMode) { @@ -5041,6 +5060,7 @@ export async function pump( } } if (replayFrame.barrier) replaySafety.markBarrier(replayFrame.barrier) + if (replayFrame.barrier === "non-control-exec") replaySafety.recordExec(esm?.id) // Reseeding is allowed only while every frame so far was positively a // control frame. Anything else, including unknown top-level fields, may have // carried output or stateful activity. @@ -6881,10 +6901,24 @@ export function cursorTurnEndedProviderMetadata( } /** Partial assistant output may grow on a host retry; it is not a new user turn. */ -function retryTurnKey(prompt: LanguageModelV3CallOptions["prompt"]): string { +export function retryTurnKey(prompt: LanguageModelV3CallOptions["prompt"], withoutLastInterruptionNotice = false): string { + // OpenCode 2 runner/llm.ts emits this synthetic after an incomplete stream; + // runner/to-llm-message.ts lowers it to an ordinary user text part. Limit + // recognition to exact, standalone text after assistant output. The budget + // accepts that candidate only after a requested host retry, then remembers + // its full key across tool steps. Actual prompt content is never removed. + const interrupted = "The previous response was interrupted. Continue from where you left off without repeating completed content." const messages = prompt.filter(message => message.role !== "system" && !hostTailNote(message)) let end = messages.length while (end > 0 && messages[end - 1]!.role !== "user") end-- + const last = messages[end - 1] + if (withoutLastInterruptionNotice && last?.role === "user" && last.content.length === 1 + && last.content[0]?.type === "text" && last.content[0].text === interrupted + && messages[end - 2]?.role === "assistant") { + // Remove only this continuation, retaining earlier literal user requests. + end-- + while (end > 0 && messages[end - 1]!.role !== "user") end-- + } return createHash("sha256").update(JSON.stringify(messages.slice(0, end))).digest("hex") } diff --git a/src/replay-safety.ts b/src/replay-safety.ts index fc12e9cc..b2358c9b 100644 --- a/src/replay-safety.ts +++ b/src/replay-safety.ts @@ -15,7 +15,13 @@ export type ReplayBarrierReason = const OUTPUT_ONLY_BARRIERS: ReadonlySet = new Set(["visible-text", "visible-reasoning"]) /** Plain Run-owned state retained across host tool-step pump calls. */ -export type ReplaySafetyState = { statefulBarrier?: ReplayBarrierReason } +export type ReplaySafetyState = { + statefulBarrier?: ReplayBarrierReason + /** Every non-control exec must have a completed host result before history recovery. */ + execIds?: number[] + /** History cannot represent opaque interactions, unknown frames or display-only work. */ + historyRebaseUnsafe?: boolean +} export class AttemptReplaySafety { private barrierReason: ReplayBarrierReason | undefined @@ -32,12 +38,22 @@ export class AttemptReplaySafety { if (!OUTPUT_ONLY_BARRIERS.has(reason)) { this.stateful = true this.state.statefulBarrier ??= reason + if (reason !== "non-control-exec") this.state.historyRebaseUnsafe = true } if (this.barrierReason) return this.barrierReason = reason trace(`replay barrier: reason=${reason} sessionId=${this.sessionId}`) } + recordExec(id: unknown): void { + const ids = this.state.execIds ??= [] + if (typeof id !== "number" || !Number.isSafeInteger(id) || ids.includes(id)) { + this.state.historyRebaseUnsafe = true + return + } + ids.push(id) + } + applyTo(failure: CursorProviderError): CursorProviderError { failure.replaySafe = this.barrierReason === undefined && !this.stateful && failure.replaySafe if (this.barrierReason) { diff --git a/src/retry-budget.ts b/src/retry-budget.ts index 68fa520d..fb91d240 100644 --- a/src/retry-budget.ts +++ b/src/retry-budget.ts @@ -7,6 +7,9 @@ export type RetryBudget = { transport: number retries: number stallStartedAt?: number + /** A plain-text host continuation is admitted only after we requested a host retry. */ + hostRetryPending?: boolean + hostRetryKey?: string } const budgets = () => processShared("run-retry-budgets.v1", () => new Map>()) @@ -18,7 +21,7 @@ export function newRetryBudget(): RetryBudget { } /** A host retry or tool continuation must not mint a fresh retry allowance. */ -export function turnRetryBudget(sessionKey: string | undefined, turnKey: string): RetryBudget { +export function turnRetryBudget(sessionKey: string | undefined, turnKey: string, continuationKey?: string): RetryBudget { if (!sessionKey) return newRetryBudget() const sessions = budgets() let turns = sessions.get(sessionKey) @@ -27,10 +30,27 @@ export function turnRetryBudget(sessionKey: string | undefined, turnKey: string) } sessions.delete(sessionKey) sessions.set(sessionKey, turns) - let budget = turns.get(turnKey) + const lookup = (key: string): [string, RetryBudget] | undefined => { + const direct = turns.get(key) + return direct ? [key, direct] : [...turns].find(([, budget]) => budget.hostRetryKey === key) + } + let entry = lookup(turnKey) + let budget = entry?.[1] + const currentKey = turnKey + if (entry) turnKey = entry[0] + if (!budget && continuationKey !== undefined) { + entry = lookup(continuationKey) + const previous = entry?.[1] + if (previous?.hostRetryPending) { + budget = previous + budget.hostRetryKey = currentKey + turnKey = entry![0] + } + } if (!budget) { budget = newRetryBudget() } + budget.hostRetryPending = false turns.delete(turnKey) turns.set(turnKey, budget) // Abandoned host retries and ephemeral helpers never reach turn_ended. diff --git a/src/session.ts b/src/session.ts index 9fb6deba..401431eb 100644 --- a/src/session.ts +++ b/src/session.ts @@ -211,6 +211,8 @@ export type CursorSession = { * recovery must not resume earlier state that lacks the delivered input. */ checkpointRevoked?: boolean + /** Only a delivered host result, rather than an unrepresented follow-up, revoked it. */ + checkpointRevokedByHostResult?: boolean /** Ignore queued checkpoints received before the latest message/result delivery. */ resumeCheckpointAfterFrame?: number /** Stateful replay barriers survive host tool-step boundaries on this Run. */ diff --git a/tasks/lessons/opencode.md b/tasks/lessons/opencode.md index 3711216a..919ca98d 100644 --- a/tasks/lessons/opencode.md +++ b/tasks/lessons/opencode.md @@ -141,6 +141,15 @@ These are failure lessons shared by both hosts, not a replacement for failure handed to an older host with unbounded retries otherwise restarts the allowance forever. Share counts across module copies and host tool steps; distinguish partial assistant output and host reminders from a new user turn. + Test the canonical host projection: OpenCode 2's interruption notice is plain + user text, not a ``, and otherwise reset the budget on every + continuation (`test/run-error-host.test.ts`). Admit it only after a requested + host retry, retaining its full prompt key across tool steps; a later literal + user request with that text still needs a new budget. Immediately after completed + host results, history recovery needs every observed exec's result, no new + output, and no opaque interaction or unknown frame. Seed all completed tool + steps; retaining only the last result loses earlier work. Never use the stale + checkpoint for that recovery. Reject an unsafe replacement upload before consulting the preceding Run's checkpoint: that checkpoint says nothing about work the replacement may have started. Failed drains must also project as terminal to both hosts, including diff --git a/test/retry-budget.test.ts b/test/retry-budget.test.ts index c2544428..99c48f23 100644 --- a/test/retry-budget.test.ts +++ b/test/retry-budget.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it } from "bun:test" import { CursorRetryExhaustedError, CursorServerError, CursorTransportError, hostFacingError } from "../src/errors.js" import { CursorRunInterruptedError } from "../src/transport/connect.js" +import { retryTurnKey } from "../src/language-model.js" +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" import { endlessRetriesEnabled, forgetRetryBudget, newRetryBudget, reserveRetry, resetRetryBudgetsForTests, retryCategory, turnRetryBudget } from "../src/retry-budget.js" const savedEndless = process.env.CURSOR_ENDLESS_RETRIES @@ -15,6 +17,35 @@ const server = () => new CursorServerError("unavailable", { transient: true, rep const stall = () => new CursorTransportError("Stalled", { transient: true, replaySafe: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" }) describe("Cursor CLI retry budgets", () => { + it("ignores only standalone host interruption notices in an ongoing turn", () => { + const notice = "The previous response was interrupted. Continue from where you left off without repeating completed content." + const user = (text: string): LanguageModelV3Prompt[number] => ({ role: "user", content: [{ type: "text", text }] }) + const prompt: LanguageModelV3Prompt = [user("Continue")] + const key = retryTurnKey(prompt) + prompt.push({ role: "assistant", content: [{ type: "text", text: "partial answer" }] }) + prompt.push(user("new instructions"), user(notice)) + expect(retryTurnKey(prompt)).not.toBe(key) + expect(retryTurnKey(prompt, true)).toBe(key) + expect(retryTurnKey([user(notice)])).not.toBe(retryTurnKey([])) + expect(retryTurnKey([...prompt, user("Continue")])).not.toBe(key) + expect(retryTurnKey([...prompt, user(`${notice}\nPlease inspect the diff`)])).not.toBe(key) + expect(retryTurnKey([...prompt, { role: "user", content: [{ type: "text", text: notice }, + { type: "file", data: new Uint8Array([1]), mediaType: "image/png" }] }])).not.toBe(key) + }) + + it("requires a requested host retry before sharing a plain-text continuation's budget", () => { + const original = turnRetryBudget("session", "original") + reserveRetry(original, server()) + expect(turnRetryBudget("session", "user-quote", "original")).not.toBe(original) + original.hostRetryPending = true + expect(turnRetryBudget("session", "host-notice", "original")).toBe(original) + // The admitted prompt still owns the same budget during later tool steps. + expect(turnRetryBudget("session", "host-notice", "original")).toBe(original) + original.hostRetryPending = true + expect(turnRetryBudget("session", "next-host-notice", "host-notice")).toBe(original) + expect(turnRetryBudget("session", "later-user-quote", "next-host-notice")).not.toBe(original) + }) + it("allows ten transport retries and three server retries independently", () => { delete process.env.CURSOR_ENDLESS_RETRIES const budget = newRetryBudget() diff --git a/test/run-error-host.test.ts b/test/run-error-host.test.ts index b06e70f0..8da2ebb9 100644 --- a/test/run-error-host.test.ts +++ b/test/run-error-host.test.ts @@ -11,7 +11,7 @@ import { decodeMessage, encodeMessage } from "../src/protocol/messages.js" import { cacheHttp2SessionForTests, closeCachedHttp2SessionsForTests, lastFrameArrival } from "../src/transport/connect.js" import { setHostCacheDirOverride } from "../src/context/paths.js" import { sessionManager } from "../src/session.js" -import { resetTurnStateForTests } from "../src/language-model.js" +import { resetTurnStateForTests, settleRunSavesForTests } from "../src/language-model.js" import { resetConversationBindingsForTests } from "../src/protocol/conversation-bind.js" import { resetCheckpointsForTests } from "../src/protocol/checkpoint.js" import { resetConversationPersistenceForTests } from "../src/protocol/conversation-persistence.js" @@ -127,6 +127,91 @@ async function streamError(sessionKey: string): Promise { } describe("checkpoint eligibility across host results", () => { + for (const scenario of ["complete", "missing-history", "unknown", "unrepresented-exec", "duplicate-exec", "new-output", "final-error"] as const) { + it(`recovers a post-result drop only from represented completed work (${scenario})`, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-result-history-")) + type RunRequest = { conversation_id: string; action: { resume_action?: unknown; user_message_action?: { user_message: { text: string } } } } + const requests: RunRequest[] = [] + const delivered: number[] = [] + scripted = (stream, message) => { + const send = (value: Record) => stream.write(frame(encodeMessage("AgentServerMessage", value))) + if (message.run_request) { + requests.push(message.run_request as RunRequest) + if (requests.length > 1) { + send({ interaction_update: { turn_ended: {} } }) + stream.end() + return + } + send({ conversation_checkpoint_update: Uint8Array.of(10, 1, 7) }) + if (scenario === "unknown") stream.write(frame(Uint8Array.of(0xa2, 0x06, 0))) // unknown field 100 + if (scenario === "unrepresented-exec") send({ exec_server_message: { id: 90, list_mcp_resources_exec_args: {} } }) + if (scenario === "duplicate-exec") send({ exec_server_message: { id: 1, list_mcp_resources_exec_args: {} } }) + send({ exec_server_message: { id: 1, shell_stream_args: { command: "echo first", tool_call_id: "first" } } }) + } else if (message.exec_client_message) { + const id = (message.exec_client_message as { id: number }).id + if (!(message.exec_client_message as Record).shell_stream || delivered.includes(id)) return + delivered.push(id) + if (id === 1) { + send({ exec_server_message: { id: 2, shell_stream_args: { command: "echo second", tool_call_id: "second" } } }) + } else { + if (scenario === "new-output") send({ interaction_update: { text_delta: { text: "new output" } } }) + if (scenario === "final-error") stream.end(frame(Buffer.from('{"error":{"code":"permission_denied"}}'), 2)) + else stream.end() // no checkpoint after either completed result + } + } + } + cacheHttp2SessionForTests(ORIGIN, client) + try { + const model = createCursor({ name: "cursor", accessToken: "test-token", agentBaseURL: ORIGIN, + workspaceRoot: root, cacheDir: root, retry: { baseDelayMs: 1, maxDelayMs: 1 } }).languageModel("composer-2.5") + const prompt: LanguageModelV3CallOptions["prompt"] = [{ role: "user", content: [{ type: "text", text: "Run both commands" }] }] + const options = { prompt, headers: { "x-opencode-session-id": `ses_result_history_${scenario}` }, + tools: [{ type: "function" as const, name: "bash", inputSchema: { type: "object", properties: { command: { type: "string" } } } }] } + const read = async () => { + const parts: LanguageModelV3StreamPart[] = [] + const { stream } = await model.doStream(options) + const reader = stream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + parts.push(value) + } + return parts + } + for (const output of ["first completed", "second completed"]) { + const parts = await read() + const call = parts.find(part => part.type === "tool-call") + if (!call || call.type !== "tool-call") throw new Error("Expected a host command") + prompt.push({ role: "assistant", content: [{ type: "tool-call", toolCallId: call.toolCallId, toolName: call.toolName, input: JSON.parse(call.input) }] }) + prompt.push({ role: "tool", content: [{ type: "tool-result", toolCallId: call.toolCallId, toolName: call.toolName, output: { type: "text", value: output } }] }) + } + if (scenario === "missing-history") prompt.splice(1, 2) + if (scenario === "complete") { + const parts = await read() + expect(parts.some(part => part.type === "finish" && part.finishReason.unified === "stop")).toBe(true) + expect(parts.some(part => part.type === "tool-call")).toBe(false) + expect(requests).toHaveLength(2) + expect(requests[1]!.action.resume_action).toBeUndefined() + expect(requests[1]!.conversation_id).not.toBe(requests[0]!.conversation_id) + const text = requests[1]!.action.user_message_action!.user_message.text + expect(text.match(/first completed/g)).toHaveLength(1) + expect(text.match(/second completed/g)).toHaveLength(1) + expect(text).toContain("Do not repeat completed work") + } else { + expect(await read().catch((error: unknown) => error)).toMatchObject({ isRetryable: false, + responseHeaders: { "x-should-retry": "false" } }) + expect(requests).toHaveLength(1) + } + expect(delivered).toEqual([1, 2]) + } finally { + sessionManager.dispose() + await settleRunSavesForTests() + setHostCacheDirOverride(undefined) + fs.rmSync(root, { recursive: true, force: true }) + } + }, 30_000) + } + for (const [afterResult, rejectReplacement] of [[false, false], [true, false], [true, true]] as const) { it(`only resumes a checkpoint received after the result (after: ${afterResult}, expired replacement: ${rejectReplacement})`, async () => { const checkpoint = Uint8Array.of(10, 1, 7) @@ -447,9 +532,10 @@ describe("retry limits across OpenCode calls", () => { try { for (let i = 0; i < retries; i++) { expect(await attempt()).toMatchObject({ isRetryable: true, responseHeaders: { "x-should-retry": "true" } }) - // OpenCode 2 keeps partial assistant output. A host system reminder is not a new request. + // Canonical OpenCode 2 runner/llm.ts Continue outcome, lowered by + // runner/to-llm-message.ts as a plain user message (no reminder wrapper). prompt.push({ role: "assistant", content: [{ type: "text", text: `partial ${i}` }] }) - prompt.push({ role: "user", content: [{ type: "text", text: "Retry the interrupted step." }] }) + prompt.push({ role: "user", content: [{ type: "text", text: "The previous response was interrupted. Continue from where you left off without repeating completed content." }] }) } expect(runs).toBe(retries) if (endless) { @@ -458,6 +544,17 @@ describe("retry limits across OpenCode calls", () => { } else { expect(await attempt()).toMatchObject({ isRetryable: false, statusCode: 400, responseHeaders: { "x-should-retry": "false" } }) expect(runs).toBe(retries + 1) + // After a final error, typing the exact same text is a genuine new + // request: there is no pending provider-requested host retry. + prompt.push({ role: "assistant", content: [{ type: "text", text: "final partial answer" }] }) + prompt.push({ role: "user", content: [{ type: "text", text: "The previous response was interrupted. Continue from where you left off without repeating completed content." }] }) + expect(await attempt()).toMatchObject({ isRetryable: true }) + for (let i = 1; i < retries; i++) { + prompt.push({ role: "assistant", content: [{ type: "text", text: `literal request partial ${i}` }] }) + prompt.push({ role: "user", content: [{ type: "text", text: "The previous response was interrupted. Continue from where you left off without repeating completed content." }] }) + expect(await attempt()).toMatchObject({ isRetryable: true }) + } + expect(await attempt()).toMatchObject({ isRetryable: false }) // A later identical user message is a new turn, even in the same session. prompt.push({ role: "user", content: [{ type: "text", text: "Hello" }] }) expect(await attempt()).toMatchObject({ isRetryable: true }) From f9620d69a6954dc56f64d43160053b8447733ae9 Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:47:03 +0200 Subject: [PATCH 08/11] fix: retain uncheckpointed follow-up recovery barriers --- src/language-model.ts | 5 ++++- tasks/lessons/opencode.md | 4 +++- test/run-error-host.test.ts | 13 +++++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/language-model.ts b/src/language-model.ts index f84bf2a1..e3e24db7 100644 --- a/src/language-model.ts +++ b/src/language-model.ts @@ -833,8 +833,11 @@ function noteRunImageCheckpoint(session: CursorSession, checkpoint: Uint8Array, /** A resumed model step cannot replay a result or follow-up absent from its checkpoint. */ function invalidateResumeCheckpoint(session: CursorSession, afterFrame = lastFrameArrival(), hostResult = false): void { session.resumeCheckpoint = undefined - session.checkpointRevoked = true + // A later result cannot make an earlier uncheckpointed follow-up appear + // represented in this call's host history. Only a new checkpoint clears it. session.checkpointRevokedByHostResult = hostResult + && (!session.checkpointRevoked || session.checkpointRevokedByHostResult === true) + session.checkpointRevoked = true session.resumeCheckpointAfterFrame = afterFrame } diff --git a/tasks/lessons/opencode.md b/tasks/lessons/opencode.md index 919ca98d..b96f9c89 100644 --- a/tasks/lessons/opencode.md +++ b/tasks/lessons/opencode.md @@ -149,7 +149,9 @@ These are failure lessons shared by both hosts, not a replacement for host results, history recovery needs every observed exec's result, no new output, and no opaque interaction or unknown frame. Seed all completed tool steps; retaining only the last result loses earlier work. Never use the stale - checkpoint for that recovery. + checkpoint for that recovery. A later tool result must not erase an earlier + uncheckpointed follow-up's barrier: that follow-up may be absent from the + host prompt. Only a newer eligible checkpoint clears the revocation. Reject an unsafe replacement upload before consulting the preceding Run's checkpoint: that checkpoint says nothing about work the replacement may have started. Failed drains must also project as terminal to both hosts, including diff --git a/test/run-error-host.test.ts b/test/run-error-host.test.ts index 8da2ebb9..e3988c9e 100644 --- a/test/run-error-host.test.ts +++ b/test/run-error-host.test.ts @@ -127,7 +127,7 @@ async function streamError(sessionKey: string): Promise { } describe("checkpoint eligibility across host results", () => { - for (const scenario of ["complete", "missing-history", "unknown", "unrepresented-exec", "duplicate-exec", "new-output", "final-error"] as const) { + for (const scenario of ["complete", "missing-history", "unknown", "unrepresented-exec", "duplicate-exec", "followup-before-result", "new-output", "final-error"] as const) { it(`recovers a post-result drop only from represented completed work (${scenario})`, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-result-history-")) type RunRequest = { conversation_id: string; action: { resume_action?: unknown; user_message_action?: { user_message: { text: string } } } } @@ -137,12 +137,17 @@ describe("checkpoint eligibility across host results", () => { const send = (value: Record) => stream.write(frame(encodeMessage("AgentServerMessage", value))) if (message.run_request) { requests.push(message.run_request as RunRequest) - if (requests.length > 1) { + if (requests.length > (scenario === "followup-before-result" ? 2 : 1)) { send({ interaction_update: { turn_ended: {} } }) stream.end() return } - send({ conversation_checkpoint_update: Uint8Array.of(10, 1, 7) }) + if (requests.length === 1) send({ conversation_checkpoint_update: Uint8Array.of(10, 1, 7) }) + if (scenario === "followup-before-result" && requests.length === 1) { + send({ interaction_update: { text_delta: { text: "Checking the workspace" } } }) + send({ interaction_update: { turn_ended: {} } }) + return + } if (scenario === "unknown") stream.write(frame(Uint8Array.of(0xa2, 0x06, 0))) // unknown field 100 if (scenario === "unrepresented-exec") send({ exec_server_message: { id: 90, list_mcp_resources_exec_args: {} } }) if (scenario === "duplicate-exec") send({ exec_server_message: { id: 1, list_mcp_resources_exec_args: {} } }) @@ -200,7 +205,7 @@ describe("checkpoint eligibility across host results", () => { } else { expect(await read().catch((error: unknown) => error)).toMatchObject({ isRetryable: false, responseHeaders: { "x-should-retry": "false" } }) - expect(requests).toHaveLength(1) + expect(requests).toHaveLength(scenario === "followup-before-result" ? 2 : 1) } expect(delivered).toEqual([1, 2]) } finally { From 640393ea1b28a1bb55c060c244bd5dcb73f59e03 Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:38:45 +0200 Subject: [PATCH 09/11] fix: preserve drained checkpoints and resumed exec ownership --- CHANGELOG.md | 1 + README.md | 2 + docs/opencode-1.md | 9 +- docs/opencode-2.md | 9 +- src/language-model.ts | 21 ++-- src/replay-safety.ts | 11 ++- src/session.ts | 12 ++- tasks/lessons/opencode.md | 5 + test/run-error-host.test.ts | 186 +++++++++++++++++++++++++++++++++++- test/session.test.ts | 29 ++++++ 10 files changed, 262 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2a7ee82..ca8ee11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Once the provider spends its retry budget, or cannot replay a Run without repeating a tool call or other side effect, OpenCode no longer starts another retry loop ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Like Cursor CLI, a Run that drops or stalls after showing only text or reasoning, before Cursor saved a checkpoint, is retried by OpenCode instead of ending the turn, and the request reaches Cursor in that retry instead of with your next message. A retry after a checkpoint resumes the turn's latest checkpoint, and two resumes that save nothing end the turn ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100), reported by [@nkoynov](https://github.com/nkoynov)) - Retry counts now follow Cursor CLI: up to 10 transport retries and 3 server retries per user turn, shared across checkpoints and OpenCode retries, including OpenCode 2's interruption notices. `CURSOR_ENDLESS_RETRIES=1` or `true` bypasses count limits while preserving replay guards and the no-progress and stall cutoffs ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) +- Tool recovery accepts checkpoints received while a completed result drains, and retains completed results across resumed Runs even when exec IDs restart ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. A drop immediately after completed tools can recover from complete host history before new output, without resuming a checkpoint predating their results. Other stateful activity remains a replay barrier. Follow-ups accept checkpoints received while opening the new Run. An ambiguous replacement upload ends the turn instead of retrying through an older checkpoint, including in endless mode ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - On macOS, a shell command that printed nothing before being moved to the background no longer reports `head: illegal byte count -- 0` - A tool that is still running no longer lets its Cursor Run expire at the inactivity limit. The Run stays open until the tool finishes, for up to four hours from when that tool started diff --git a/README.md b/README.md index 3cd0d03f..09d025c1 100644 --- a/README.md +++ b/README.md @@ -338,6 +338,8 @@ the provider can rebuild from host history containing every observed exec's result. It sends the completed results once and asks Cursor to continue. A checkpoint predating those results is never resumed. Missing results, unknown activity or a new interaction keep the failure final. +A checkpoint received while the completed result drains remains eligible. +History recovery retains completed results from earlier resumed Runs too. For direct SDK use, `retry.maxAttempts` adds a total attempt ceiling, including the initial attempt; omitting it uses the two Cursor CLI budgets. The existing diff --git a/docs/opencode-1.md b/docs/opencode-1.md index 49f8ad3b..f04875d4 100644 --- a/docs/opencode-1.md +++ b/docs/opencode-1.md @@ -143,11 +143,12 @@ OpenCode retries a transient failure after only text or reasoning when the turn has no new checkpoint. Stateful activity stays a replay barrier. A drop immediately after completed tool results can rebuild from complete host history before new output; missing results or unrepresented activity remain final. -Recovery retains the latest -eligible checkpoint across token renewal, including a rejected credential on -an untouched resumed attempt. Follow-ups retain checkpoints received while -opening the new Run. Two resumed attempts that stream without saving a +Recovery retains the latest eligible checkpoint across token renewal, including +a rejected credential on an untouched resumed attempt. Checkpoints received +while a completed tool result drains remain eligible. Follow-ups retain +checkpoints received while opening the new Run. Two resumed attempts that stream without saving a checkpoint end the turn. +History recovery keeps completed results from earlier resumed Runs too. The provider shares Cursor CLI's limits of 10 transport retries and 3 server retries across this turn, including OpenCode's retries. OpenCode can stop earlier. diff --git a/docs/opencode-2.md b/docs/opencode-2.md index 9ef72d0b..0fb0269d 100644 --- a/docs/opencode-2.md +++ b/docs/opencode-2.md @@ -214,11 +214,12 @@ OpenCode retries a transient failure after only text or reasoning when the turn has no new checkpoint. Stateful activity stays a replay barrier. A drop immediately after completed tool results can rebuild from complete host history before new output; missing results or unrepresented activity remain final. -Recovery retains the latest -eligible checkpoint across token renewal, including a rejected credential on -an untouched resumed attempt. Follow-ups retain checkpoints received while -opening the new Run. Two resumed attempts that stream without saving a +Recovery retains the latest eligible checkpoint across token renewal, including +a rejected credential on an untouched resumed attempt. Checkpoints received +while a completed tool result drains remain eligible. Follow-ups retain +checkpoints received while opening the new Run. Two resumed attempts that stream without saving a checkpoint end the turn. +History recovery keeps completed results from earlier resumed Runs too. The provider shares Cursor CLI's limits of 10 transport retries and 3 server retries across this turn, including OpenCode's retries. OpenCode can stop earlier. diff --git a/src/language-model.ts b/src/language-model.ts index e3e24db7..561658de 100644 --- a/src/language-model.ts +++ b/src/language-model.ts @@ -1174,9 +1174,9 @@ async function doStreamImpl( || !failed.checkpointRevokedByHostResult || failed.pending.size > 0 || failed.replaySafety?.historyRebaseUnsafe) return false const completed = new Set(extractToolResults(prompt) - .filter(result => result.sessionId === failed.sessionId).map(result => result.execId)) - const execIds = failed.replaySafety?.execIds - return !!execIds?.length && execIds.every(id => completed.has(id)) + .map(result => `${result.sessionId}:${result.execId}`)) + const execs = failed.replaySafety?.execs + return !!execs?.length && execs.every(exec => completed.has(`${exec.sessionId}:${exec.id}`)) }, recover: (recovery) => openSession({ recovery }), onSession: (next) => { activeSession = next }, @@ -1328,7 +1328,10 @@ export async function pumpWithRecovery(input: { recovery.kind === "resume" ? next.sessionId : undefined, ) if (recovery.kind === "resume") { - next.replaySafety = { ...pumpedSession.replaySafety } + next.replaySafety = { + ...pumpedSession.replaySafety, + execs: pumpedSession.replaySafety?.execs?.slice(), + } next.userTurnHostNotes = pumpedSession.userTurnHostNotes next.usageEstimate = { ...pumpedSession.usageEstimate } next.editToolCalls = new Map(pumpedSession.editToolCalls) @@ -3561,7 +3564,13 @@ async function deliverContinuationResultsNow( ? attachHookAdditionalContexts(frames, hookContexts) : undefined if (notedFrames) frames = notedFrames - const outcome = await sessionManager.deliverClaim(claim, frames) + // Ignore checkpoints from before the final result frame's actual write, + // including time spent queued behind another write, but admit those that + // arrive while that frame drains. Earlier result frames are incomplete. + let beforeResultWrite = lastFrameArrival() + const outcome = await sessionManager.deliverClaim(claim, frames, () => { + beforeResultWrite = lastFrameArrival() + }) if (outcome.kind !== "delivered") { trace(`continuation: delivery stopped execId=${r.execId} reason=${outcome.reason}`) if (outcome.kind === "duplicate") continue @@ -3569,7 +3578,7 @@ async function deliverContinuationResultsNow( } deliveredResults++ if (outcome.framesWritten > 0 || (pending.bridged && !pending.aborted)) { - invalidateResumeCheckpoint(session, lastFrameArrival(), true) + invalidateResumeCheckpoint(session, beforeResultWrite, true) } if (session.historyImages) noteHistoryImagesSent(session.historyImages, deliveredImageHashes, lastFrameArrival()) if (deliveredSwitchMode) { diff --git a/src/replay-safety.ts b/src/replay-safety.ts index b2358c9b..2900fe2f 100644 --- a/src/replay-safety.ts +++ b/src/replay-safety.ts @@ -17,8 +17,8 @@ const OUTPUT_ONLY_BARRIERS: ReadonlySet = new Set(["visible /** Plain Run-owned state retained across host tool-step pump calls. */ export type ReplaySafetyState = { statefulBarrier?: ReplayBarrierReason - /** Every non-control exec must have a completed host result before history recovery. */ - execIds?: number[] + /** Every exec needs its owner's host result; ids can restart on a resumed Run. */ + execs?: Array<{ sessionId: string; id: number }> /** History cannot represent opaque interactions, unknown frames or display-only work. */ historyRebaseUnsafe?: boolean } @@ -46,12 +46,13 @@ export class AttemptReplaySafety { } recordExec(id: unknown): void { - const ids = this.state.execIds ??= [] - if (typeof id !== "number" || !Number.isSafeInteger(id) || ids.includes(id)) { + const execs = this.state.execs ??= [] + if (typeof id !== "number" || !Number.isSafeInteger(id) + || execs.some(exec => exec.sessionId === this.sessionId && exec.id === id)) { this.state.historyRebaseUnsafe = true return } - ids.push(id) + execs.push({ sessionId: this.sessionId, id }) } applyTo(failure: CursorProviderError): CursorProviderError { diff --git a/src/session.ts b/src/session.ts index 401431eb..c53d61cb 100644 --- a/src/session.ts +++ b/src/session.ts @@ -722,7 +722,12 @@ export class SessionManager { } } - async deliverClaim(claim: ContinuationClaim, frames: readonly Uint8Array[]): Promise { + async deliverClaim( + claim: ContinuationClaim, + frames: readonly Uint8Array[], + /** Called at each actual serialized write, before write() and its drain. */ + beforeWrite?: () => void, + ): Promise { const { session, execId, pending } = claim const key = this.key(session.sessionId, execId) const unavailable = (framesWritten: number): DeliveryOutcome | undefined => { @@ -757,7 +762,10 @@ export class SessionManager { } for (const frame of frames) { try { - await writeStreamMessage(session.stream, frame, "continuation result") + await writeStreamMessage(session.stream, frame, "continuation result", beforeWrite ? () => { + beforeWrite() + return true + } : undefined) } catch (error) { // write() returned false before the drain failed: the bytes are buffered. if (error instanceof StreamWriteDrainError) framesWritten++ diff --git a/tasks/lessons/opencode.md b/tasks/lessons/opencode.md index b96f9c89..156e2632 100644 --- a/tasks/lessons/opencode.md +++ b/tasks/lessons/opencode.md @@ -152,6 +152,11 @@ These are failure lessons shared by both hosts, not a replacement for checkpoint for that recovery. A later tool result must not erase an earlier uncheckpointed follow-up's barrier: that follow-up may be absent from the host prompt. Only a newer eligible checkpoint clears the revocation. + Capture result cutoffs at each actual serialized write, before its drain; + after-drain timestamps rejected valid checkpoints, while a cutoff before the + queue admitted checkpoints predating the completed result. Retain every + exec's owning session across resumes: bare stream-local ids both collide and + lose earlier completed results (`test/run-error-host.test.ts`, `test/session.test.ts`). Reject an unsafe replacement upload before consulting the preceding Run's checkpoint: that checkpoint says nothing about work the replacement may have started. Failed drains must also project as terminal to both hosts, including diff --git a/test/run-error-host.test.ts b/test/run-error-host.test.ts index e3988c9e..afa5e8dd 100644 --- a/test/run-error-host.test.ts +++ b/test/run-error-host.test.ts @@ -4,11 +4,11 @@ import http2 from "node:http2" import net from "node:net" import os from "node:os" import path from "node:path" -import { APICallError, type LanguageModelV3CallOptions, type LanguageModelV3StreamPart } from "@ai-sdk/provider" +import { APICallError, type LanguageModelV3, type LanguageModelV3CallOptions, type LanguageModelV3StreamPart } from "@ai-sdk/provider" import { createCursor } from "../src/index.js" import { CursorAuthError, CursorRetryExhaustedError, CursorServerError, CursorTransportError } from "../src/errors.js" import { decodeMessage, encodeMessage } from "../src/protocol/messages.js" -import { cacheHttp2SessionForTests, closeCachedHttp2SessionsForTests, lastFrameArrival } from "../src/transport/connect.js" +import { cacheHttp2SessionForTests, closeCachedHttp2SessionsForTests, frameArrival, lastFrameArrival } from "../src/transport/connect.js" import { setHostCacheDirOverride } from "../src/context/paths.js" import { sessionManager } from "../src/session.js" import { resetTurnStateForTests, settleRunSavesForTests } from "../src/language-model.js" @@ -127,6 +127,188 @@ async function streamError(sessionKey: string): Promise { } describe("checkpoint eligibility across host results", () => { + const readParts = async (model: LanguageModelV3, options: LanguageModelV3CallOptions) => { + const parts: LanguageModelV3StreamPart[] = [] + const { stream } = await model.doStream(options) + const reader = stream.getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) return parts + parts.push(value) + } + } + const appendResult = (prompt: LanguageModelV3CallOptions["prompt"], parts: LanguageModelV3StreamPart[], output: string) => { + const call = parts.find(part => part.type === "tool-call") + if (!call || call.type !== "tool-call") throw new Error("Expected a host tool call") + prompt.push({ role: "assistant", content: [{ type: "tool-call", toolCallId: call.toolCallId, toolName: call.toolName, input: JSON.parse(call.input) }] }) + prompt.push({ role: "tool", content: [{ type: "tool-result", toolCallId: call.toolCallId, toolName: call.toolName, output: { type: "text", value: output } }] }) + } + + for (const checkpointAt of ["prefix-drain", "final-drain"] as const) { + it(`uses the actual result write cutoff when a checkpoint arrives during ${checkpointAt}`, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-result-drain-")) + const checkpoint = Uint8Array.of(10, 1, 8) + let resumed = false + let resumedState: Uint8Array | undefined + let blocked = false + let checkpointSent = false + let checkpointArrivedDuringDrain = false + let boundary = 0 + let drainDone!: () => void + const drained = new Promise(resolve => { drainDone = resolve }) + scripted = (stream, message) => { + const send = (value: Record) => stream.write(frame(encodeMessage("AgentServerMessage", value))) + if (message.run_request) { + runs++ + resumed = !!(message.run_request as { action?: { resume_action?: unknown } }).action?.resume_action + resumedState = (message.run_request as { conversation_state?: Uint8Array }).conversation_state + if (runs > 1) { + send({ interaction_update: { turn_ended: {} } }) + stream.end() + } else send({ exec_server_message: { id: 1, shell_stream_args: { command: "echo done", tool_call_id: "command" } } }) + return + } + const control = message.exec_client_control_message as { stream_close?: { id: number } } | undefined + const result = message.exec_client_message as { shell_stream?: { start?: unknown } } | undefined + if (!checkpointSent && (checkpointAt === "final-drain" ? control?.stream_close?.id === 1 : result?.shell_stream?.start)) { + checkpointSent = true + send({ conversation_checkpoint_update: checkpoint }) + } + if (control?.stream_close?.id === 1) { + void drained.then(() => new Promise(resolve => setImmediate(resolve))).then(() => { + send({ interaction_update: { thinking_delta: { text: "after the result" } } }) + stream.end(frame(Buffer.from('{"error":{"code":"unavailable"}}'), 2)) + }) + } + } + cacheHttp2SessionForTests(ORIGIN, client) + try { + const key = `ses_checkpoint_${checkpointAt}` + const model = createCursor({ name: "cursor", accessToken: "test-token", agentBaseURL: ORIGIN, cacheDir: root, + workspaceRoot: root, retry: { maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1 } }).languageModel("composer-2.5") + const prompt: LanguageModelV3CallOptions["prompt"] = [{ role: "user", content: [{ type: "text", text: "Run the command" }] }] + const options: LanguageModelV3CallOptions = { prompt, headers: { "x-opencode-session-id": key }, + tools: [{ type: "function", name: "bash", inputSchema: { type: "object", properties: { command: { type: "string" } } } }] } + appendResult(prompt, await readParts(model, options), "done") + const held = sessionManager.findOpenByOpenCodeSessionId(key)! + const write = held.stream.write.bind(held.stream) + const waitForDrain = held.stream.waitForDrain?.bind(held.stream) + held.stream.write = bytes => { + const message = decodeMessage<{ exec_client_message?: { shell_stream?: { start?: unknown } }; exec_client_control_message?: { stream_close?: { id: number } } }>("AgentClientMessage", bytes) + const block = checkpointAt === "final-drain" + ? message.exec_client_control_message?.stream_close?.id === 1 + : !!message.exec_client_message?.shell_stream?.start + if (block) { blocked = true; boundary = lastFrameArrival() } + const accepted = write(bytes) + return block ? false : accepted + } + held.stream.waitForDrain = async () => { + if (!blocked) { await waitForDrain?.(); return } + const arrived = () => held.heldFrames?.some(queued => { + if ((frameArrival(queued) ?? 0) <= boundary) return false + const message = decodeMessage<{ conversation_checkpoint_update?: Uint8Array }>("AgentServerMessage", queued.payload) + return message.conversation_checkpoint_update?.every((byte, index) => byte === checkpoint[index]) + && message.conversation_checkpoint_update.length === checkpoint.length + }) ?? false + for (let i = 0; i < 200 && !arrived(); i++) await new Promise(resolve => setTimeout(resolve, 1)) + checkpointArrivedDuringDrain = arrived() + expect(checkpointArrivedDuringDrain).toBe(true) + blocked = false + drainDone() + } + if (checkpointAt === "final-drain") { + expect((await readParts(model, options)).some(part => part.type === "finish" && part.finishReason.unified === "stop")).toBe(true) + expect(resumed).toBe(true) + expect(resumedState).toEqual(checkpoint) + expect(runs).toBe(2) + } else { + expect(await readParts(model, options).catch((error: unknown) => error)).toMatchObject({ isRetryable: false }) + expect(runs).toBe(1) + } + expect(checkpointArrivedDuringDrain).toBe(true) + } finally { + drainDone() + sessionManager.dispose() + await settleRunSavesForTests() + setHostCacheDirOverride(undefined) + fs.rmSync(root, { recursive: true, force: true }) + } + }, 30_000) + } + + for (const [nextId, incomplete, opaque] of [[1, false, false], [2, false, false], [2, true, false], [2, false, true]] as const) { + it(`recovers after a resumed Run's completed exec (id=${nextId}, incomplete=${incomplete}, opaque=${opaque})`, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-resumed-result-")) + const requests: Array<{ conversation_id: string; action: { resume_action?: unknown; user_message_action?: { user_message: { text: string } } } }> = [] + const contexts: Uint8Array[] = [] + const delivered = new Set() + scripted = (stream, message) => { + const send = (value: Record) => stream.write(frame(encodeMessage("AgentServerMessage", value))) + if (message.run_request) { + requests.push(message.run_request as typeof requests[number]) + if (requests.length === 3) { send({ interaction_update: { turn_ended: {} } }); stream.end(); return } + send({ exec_server_message: { id: 90, request_context_args: {} } }) + if (requests.length === 1 && opaque) stream.write(frame(Uint8Array.of(0xa2, 0x06, 0))) + send({ exec_server_message: { id: requests.length === 1 ? 1 : nextId, + shell_args: { command: `echo step-${requests.length}`, tool_call_id: `command-${requests.length}` } } }) + } else if (message.exec_client_message) { + const reply = message.exec_client_message as { request_context_result?: { success?: { request_context?: Record } } } + const context = reply.request_context_result?.success?.request_context + if (context) contexts.push(encodeMessage("RequestContext", context)) + } else if (message.exec_client_control_message) { + const close = (message.exec_client_control_message as { stream_close?: { id: number } }).stream_close + const key = `${requests.length}:${close?.id}` + if (!close || close.id !== (requests.length === 1 ? 1 : nextId) || delivered.has(key)) return + delivered.add(key) + if (requests.length === 1) { + send({ conversation_checkpoint_update: Uint8Array.of(10, 1, 9) }) + send({ interaction_update: { thinking_delta: { text: "checkpointed first result" } } }) + stream.end(frame(Buffer.from('{"error":{"code":"unavailable"}}'), 2)) + } else stream.end() + } + } + cacheHttp2SessionForTests(ORIGIN, client) + try { + const model = createCursor({ name: "cursor", accessToken: "test-token", agentBaseURL: ORIGIN, cacheDir: root, + workspaceRoot: root, retry: { baseDelayMs: 1, maxDelayMs: 1 } }).languageModel("composer-2.5") + const prompt: LanguageModelV3CallOptions["prompt"] = [{ role: "user", content: [{ type: "text", text: "Run both commands" }] }] + const options: LanguageModelV3CallOptions = { prompt, headers: { "x-opencode-session-id": `ses_resume_owners_${nextId}_${incomplete}_${opaque}` }, + tools: [{ type: "function", name: "bash", inputSchema: { type: "object", properties: { command: { type: "string" } } } }] } + appendResult(prompt, await readParts(model, options), "first command completed") + appendResult(prompt, await readParts(model, options), "second command completed") + expect(requests).toHaveLength(2) + expect(requests[1]!.action.resume_action).toBeDefined() + expect(requests[1]!.conversation_id).toBe(requests[0]!.conversation_id) + for (let i = 0; i < 200 && contexts.length < 2; i++) await new Promise(resolve => setTimeout(resolve, 1)) + expect(contexts).toHaveLength(2) + const beforeContext = decodeMessage>("RequestContext", contexts[0]!) + const afterContext = decodeMessage>("RequestContext", contexts[1]!) + const changed = [...new Set([...Object.keys(beforeContext), ...Object.keys(afterContext)])] + .filter(key => JSON.stringify(beforeContext[key]) !== JSON.stringify(afterContext[key])) + expect(changed).toEqual([]) + if (incomplete) prompt.splice(1, 2) + if (incomplete || opaque) { + expect(await readParts(model, options).catch((error: unknown) => error)).toMatchObject({ isRetryable: false }) + expect(requests).toHaveLength(2) + } else { + expect((await readParts(model, options)).some(part => part.type === "finish" && part.finishReason.unified === "stop")).toBe(true) + expect(requests).toHaveLength(3) + expect(requests[2]!.action.resume_action).toBeUndefined() + expect(requests[2]!.conversation_id).not.toBe(requests[0]!.conversation_id) + const text = requests[2]!.action.user_message_action!.user_message.text + expect(text.match(/first command completed/g)).toHaveLength(1) + expect(text.match(/second command completed/g)).toHaveLength(1) + } + expect(delivered.size).toBe(2) + } finally { + sessionManager.dispose() + await settleRunSavesForTests() + setHostCacheDirOverride(undefined) + fs.rmSync(root, { recursive: true, force: true }) + } + }, 30_000) + } + for (const scenario of ["complete", "missing-history", "unknown", "unrepresented-exec", "duplicate-exec", "followup-before-result", "new-output", "final-error"] as const) { it(`recovers a post-result drop only from represented completed work (${scenario})`, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "cursor-result-history-")) diff --git a/test/session.test.ts b/test/session.test.ts index 6b21823c..ed93347c 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -8,6 +8,7 @@ import fs from "node:fs" import os from "node:os" import path from "node:path" import { createManagedTerminal } from "../src/terminal-runner.js" +import { writeStreamMessage } from "../src/stream-write.js" let _seq = 0 it("releases an unstarted shell reservation when cancellation closes its Run without a tool result", () => { @@ -181,6 +182,34 @@ describe("SessionManager", () => { expect(mgr.classify(s.sessionId, 9)).toMatchObject({ kind: "duplicate", reason: "delivered" }) }) + it("captures result boundaries at each queued write, never at enqueue or duplicate delivery", async () => { + const mgr = new SessionManager() + const s = fakeSession() + const events: string[] = [] + let drain!: () => void + s.stream.write = bytes => { events.push(`write:${bytes[0]}`); return bytes[0] !== 0 } + s.stream.waitForDrain = () => new Promise(resolve => { drain = resolve }) + mgr.registerPending(12, s, "mcp_result") + const claim = mgr.claim(s.sessionId, 12) + if ("kind" in claim) throw new Error("expected continuation claim") + const previous = writeStreamMessage(s.stream, Uint8Array.of(0), "preceding reply") + const delivery = mgr.deliverClaim(claim, [Uint8Array.of(1), Uint8Array.of(2)], () => { events.push("boundary") }) + try { + await new Promise(resolve => setImmediate(resolve)) + expect(events).toEqual(["write:0"]) + expect(await mgr.deliverClaim(claim, [Uint8Array.of(3)], () => { events.push("duplicate-boundary") })) + .toMatchObject({ kind: "duplicate" }) + drain() + await previous + expect(await delivery).toEqual({ kind: "delivered", framesWritten: 2 }) + expect(events).toEqual(["write:0", "boundary", "write:1", "boundary", "write:2"]) + } finally { + drain?.() + await Promise.allSettled([previous, delivery]) + mgr.dispose() + } + }) + it("does not replay a continuation frame whose drain fails after the write is buffered", async () => { const mgr = new SessionManager() const s = fakeSession() From 0d5ded5a7d61e4b795a141611de6b083432698df Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:13:05 +0200 Subject: [PATCH 10/11] fix: match CLI retries for unfinished tool rows --- CHANGELOG.md | 5 ++-- README.md | 18 +++++++++++--- docs/opencode-1.md | 9 +++++-- docs/opencode-2.md | 13 +++++++--- src/language-model.ts | 15 ++++++++---- src/replay-safety.ts | 8 ++++--- src/session.ts | 3 ++- tasks/lessons/opencode.md | 21 +++++++++++----- test/retry-budget.test.ts | 26 ++++++++++++++++++++ test/run-error-host.test.ts | 14 ++++++++--- test/run-interruption.test.ts | 45 +++++++++++++++++++++++++++++++++++ test/run-liveness.test.ts | 42 ++++++++++++++++++++++++++++++-- 12 files changed, 190 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca8ee11e..677e48a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,13 +23,14 @@ - A turn stopped mid-step keeps its Cursor conversation and prompt cache at the next message, also after a restart ([#58](https://github.com/oakimov/cursor-opencode-provider/pull/58) by [@nkoynov](https://github.com/nkoynov), [#74](https://github.com/oakimov/cursor-opencode-provider/issues/74)) - On OpenCode 2, plain-text steers without files or skills can reach Cursor during a background-shell wait; queued messages are preserved after Stop and mixed tool continuations, and answered copies are deduplicated across restarts and retries while preserving later identical requests and neighboring attachments ([#57](https://github.com/oakimov/cursor-opencode-provider/pull/57) by [@nkoynov](https://github.com/nkoynov)) - On OpenCode 2, a steer that Cursor took after tool results is no longer sent twice when its Run drops and cannot be recovered; the retried step rebuilds from host history ([#92](https://github.com/oakimov/cursor-opencode-provider/pull/92) by [@nkoynov](https://github.com/nkoynov)) -- Large file writes no longer fail after 120 s while the model is still composing them; a Run now fails only when Cursor sends nothing at all for that long ([#66](https://github.com/oakimov/cursor-opencode-provider/issues/66)) +- Large file writes stay alive while Cursor is composing them and sending frames; like Cursor CLI, an active Run with no pending host work now retries after 30 seconds of complete silence ([#66](https://github.com/oakimov/cursor-opencode-provider/issues/66)) - Long-running tools keep their Cursor Run alive with the same exec heartbeats Cursor CLI sends - Errors show Cursor's own explanation (for example "Request blocked by Anthropic …"), and a request Cursor marks as not retryable is no longer retried by OpenCode (OpenCode 2 sent it 11 times in testing; OpenCode 1.x resent a used-up usage limit five more times because of its message wording) - Like Cursor CLI, errors that need your action (usage or rate limits, sign-in, pricing, API key, outdated client), a bare `permission_denied`, and authentication failures, including those before a Run opens, end the turn instead of being retried; on OpenCode 1.x this also covers final `internal`/`unknown` errors. Final Cursor details take precedence over token renewal ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Once the provider spends its retry budget, or cannot replay a Run without repeating a tool call or other side effect, OpenCode no longer starts another retry loop ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Like Cursor CLI, a Run that drops or stalls after showing only text or reasoning, before Cursor saved a checkpoint, is retried by OpenCode instead of ending the turn, and the request reaches Cursor in that retry instead of with your next message. A retry after a checkpoint resumes the turn's latest checkpoint, and two resumes that save nothing end the turn ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100), reported by [@nkoynov](https://github.com/nkoynov)) -- Retry counts now follow Cursor CLI: up to 10 transport retries and 3 server retries per user turn, shared across checkpoints and OpenCode retries, including OpenCode 2's interruption notices. `CURSOR_ENDLESS_RETRIES=1` or `true` bypasses count limits while preserving replay guards and the no-progress and stall cutoffs ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) +- Retry counts now follow Cursor CLI: up to 10 transport retries and 3 server retries per user turn, shared across checkpoints and OpenCode retries, including OpenCode 2's interruption notices with appended host reminders. `CURSOR_ENDLESS_RETRIES=1` or `true` bypasses count limits while preserving replay guards and the no-progress and stall cutoffs ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) +- A dropped or stalled Run with an unfinished tool display row can be retried when no exec or started tool lifecycle arrived; the row closes without executing a tool ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - Tool recovery accepts checkpoints received while a completed result drains, and retains completed results across resumed Runs even when exec IDs restart ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. A drop immediately after completed tools can recover from complete host history before new output, without resuming a checkpoint predating their results. Other stateful activity remains a replay barrier. Follow-ups accept checkpoints received while opening the new Run. An ambiguous replacement upload ends the turn instead of retrying through an older checkpoint, including in endless mode ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - On macOS, a shell command that printed nothing before being moved to the background no longer reports `head: illegal byte count -- 0` diff --git a/README.md b/README.md index 09d025c1..b845850a 100644 --- a/README.md +++ b/README.md @@ -241,7 +241,7 @@ const cursor = createCursor({ // telemetryEnabled: true, // opt in to GetServerConfig telemetry // cacheDir: "/path/to/host/cache", // optional; else path bridge or native OpenCode cache // retry: { maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 8_000 }, // optional stricter total limit - // continuation: { heartbeatMs: 5_000, semanticIdleMs: 120_000, hardCapMs: 600_000 }, + // continuation: { heartbeatMs: 5_000, semanticIdleMs: 30_000, hardCapMs: 600_000 }, }) const model = cursor.languageModel("composer-2.5") @@ -330,9 +330,19 @@ budget; early stream closure and the inbound read-idle deadline use the server budget. Retries of the same turn share these counts across checkpoints, tool steps and OpenCode's own retry calls. A new user turn, successful completion or Stop starts a fresh budget. Counts are held in memory for the running process. -OpenCode 2's interruption notices keep the same budget as the original request. +OpenCode 2's interruption notices keep the same budget as the original request, +including when a plugin appends recognized `` or +`` text parts. These notes remain in the request sent to Cursor; +attachments and ordinary follow-up text still identify a new request. OpenCode may stop earlier under its own retry policy. +Like Cursor CLI, an active Run with no pending host work fails after **30 seconds +without an inbound frame**. Every inbound frame, including a heartbeat or partial +tool input, renews this deadline; client heartbeats do not. Silence uses the +transport retry budget and the 180-second consecutive-stall window. The separate +120-second transport read-idle watchdog remains a backstop. Direct SDK callers +can override `continuation.semanticIdleMs` (or its `softHealthMs` alias). + If a Run drops immediately after completed tool results, before new output, the provider can rebuild from host history containing every observed exec's result. It sends the completed results once and asks Cursor to continue. A @@ -345,7 +355,9 @@ For direct SDK use, `retry.maxAttempts` adds a total attempt ceiling, including the initial attempt; omitting it uses the two Cursor CLI budgets. The existing backoff defaults remain 500 ms full jitter, growing exponentially to an 8 s ceiling (`baseDelayMs` / `maxDelayMs`). OpenCode controls delays when it owns -the retry after partial output. +the retry after partial output. An unfinished tool display row, without an exec +or started tool lifecycle, also permits a host retry: the row closes as unfinished +and has no tool side effect to repeat. Set `CURSOR_ENDLESS_RETRIES=1` or `true` before starting OpenCode (or its daemon) to bypass both count limits and `retry.maxAttempts`, like Cursor CLI's endless diff --git a/docs/opencode-1.md b/docs/opencode-1.md index f04875d4..b9a470b7 100644 --- a/docs/opencode-1.md +++ b/docs/opencode-1.md @@ -140,8 +140,13 @@ stored messages are preserved. ## Interrupted model requests OpenCode retries a transient failure after only text or reasoning when the -turn has no new checkpoint. Stateful activity stays a replay barrier. A drop -immediately after completed tool results can rebuild from complete host history +turn has no new checkpoint. An unfinished tool display row also permits retry +if no exec or started tool lifecycle arrived; it closes without execution. +Stateful activity stays a replay barrier. Like Cursor CLI, an active Run with no +pending host work fails after 30 s without an inbound frame; heartbeats and +partial inputs renew that deadline, while client heartbeats do not. The 120 s +transport read-idle watchdog remains a backstop. A drop immediately after +completed tool results can rebuild from complete host history before new output; missing results or unrepresented activity remain final. Recovery retains the latest eligible checkpoint across token renewal, including a rejected credential on an untouched resumed attempt. Checkpoints received diff --git a/docs/opencode-2.md b/docs/opencode-2.md index 0fb0269d..2d7481ed 100644 --- a/docs/opencode-2.md +++ b/docs/opencode-2.md @@ -211,8 +211,13 @@ patch operations that would lose CRLF bytes before changing a file. Native ## Interrupted model requests OpenCode retries a transient failure after only text or reasoning when the -turn has no new checkpoint. Stateful activity stays a replay barrier. A drop -immediately after completed tool results can rebuild from complete host history +turn has no new checkpoint. An unfinished tool display row also permits retry +if no exec or started tool lifecycle arrived; it closes without execution. +Stateful activity stays a replay barrier. Like Cursor CLI, an active Run with no +pending host work fails after 30 s without an inbound frame; heartbeats and +partial inputs renew that deadline, while client heartbeats do not. The 120 s +transport read-idle watchdog remains a backstop. A drop immediately after +completed tool results can rebuild from complete host history before new output; missing results or unrepresented activity remain final. Recovery retains the latest eligible checkpoint across token renewal, including a rejected credential on an untouched resumed attempt. Checkpoints received @@ -223,7 +228,9 @@ History recovery keeps completed results from earlier resumed Runs too. The provider shares Cursor CLI's limits of 10 transport retries and 3 server retries across this turn, including OpenCode's retries. OpenCode can stop earlier. -The host's plain-text interruption notice does not reset the provider's budget. +The host's plain-text interruption notice does not reset the provider's budget, +including appended `` or `` text parts. +Only retry identity ignores these notes; Cursor still receives their content. Set `CURSOR_ENDLESS_RETRIES=1` or `true` before starting the host (or its daemon) to bypass count limits while retaining replay guards, Stop, final errors, the two-resume cutoff and the 180 s consecutive semantic-stall retry window. diff --git a/src/language-model.ts b/src/language-model.ts index 561658de..0d4f2d59 100644 --- a/src/language-model.ts +++ b/src/language-model.ts @@ -6916,11 +6916,18 @@ export function cursorTurnEndedProviderMetadata( export function retryTurnKey(prompt: LanguageModelV3CallOptions["prompt"], withoutLastInterruptionNotice = false): string { // OpenCode 2 runner/llm.ts emits this synthetic after an incomplete stream; // runner/to-llm-message.ts lowers it to an ordinary user text part. Limit - // recognition to exact, standalone text after assistant output. The budget - // accepts that candidate only after a requested host retry, then remembers - // its full key across tool steps. Actual prompt content is never removed. + // recognition to exact, standalone text after assistant output. A plugin's + // trailing host-note parts do not identify a user turn: they may appear only + // on this request and disappear from the next request's history. Normalize + // them for hashing only. The budget admits the continuation only after a + // requested host retry and remembers its full key across tool steps. const interrupted = "The previous response was interrupted. Continue from where you left off without repeating completed content." - const messages = prompt.filter(message => message.role !== "system" && !hostTailNote(message)) + const messages = prompt.filter(message => message.role !== "system" && !hostTailNote(message)).map(message => { + if (message.role !== "user") return message + let end = message.content.length + while (end > 1 && hostTailNote({ role: "user", content: [message.content[end - 1]!] }) !== undefined) end-- + return end === message.content.length ? message : { ...message, content: message.content.slice(0, end) } + }) let end = messages.length while (end > 0 && messages[end - 1]!.role !== "user") end-- const last = messages[end - 1] diff --git a/src/replay-safety.ts b/src/replay-safety.ts index 2900fe2f..9b907244 100644 --- a/src/replay-safety.ts +++ b/src/replay-safety.ts @@ -11,15 +11,17 @@ export type ReplayBarrierReason = | "stateful-interaction" | "unknown-or-malformed-frame" -/** Barriers that only showed the host text or reasoning: sending the turn again repeats no side effect. */ -const OUTPUT_ONLY_BARRIERS: ReadonlySet = new Set(["visible-text", "visible-reasoning"]) +/** Host output without execution authority: sending the turn again repeats no side effect. */ +const OUTPUT_ONLY_BARRIERS: ReadonlySet = new Set([ + "visible-text", "visible-reasoning", "visible-tool-row", +]) /** Plain Run-owned state retained across host tool-step pump calls. */ export type ReplaySafetyState = { statefulBarrier?: ReplayBarrierReason /** Every exec needs its owner's host result; ids can restart on a resumed Run. */ execs?: Array<{ sessionId: string; id: number }> - /** History cannot represent opaque interactions, unknown frames or display-only work. */ + /** History cannot represent opaque interactions, unknown frames or started display work. */ historyRebaseUnsafe?: boolean } diff --git a/src/session.ts b/src/session.ts index c53d61cb..e86764a1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -37,7 +37,8 @@ export type CursorContinuationPolicy = { } export const DEFAULT_CONTINUATION_POLICY: Readonly = { - semanticIdleMs: 120_000, + // Cursor CLI aborts an active attempt after 30 s without an inbound frame. + semanticIdleMs: 30_000, hardCapMs: 600_000, heartbeatMs: 5_000, } diff --git a/tasks/lessons/opencode.md b/tasks/lessons/opencode.md index 156e2632..d2bc5506 100644 --- a/tasks/lessons/opencode.md +++ b/tasks/lessons/opencode.md @@ -136,8 +136,11 @@ These are failure lessons shared by both hosts, not a replacement for only reasoning; its request reached Cursor only with the next message ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100)). Cursor CLI resends the action, resumes the turn's latest checkpoint, and stops - after two resumes without a new one. Its separate 10-transport / 3-server - retry budgets belong to the user turn, not one provider call: an output-only + after two resumes without a new one. Match its 30 s inbound-silence default; + outbound client heartbeats cannot extend it. The transport's 120 s read-idle + watchdog is a separate backstop (`test/run-liveness.test.ts`). + Its separate 10-transport / 3-server retry budgets belong to the user turn, + not one provider call: an output-only failure handed to an older host with unbounded retries otherwise restarts the allowance forever. Share counts across module copies and host tool steps; distinguish partial assistant output and host reminders from a new user turn. @@ -145,10 +148,16 @@ These are failure lessons shared by both hosts, not a replacement for user text, not a ``, and otherwise reset the budget on every continuation (`test/run-error-host.test.ts`). Admit it only after a requested host retry, retaining its full prompt key across tool steps; a later literal - user request with that text still needs a new budget. Immediately after completed - host results, history recovery needs every observed exec's result, no new - output, and no opaque interaction or unknown frame. Seed all completed tool - steps; retaining only the last result loses earlier work. Never use the stale + user request with that text still needs a new budget. Normalize recognized + trailing host-note parts in every user message for identity only: plugins + append them to the initial request too, without persisting them in history. + Keep actual request content and attachment/new-request boundaries intact. + An unfinished partial-tool row has no execution authority; close it as + unfinished and classify it as output only. Started tool lifecycles, execs and + unknown frames still forbid that host retry (`test/run-interruption.test.ts`). + Immediately after completed host results, history recovery needs every observed + exec's result, no new output, and no opaque interaction or unknown frame. + Seed all completed tool steps; retaining only the last result loses earlier work. Never use the stale checkpoint for that recovery. A later tool result must not erase an earlier uncheckpointed follow-up's barrier: that follow-up may be absent from the host prompt. Only a newer eligible checkpoint clears the revocation. diff --git a/test/retry-budget.test.ts b/test/retry-budget.test.ts index 99c48f23..a74cae14 100644 --- a/test/retry-budget.test.ts +++ b/test/retry-budget.test.ts @@ -33,6 +33,32 @@ describe("Cursor CLI retry budgets", () => { { type: "file", data: new Uint8Array([1]), mediaType: "image/png" }] }])).not.toBe(key) }) + it("recognizes interruption notices with appended host-note parts but not new requests or attachments", () => { + const notice = "The previous response was interrupted. Continue from where you left off without repeating completed content." + const prompt: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Continue" }] }] + const key = retryTurnKey(prompt) + const transformed = structuredClone(prompt) + const first = transformed[0]! + if (first.role !== "user") throw new Error("Expected the initial user message") + first.content.push({ type: "text", text: "Initial request reminder" }) + expect(retryTurnKey(transformed)).toBe(key) + expect(first.content).toHaveLength(2) // identity normalization must not edit the actual prompt + prompt.push({ role: "assistant", content: [{ type: "text", text: "partial answer" }] }) + for (const extra of ["Check the pending tasks", "Updated instructions"]) { + const continued: LanguageModelV3Prompt = [...prompt, { role: "user", content: [ + { type: "text", text: notice }, { type: "text", text: extra }, + ] }] + expect(retryTurnKey(continued)).not.toBe(key) + expect(retryTurnKey(continued, true)).toBe(key) + } + for (const extra of [ + { type: "text" as const, text: "Actually inspect another file" }, + { type: "file" as const, data: new Uint8Array([1]), mediaType: "image/png" }, + ]) { + expect(retryTurnKey([...prompt, { role: "user", content: [{ type: "text", text: notice }, extra] }], true)).not.toBe(key) + } + }) + it("requires a requested host retry before sharing a plain-text continuation's budget", () => { const original = turnRetryBudget("session", "original") reserveRetry(original, server()) diff --git a/test/run-error-host.test.ts b/test/run-error-host.test.ts index afa5e8dd..e4e254cd 100644 --- a/test/run-error-host.test.ts +++ b/test/run-error-host.test.ts @@ -694,8 +694,11 @@ describe("retry limits across OpenCode calls", () => { } }) - for (const [endless, maxAttempts, retries] of [[false, undefined, 3], [false, 2, 1], [true, 2, 14]] as const) { - it(`bounds output-only host retries (endless=${endless}, maxAttempts=${maxAttempts})`, async () => { + for (const [endless, maxAttempts, retries, reminderParts] of [ + [false, undefined, 3, false], [false, 2, 1, false], [true, 2, 14, false], + [false, undefined, 3, true], [false, 2, 1, true], [true, 2, 14, true], + ] as const) { + it(`bounds output-only host retries (endless=${endless}, maxAttempts=${maxAttempts}, reminderParts=${reminderParts})`, async () => { const saved = process.env.CURSOR_ENDLESS_RETRIES if (endless) process.env.CURSOR_ENDLESS_RETRIES = "true" else delete process.env.CURSOR_ENDLESS_RETRIES @@ -711,7 +714,12 @@ describe("retry limits across OpenCode calls", () => { retry: { maxAttempts, baseDelayMs: 1, maxDelayMs: 1 }, }).languageModel("composer-2.5") try { - const { stream } = await model.doStream({ prompt, headers: { "x-opencode-session-id": "ses_repeated_output" } }) + // A plugin transforms this request, not the host's stored history. + const requestPrompt = structuredClone(prompt) + const last = requestPrompt.at(-1) + if (reminderParts && last?.role === "user") last.content.push({ type: "text", + text: `Pending task reminder ${runs}` }) + const { stream } = await model.doStream({ prompt: requestPrompt, headers: { "x-opencode-session-id": "ses_repeated_output" } }) const reader = stream.getReader() while (!(await reader.read()).done) { /* drain host-visible output */ } } catch (error) { return error } diff --git a/test/run-interruption.test.ts b/test/run-interruption.test.ts index f84196e8..bd5161f3 100644 --- a/test/run-interruption.test.ts +++ b/test/run-interruption.test.ts @@ -270,6 +270,51 @@ describe("interrupted Cursor Run handling", () => { expect(parts.some((part: any) => part.type === "finish")).toBe(false) }) + for (const afterRow of ["eof", "read-idle", "semantic-idle", "unknown", "started", "exec"] as const) { + it(`retries an unfinished display row only without stateful activity (${afterRow})`, async () => { + const session = fakeSession(`unfinished-row-${afterRow}`, [ + serverFrame({ interaction_update: { thinking_delta: { text: "Preparing an edit" } } }), + serverFrame({ interaction_update: { partial_tool_call: { call_id: "unfinished-edit", + tool_call: { edit_tool_call: { args: { path: "unfinished.txt" } } } } } }), + ...(afterRow === "unknown" ? [{ flags: 0, payload: Uint8Array.of(0xa2, 0x06, 0) }] : []), + ...(afterRow === "started" ? [serverFrame({ interaction_update: { tool_call_started: { call_id: "unfinished-edit" } } })] : []), + ...(afterRow === "exec" ? [serverFrame({ exec_server_message: { id: 4, list_mcp_resources_exec_args: {} } })] : []), + ]) + session.toolCatalog = [{ name: "write", description: "Write a file" }] + session.permittedToolNames = new Set(["write"]) + session.requestContext.env = { workspace_paths: [process.cwd()] } + const next = session.frames.next.bind(session.frames) + session.frames.next = async () => { + const frame = await next() + if (frame.done && (afterRow === "read-idle" || afterRow === "semantic-idle")) { + throw new CursorTransportError("Cursor Run stalled", { transient: true, replaySafe: true, + code: afterRow === "read-idle" ? "CURSOR_READ_IDLE_TIMEOUT" : "CURSOR_SEMANTIC_IDLE_TIMEOUT" }) + } + return frame + } + const parts: Array<{ type: string; providerExecuted?: boolean; result?: string }> = [] + try { + const failure = await pumpWithRecovery({ + initialSession: session, + controller: controller(parts), + recover: async () => { throw new Error("Already displayed output must be retried by the host") }, + }).catch((error: unknown) => error) + const outputOnly = ["eof", "read-idle", "semantic-idle"].includes(afterRow) + expect(hostFacingError(failure)).toMatchObject({ isRetryable: outputOnly, + responseHeaders: { "x-should-retry": String(outputOnly) } }) + expect(parts.filter(part => part.type === "tool-input-start")).toHaveLength(1) + expect(parts.filter(part => part.type === "tool-input-end")).toHaveLength(1) + const calls = parts.filter(part => part.type === "tool-call") + expect(calls).toHaveLength(1) + expect(calls[0]!.providerExecuted).toBe(true) + expect(parts.find(part => part.type === "tool-result")?.result).toBe("Cursor did not finish this tool call.") + expect(session.pending.size).toBe(0) + } finally { + sessionManager.close(session) + } + }) + } + it("recovers once on a fresh session and completes only after turn_ended", async () => { const interrupted = fakeSession("first", []) const recovered = fakeSession("second", [ diff --git a/test/run-liveness.test.ts b/test/run-liveness.test.ts index 77ca8999..46ecfba0 100644 --- a/test/run-liveness.test.ts +++ b/test/run-liveness.test.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it } from "bun:test" -import { attachSessionHeartbeat, drainSessionUntilTurnEnded, pump } from "../src/language-model.js" +import { attachSessionHeartbeat, drainSessionUntilTurnEnded, pump, pumpWithRecovery } from "../src/language-model.js" import { decodeMessage, encodeMessage } from "../src/protocol/messages.js" import { ASK_QUESTION_RESULT_FIELD } from "../src/protocol/ask-question.js" -import { sessionManager, type CursorSession, type Frame } from "../src/session.js" +import { resolveContinuationPolicy, sessionManager, type CursorSession, type Frame } from "../src/session.js" import type { CursorProviderError } from "../src/errors.js" import { sessionFixture } from "./session-fixture.js" @@ -80,6 +80,44 @@ afterEach(() => { }) describe("Run liveness", () => { + it("uses Cursor CLI's 30 second silence default while retaining explicit overrides", () => { + expect(resolveContinuationPolicy(undefined).semanticIdleMs).toBe(30_000) + expect(resolveContinuationPolicy({ semanticIdleMs: 180_000 }).semanticIdleMs).toBe(180_000) + expect(resolveContinuationPolicy({ softHealthMs: 180_000 }).semanticIdleMs).toBe(180_000) + }) + + it("leaves a real silence deadline after reasoning and an unfinished row to the host", async () => { + const parts: Array<{ type: string; providerExecuted?: boolean }> = [] + const session = livenessSession([ + { afterMs: 0, frame: serverFrame({ interaction_update: { thinking_delta: { text: "Preparing the edit" } } }) }, + { afterMs: 0, frame: serverFrame({ interaction_update: { partial_tool_call: { call_id: "unfinished", + tool_call: { edit_tool_call: { args: { path: "unfinished.txt" } } } } } }) }, + ], 40) + session.permittedToolNames = new Set(["write"]) + session.requestContext.env = { workspace_paths: [process.cwd()] } + live.push(session) + let sawRow = false + const output = collect(parts) + const enqueue = output.enqueue.bind(output) + output.enqueue = part => { + enqueue(part) + if (part.type !== "tool-input-start") return + sawRow = true + const deadline = session.semanticDeadlineAt + // Client traffic is not an inbound server frame and must not delay the cutoff. + sessionManager.recordHeartbeatWrite(session) + expect(session.semanticDeadlineAt).toBe(deadline) + } + const failure = await pumpWithRecovery({ + initialSession: session, controller: output, + recover: async () => { throw new Error("The host owns this output-only retry") }, + }).catch((error: unknown) => error) + expect(sawRow).toBe(true) + expect(failure).toMatchObject({ hostRetryable: true, code: "CURSOR_SEMANTIC_IDLE_TIMEOUT" }) + expect(parts.filter(part => part.type === "tool-call").every(part => part.providerExecuted)).toBe(true) + expect(session.pending.size).toBe(0) + }) + it("keeps a Run alive on heartbeats alone for longer than the idle limit", async () => { const parts: Array<{ type: string; delta?: string }> = [] const session = livenessSession([ From e29d69c6cd9079f3cc1ef06436acaf811cb778f3 Mon Sep 17 00:00:00 2001 From: oakimov <5861674+oakimov@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:25:00 +0200 Subject: [PATCH 11/11] fix: count incomplete streams as CLI transport retries --- CHANGELOG.md | 2 +- README.md | 12 ++++++------ src/retry-budget.ts | 3 +-- tasks/lessons/opencode.md | 4 ++++ test/retry-budget.test.ts | 4 ++-- test/run-error-host.test.ts | 14 +++++++++----- test/run-interruption.test.ts | 8 ++++---- 7 files changed, 27 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 677e48a0..247610b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ - Like Cursor CLI, errors that need your action (usage or rate limits, sign-in, pricing, API key, outdated client), a bare `permission_denied`, and authentication failures, including those before a Run opens, end the turn instead of being retried; on OpenCode 1.x this also covers final `internal`/`unknown` errors. Final Cursor details take precedence over token renewal ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Once the provider spends its retry budget, or cannot replay a Run without repeating a tool call or other side effect, OpenCode no longer starts another retry loop ([#97](https://github.com/oakimov/cursor-opencode-provider/pull/97)) - Like Cursor CLI, a Run that drops or stalls after showing only text or reasoning, before Cursor saved a checkpoint, is retried by OpenCode instead of ending the turn, and the request reaches Cursor in that retry instead of with your next message. A retry after a checkpoint resumes the turn's latest checkpoint, and two resumes that save nothing end the turn ([#100](https://github.com/oakimov/cursor-opencode-provider/issues/100), reported by [@nkoynov](https://github.com/nkoynov)) -- Retry counts now follow Cursor CLI: up to 10 transport retries and 3 server retries per user turn, shared across checkpoints and OpenCode retries, including OpenCode 2's interruption notices with appended host reminders. `CURSOR_ENDLESS_RETRIES=1` or `true` bypasses count limits while preserving replay guards and the no-progress and stall cutoffs ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) +- Retry counts now follow Cursor CLI: up to 10 transport retries (including streams that end without `turn_ended`) and 3 server retries per user turn, shared across checkpoints and OpenCode retries, including OpenCode 2's interruption notices with appended host reminders. `CURSOR_ENDLESS_RETRIES=1` or `true` bypasses count limits while preserving replay guards and the no-progress and stall cutoffs ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - A dropped or stalled Run with an unfinished tool display row can be retried when no exec or started tool lifecycle arrived; the row closes without executing a tool ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - Tool recovery accepts checkpoints received while a completed result drains, and retains completed results across resumed Runs even when exec IDs restart ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) - Interrupted Runs retain their checkpoint across credential renewal, including a rejected login on an untouched resumed attempt. A drop immediately after completed tools can recover from complete host history before new output, without resuming a checkpoint predating their results. Other stateful activity remains a replay barrier. Follow-ups accept checkpoints received while opening the new Run. An ambiguous replacement upload ends the turn instead of retrying through an older checkpoint, including in endless mode ([#103](https://github.com/oakimov/cursor-opencode-provider/pull/103)) diff --git a/README.md b/README.md index b845850a..0fc81fef 100644 --- a/README.md +++ b/README.md @@ -325,11 +325,11 @@ creates that directory nor writes host transcripts into it. ## Retry limits Following Cursor CLI, each user turn allows **10 transport retries** and -**3 server retries**, counted separately. Connection failures use the transport -budget; early stream closure and the inbound read-idle deadline use the server -budget. Retries of the same turn share these counts across checkpoints, tool -steps and OpenCode's own retry calls. A new user turn, successful completion or -Stop starts a fresh budget. Counts are held in memory for the running process. +**3 server retries**, counted separately. Connection failures and a stream ending +without `turn_ended` use the transport budget; server failures and the inbound +read-idle deadline use the server budget. Retries of the same turn share these +counts across checkpoints, tool steps and OpenCode's own retry calls. A new user +turn, successful completion or Stop starts a fresh budget. Counts are held in memory for the running process. OpenCode 2's interruption notices keep the same budget as the original request, including when a plugin appends recognized `` or `` text parts. These notes remain in the request sent to Cursor; @@ -528,7 +528,7 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader. | “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. | | Empty or stale model list | Delete `/cursor-models.json` (native default `~/.cache/opencode/`) and restart OpenCode. A compatibility layer may supply a different host-cache root through the structural path bridge. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. | | Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, resumes transient interruptions from the latest eligible Cursor checkpoint, and falls back to a fresh-history rebase only before stateful output when no checkpoint exists. A transient interruption after only text or reasoning, without a checkpoint from that turn, is left to OpenCode to retry. Completed tools remain replay barriers across host steps; credential renewal retains the latest eligible checkpoint. Repeated resumes without progress end as an error. Behind an HTTP proxy, set `HTTPS_PROXY` (and `NO_PROXY` as needed): unary `fetch` and the Run HTTP/2 CONNECT tunnel both honor them. With debug logging enabled, look for `Run interrupted`, `resuming … checkpoint`, `rebasing fresh Run`, or `h2 connect via HTTPS proxy`. Restart OpenCode after rebuilding a local `file://` install. | -| No response / silent 200 + close | HTTP 200 alone is not a successful agent turn: the provider now requires Cursor's explicit `turn_ended`, captures HTTP/2 trailers/GOAWAY, and retries bare EOF within the server retry budget. The Run host still comes from in-memory `GetServerConfig` resolution; set `CURSOR_PROVIDER_DEBUG=1` to confirm the host and termination reason. | +| No response / silent 200 + close | HTTP 200 alone is not a successful agent turn: the provider now requires Cursor's explicit `turn_ended`, captures HTTP/2 trailers/GOAWAY, and retries bare EOF within the transport retry budget. The Run host still comes from in-memory `GetServerConfig` resolution; set `CURSOR_PROVIDER_DEBUG=1` to confirm the host and termination reason. | | Visible `` timeout text | Rebuild and restart a local install. Cursor's shell timeout is carried on its exec request; the provider now removes OpenCode's internal timeout envelope before it is rendered or stored, then returns Cursor's typed timeout or background-handoff event instead of treating the text as successful stdout. | | `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. Known `handling=unsupported` variants (Cursor-native capabilities without a safe OpenCode AI SDK bridge) are now soft-denied on the wire with a populated typed error/`rejected`/`allowlisted:false` result and the Run continues — look for `exec: SOFT-DENIED …` in the debug log and a `Workspace root: …` suffix in the deny text — while `unknown request field` still hard-fails as protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. | | Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`; the default is `debug-.log` under `$TMPDIR/cursor-provider-logs-/`) and reproduce the issue. Every completed turn emits a `turn usage validation: status=ok\|mismatch` line confirming token-accounting integrity and a `cache diagnosis:` line showing warm/cold checkpoint continuity, raw read/write/uncached tokens, cache-read coverage of the prior context, context/category deltas, stable RequestContext hashes, and Run step/tool counts. Checkpoint lines include Cursor's current category totals. Cursor does not expose per-model-call cache splits, so the diagnostic labels that value unavailable instead of inferring it from aggregate `TurnEnded` counters. | diff --git a/src/retry-budget.ts b/src/retry-budget.ts index fb91d240..534bf3b7 100644 --- a/src/retry-budget.ts +++ b/src/retry-budget.ts @@ -76,10 +76,9 @@ export function endlessRetriesEnabled(): boolean { return process.env.CURSOR_ENDLESS_RETRIES === "1" || process.env.CURSOR_ENDLESS_RETRIES === "true" } -/** Cursor CLI counts early EOF / read-idle as server failures, stalls as transport. */ +/** CLI's incomplete-stream error is explicitly transport; an idle deadline is server. */ export function retryCategory(failure: CursorProviderError): "server" | "transport" { return failure.origin === "transport" - && failure.code !== "CURSOR_STREAM_CLOSED_EARLY" && failure.code !== "CURSOR_READ_IDLE_TIMEOUT" ? "transport" : "server" } diff --git a/tasks/lessons/opencode.md b/tasks/lessons/opencode.md index d2bc5506..97faa23a 100644 --- a/tasks/lessons/opencode.md +++ b/tasks/lessons/opencode.md @@ -139,6 +139,10 @@ These are failure lessons shared by both hosts, not a replacement for after two resumes without a new one. Match its 30 s inbound-silence default; outbound client heartbeats cannot extend it. The transport's 120 s read-idle watchdog is a separate backstop (`test/run-liveness.test.ts`). + Classify the constructed error, not only the signature table: CLI marks a + stream ending without `turnEnded` as transport, so it receives ten retries, + even though a different early-close signature uses the server category + (`test/run-error-host.test.ts`, `test/retry-budget.test.ts`). Its separate 10-transport / 3-server retry budgets belong to the user turn, not one provider call: an output-only failure handed to an older host with unbounded retries otherwise restarts the diff --git a/test/retry-budget.test.ts b/test/retry-budget.test.ts index a74cae14..5e769bc2 100644 --- a/test/retry-budget.test.ts +++ b/test/retry-budget.test.ts @@ -83,8 +83,8 @@ describe("Cursor CLI retry budgets", () => { expect(budget.retries).toBe(13) }) - it("uses the server budget for early EOF and read-idle, and transport for stalls", () => { - expect(retryCategory(new CursorRunInterruptedError())).toBe("server") + it("uses transport for incomplete streams and stalls, and server for read-idle", () => { + expect(retryCategory(new CursorRunInterruptedError())).toBe("transport") expect(retryCategory(new CursorTransportError("idle", { code: "CURSOR_READ_IDLE_TIMEOUT", transient: true, replaySafe: true }))).toBe("server") expect(retryCategory(stall())).toBe("transport") }) diff --git a/test/run-error-host.test.ts b/test/run-error-host.test.ts index e4e254cd..c85cb959 100644 --- a/test/run-error-host.test.ts +++ b/test/run-error-host.test.ts @@ -27,6 +27,7 @@ let client: http2.ClientHttp2Session let trailer: Record = {} let runs = 0 let partialOutput = false +let bareEof = false let scripted: ((stream: http2.ServerHttp2Stream, message: Record) => void) | undefined const savedProxy: Record = {} @@ -62,7 +63,7 @@ beforeAll(async () => { interaction_update: { thinking_delta: { text: "Already delivered" } }, }))) } - stream.write(frame(Buffer.from(JSON.stringify(trailer)), 2)) + if (!bareEof) stream.write(frame(Buffer.from(JSON.stringify(trailer)), 2)) stream.end() } }) @@ -98,6 +99,7 @@ afterEach(() => { resetCheckpointsForTests() runs = 0 partialOutput = false + bareEof = false scripted = undefined }) @@ -694,11 +696,12 @@ describe("retry limits across OpenCode calls", () => { } }) - for (const [endless, maxAttempts, retries, reminderParts] of [ - [false, undefined, 3, false], [false, 2, 1, false], [true, 2, 14, false], - [false, undefined, 3, true], [false, 2, 1, true], [true, 2, 14, true], + for (const [endless, maxAttempts, retries, reminderParts, incomplete] of [ + [false, undefined, 3, false, false], [false, 2, 1, false, false], [true, 2, 14, false, false], + [false, undefined, 3, true, false], [false, 2, 1, true, false], [true, 2, 14, true, false], + [false, undefined, 10, true, true], ] as const) { - it(`bounds output-only host retries (endless=${endless}, maxAttempts=${maxAttempts}, reminderParts=${reminderParts})`, async () => { + it(`bounds output-only host retries (endless=${endless}, maxAttempts=${maxAttempts}, reminderParts=${reminderParts}, bareEOF=${incomplete})`, async () => { const saved = process.env.CURSOR_ENDLESS_RETRIES if (endless) process.env.CURSOR_ENDLESS_RETRIES = "true" else delete process.env.CURSOR_ENDLESS_RETRIES @@ -706,6 +709,7 @@ describe("retry limits across OpenCode calls", () => { cacheHttp2SessionForTests(ORIGIN, client) trailer = { error: { code: "unavailable" } } partialOutput = true + bareEof = incomplete const prompt: LanguageModelV3CallOptions["prompt"] = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] const attempt = async () => { // Hosts may reload the SDK model between retries. State belongs to the turn. diff --git a/test/run-interruption.test.ts b/test/run-interruption.test.ts index bd5161f3..e8da4a0c 100644 --- a/test/run-interruption.test.ts +++ b/test/run-interruption.test.ts @@ -385,7 +385,7 @@ describe("interrupted Cursor Run handling", () => { expect(writes).toHaveLength(4) }) - it("stops early EOF after three server retries, like Cursor CLI", async () => { + it("stops early EOF after ten transport retries, like Cursor CLI", async () => { let recoveries = 0 await expect( pumpWithRecovery({ @@ -398,7 +398,7 @@ describe("interrupted Cursor Run handling", () => { }, }), ).rejects.toBeInstanceOf(CursorRetryExhaustedError) - expect(recoveries).toBe(3) + expect(recoveries).toBe(10) }) it("does not leak OpenCode SessionRetry triggers after unavailable retries finish", async () => { @@ -675,8 +675,8 @@ describe("interrupted Cursor Run handling", () => { throw new CursorTransportError("connect reset", { transient: true, replaySafe: true, code: "ECONNRESET" }) }, })).rejects.toBeInstanceOf(CursorRetryExhaustedError) - // First EOF spends one server retry; failed opens then spend ten transport retries. - expect(openings).toBe(11) + // The initial EOF and failed replacement opens share ten transport retries. + expect(openings).toBe(10) }) for (const endless of [false, true]) {