Skip to content
77 changes: 68 additions & 9 deletions packages/provider-cursor/src/server/adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,58 @@ describe("CursorAdapterV2", () => {
}),
failure: { class: "transport_error", message: "Provider turn failed." },
},
])("fails the turn with $reason", ({ updates = [], wait, failure }) =>
{
reason: "an error Cursor's run ended with after the turn ended",
updates: [
{ type: "text-delta", text: "The answer." },
{ type: "turn-ended" },
] satisfies ReadonlyArray<InteractionUpdate>,
wait: Effect.succeed({
id: "native-cursor-run",
requestId: "native-request",
status: "error" as const,
error: { message: "Checkpoint persistence failed" },
model: { id: "claude-fable-5" },
durationMs: 1,
}),
failure: { class: "provider_error", message: "Checkpoint persistence failed" },
},
{
reason: "no failure when Cursor's resume after the turn ended made no progress",
// Cursor's SDK resumed from an older checkpoint after the answer was
// delivered, regenerated the closing, then gave up on the run.
updates: [
{ type: "text-delta", text: "The answer." },
{ type: "turn-ended" },
{ type: "text-delta", text: "A regenerated closing." },
{ type: "turn-ended" },
] satisfies ReadonlyArray<InteractionUpdate>,
wait: Effect.succeed({
id: "native-cursor-run",
requestId: "native-request",
status: "error" as const,
error: { message: "Agent turn stopped after repeated resume attempts made no progress" },
model: { id: "claude-fable-5" },
durationMs: 1,
}),
failure: null,
},
{
reason: "no answer from a resume after the turn ended that made no progress",
updates: [{ type: "turn-ended" }] satisfies ReadonlyArray<InteractionUpdate>,
wait: Effect.succeed({
id: "native-cursor-run",
requestId: "native-request",
status: "error" as const,
error: { message: "Agent turn stopped after repeated resume attempts made no progress" },
result: "A regenerated closing.",
model: { id: "claude-fable-5" },
durationMs: 1,
}),
failure: null,
answers: [],
},
])("ends the turn with $reason", ({ updates = [], wait, failure, answers }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-failure-" });
Expand Down Expand Up @@ -365,15 +416,23 @@ describe("CursorAdapterV2", () => {
attachments: [],
},
});
const terminal = yield* runtime.events.pipe(
Stream.filter((event) => event.type === "turn.terminal"),
Stream.runHead,
const events = yield* runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runCollect,
);
assert.isTrue(Option.isSome(terminal));
if (Option.isSome(terminal)) {
assert.equal(terminal.value.status, "failed");
assert.equal(terminal.value.failure?.class, failure.class);
assert.equal(terminal.value.failure?.message, failure.message);
const terminal = events.find((event) => event.type === "turn.terminal");
assert.equal(terminal?.status, failure === null ? "completed" : "failed");
assert.equal(terminal?.failure?.class, failure?.class);
assert.equal(terminal?.failure?.message, failure?.message);
if (answers !== undefined) {
assert.deepEqual(
events.flatMap((event) =>
event.type === "message.updated" && event.message.role === "assistant"
? [event.message.text]
: [],
),
answers,
);
}
}).pipe(
Effect.scoped,
Expand Down
27 changes: 26 additions & 1 deletion packages/provider-cursor/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,11 @@ interface CursorProjectionTarget {
// Fallback final output when Cursor's result carries none. The persisted row only
// ever shows a bounded tail, so keep a little more than that.
const MAX_CURSOR_STREAMED_OUTPUT_CHARS = 256 * 1024;
// After a turn ends, a retryable stream error can make Cursor's SDK resume from
// an older checkpoint inside the same run. When those resumes save nothing, it
// ends the run with this error even though the answer was already delivered.
const CURSOR_RESUME_NO_PROGRESS_MESSAGE =
"Agent turn stopped after repeated resume attempts made no progress";

interface ActiveCursorToolCall {
readonly callId: string;
Expand Down Expand Up @@ -871,6 +876,7 @@ interface ActiveCursorTurn {
readonly reasoning: ActiveCursorTextStream;
interrupted: boolean;
finalized: boolean;
turnEnded: boolean;
}

interface CursorLiveAgent {
Expand Down Expand Up @@ -1953,6 +1959,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
return;
case "step-completed":
case "turn-ended":
context.turnEnded ||= update.type === "turn-ended";
yield* completeAssistant(context);
yield* completeReasoning(context);
return;
Expand Down Expand Up @@ -2099,6 +2106,11 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
yield* Deferred.succeed(input.context.completed, undefined);
});

const isResumeFailureAfterTurnEnded = (context: ActiveCursorTurn, result: RunResult) =>
context.turnEnded &&
result.status === "error" &&
result.error?.message === CURSOR_RESUME_NO_PROGRESS_MESSAGE;

const terminalStatus = (
context: ActiveCursorTurn,
result: RunResult,
Expand All @@ -2115,7 +2127,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
case "cancelled":
return "cancelled";
case "error":
return "failed";
return isResumeFailureAfterTurnEnded(context, result) ? "completed" : "failed";
}
};

Expand Down Expand Up @@ -2319,6 +2331,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
},
interrupted: false,
finalized: false,
turnEnded: false,
};
yield* Ref.set(activeTurn, context);
yield* emitProviderEvent({
Expand Down Expand Up @@ -2350,6 +2363,8 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
if (
context !== null &&
context.assistant.nextSegment === 0 &&
// Text from a resume that made no progress is not the turn's answer.
!isResumeFailureAfterTurnEnded(context, result) &&
result.result !== undefined &&
result.result.length > 0
) {
Expand All @@ -2361,6 +2376,16 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
text: result.result,
});
}
if (context !== null && isResumeFailureAfterTurnEnded(context, result)) {
yield* Effect.logWarning(
"orchestration-v2.cursor-resume-failed-after-turn-ended",
{
providerSessionId: input.providerSessionId,
providerThreadId: turnInput.providerThread.id,
providerTurnId,
},
);
}
if (context !== null) {
const transportFailure = context.assistantReply.failure;
const status =
Expand Down
Loading