authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-09-07 08:55:29+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 03:25:29+00:00
log6134b90ffb19c18e0b2f468aa172095a393470b0
tree233d7dfa94b47b846d18ea124c3741591c7aace8
parent4e969f3731aaa590889d74274b3b6dced6d86291
signature Signed by PGP key B5690EEEBB952194

fix(server): mark Cursor transport error answers as failed (#10337)


4 files changed, 162 insertions(+), 0 deletions(-)

apps/server/src/provider/Layers/CursorAdapter.test.ts+36
......@@ -162,6 +162,42 @@ const cursorAdapterTestLayer = it.layer(
162162);
163163
164164cursorAdapterTestLayer("CursorAdapterLive", (it) => {
165 it.effect("rejects a Cursor transport error returned as a successful assistant answer", () =>
166 Effect.gen(function* () {
167 const adapter = yield* CursorAdapter;
168 const settings = yield* ServerSettingsService;
169 const threadId = ThreadId.make("cursor-transport-error-answer");
170 const wrapperPath = yield* Effect.promise(() =>
171 makeMockAgentWrapper({
172 T3_ACP_PROMPT_RESPONSE_TEXT: "Error: RetriableError: WritableIterable is closed",
173 }),
174 );
175 yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });
176 const runtimeEventsFiber = yield* adapter.streamEvents.pipe(
177 Stream.takeUntil((event) => event.type === "session.exited"),
178 Stream.runCollect,
179 Effect.forkChild,
180 );
181 yield* adapter.startSession({
182 threadId,
183 provider: ProviderDriverKind.make("cursor"),
184 cwd: process.cwd(),
185 runtimeMode: "full-access",
186 });
187 const error = yield* adapter
188 .sendTurn({ threadId, input: "continue", attachments: [] })
189 .pipe(Effect.flip);
190 assert.equal(error._tag, "ProviderAdapterRequestError");
191 if (error._tag === "ProviderAdapterRequestError") {
192 assert.equal(error.detail, "Cursor reported a transport failure.");
193 assert.equal(error.cause, "Error: RetriableError: WritableIterable is closed");
194 }
195 yield* adapter.stopSession(threadId);
196 const runtimeEvents = yield* Fiber.join(runtimeEventsFiber);
197 assert.isFalse(runtimeEvents.some((event) => event.type === "turn.completed"));
198 }),
199 );
200
165201 it.effect("starts a session and maps mock ACP prompt flow to runtime events", () =>
166202 Effect.gen(function* () {
167203 const adapter = yield* CursorAdapter;
apps/server/src/provider/Layers/CursorAdapter.ts+17
......@@ -67,6 +67,7 @@ import {
6767} from "../acp/AcpRuntimeModel.ts";
6868import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts";
6969import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts";
70import { CursorTransportFailure } from "../acp/CursorTransportFailure.ts";
7071import {
7172 CursorAskQuestionRequest,
7273 CursorCreatePlanRequest,
......@@ -144,6 +145,7 @@ interface CursorSessionContext {
144145 * >0 means a turn is actively running, so a new sendTurn is a steer that
145146 * continues it, and only the last remaining prompt settles the turn. */
146147 promptsInFlight: number;
148 assistantReply: CursorTransportFailure;
147149 stopped: boolean;
148150}
149151
......@@ -788,6 +790,7 @@ export function makeCursorAdapter(
788790 activeTurnId: undefined,
789791 cursorSkillNames: undefined,
790792 promptsInFlight: 0,
793 assistantReply: new CursorTransportFailure(),
791794 stopped: false,
792795 };
793796
......@@ -801,6 +804,7 @@ export function makeCursorAdapter(
801804 case "ModeChanged":
802805 return;
803806 case "AssistantItemStarted":
807 ctx.assistantReply = new CursorTransportFailure();
804808 yield* offerRuntimeEvent(
805809 makeAcpAssistantItemEvent({
806810 stamp: yield* makeEventStamp(),
......@@ -858,6 +862,7 @@ export function makeCursorAdapter(
858862 );
859863 return;
860864 case "ContentDelta":
865 ctx.assistantReply.push(event.text);
861866 yield* logNative(
862867 ctx.threadId,
863868 "session/update",
......@@ -957,6 +962,7 @@ export function makeCursorAdapter(
957962 ctx.activeTurnId = turnId;
958963 if (steeringTurnId === undefined) {
959964 ctx.lastPlanFingerprint = undefined;
965 ctx.assistantReply = new CursorTransportFailure();
960966 }
961967 ctx.session = {
962968 ...ctx.session,
......@@ -1061,6 +1067,17 @@ export function makeCursorAdapter(
10611067 ),
10621068 );
10631069
1070 yield* ctx.acp.drainEvents;
1071 const failure = ctx.assistantReply.failure;
1072 if (ctx.promptsInFlight === 1 && result.stopReason !== "cancelled" && failure) {
1073 return yield* new ProviderAdapterRequestError({
1074 provider: PROVIDER,
1075 method: "session/prompt",
1076 detail: "Cursor reported a transport failure.",
1077 cause: failure,
1078 });
1079 }
1080
10641081 const turnRecord = ctx.turns.find((turn) => turn.id === turnId);
10651082 if (turnRecord) {
10661083 turnRecord.items.push({ prompt: promptParts, result });
apps/server/src/provider/acp/CursorTransportFailure.test.ts created+58
......@@ -0,0 +1,58 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { CursorTransportFailure } from "./CursorTransportFailure.ts";
4
5const diagnostic = "Error: RetriableError: WritableIterable is closed";
6
7function failureFor(chunks: string[]) {
8 const reply = new CursorTransportFailure();
9 for (const chunk of chunks) reply.push(chunk);
10 return reply.failure;
11}
12
13describe("CursorTransportFailure", () => {
14 it.each([
15 diagnostic,
16 "Error: ConnectError: [unavailable] transport closed",
17 "Error: ConnectError: [aborted] aborted",
18 "Error: ConnectError: [deadline_exceeded] timed out",
19 "Something went wrong communicating with the server. Please try again.",
20 ])("recognizes a terminal diagnostic: %s", (message) => {
21 expect(failureFor([message])).toBe(message);
22 expect(failureFor([...message])).toBe(message);
23 });
24
25 it("recognizes a standalone failure with a stack trace", () => {
26 expect(failureFor(["\n", diagnostic, "\n at send (cli.js:1:2)\n\n"])).toBe(diagnostic);
27 });
28
29 it.each([
30 "The error was " + diagnostic,
31 "This is an example of a transport error:\n" + diagnostic,
32 "I inspected the files.\n" + diagnostic,
33 "> " + diagnostic,
34 " " + diagnostic,
35 "```text\n" + diagnostic + "\n```",
36 "~~~\n" + diagnostic + "\n~~~",
37 diagnostic + "\nThis is an example of a transport error.",
38 "Error: ConnectError: [unauthenticated] sign in",
39 "Error: ConnectError: [permission_denied] subscription required",
40 "Error: HTTP 500 from the application being debugged",
41 ])("preserves prose, code and non-transport errors: %s", (message) => {
42 expect(failureFor([...message])).toBeUndefined();
43 });
44
45 it("tracks fences across long lines without retaining the answer", () => {
46 expect(failureFor(["```\n", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined();
47 expect(failureFor(["```", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined();
48 expect(failureFor(["x".repeat(100_000), "\n", diagnostic])).toBeUndefined();
49 });
50
51 it("does not retain a previous failure after new output", () => {
52 const reply = new CursorTransportFailure();
53 reply.push(diagnostic);
54 expect(reply.failure).toBe(diagnostic);
55 reply.push("\nRecovered successfully.");
56 expect(reply.failure).toBeUndefined();
57 });
58});
apps/server/src/provider/acp/CursorTransportFailure.ts created+51
......@@ -0,0 +1,51 @@
1const maxLineLength = 4096;
2const transportError =
3 /^Error: (?:RetriableError: .+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/;
4const serverError = "Something went wrong communicating with the server. Please try again.";
5
6interface ReplyState {
7 disqualified: boolean;
8 failure: string | undefined;
9}
10
11function consumeLine(state: ReplyState, line: string) {
12 if (state.disqualified) return;
13 const text = line.trimEnd();
14 if (transportError.test(text) || text === serverError) {
15 state.failure = text;
16 } else if (text.trim() !== "" && !(state.failure && /^\s+at\s/.test(text))) {
17 // An explanation or code sample can quote the same diagnostic. Only
18 // classify an assistant item consisting entirely of a transport dump.
19 state.disqualified = true;
20 state.failure = undefined;
21 }
22}
23
24/** Tracks a standalone Cursor diagnostic without retaining an entire streamed answer. */
25export class CursorTransportFailure {
26 private state: ReplyState = { disqualified: false, failure: undefined };
27 private line = "";
28
29 push(text: string) {
30 for (const [index, part] of text.split("\n").entries()) {
31 if (this.state.disqualified) return;
32 if (index > 0) {
33 consumeLine(this.state, this.line);
34 this.line = "";
35 }
36 if (this.line.length + part.length > maxLineLength) {
37 this.state.disqualified = true;
38 this.state.failure = undefined;
39 this.line = "";
40 return;
41 }
42 this.line += part;
43 }
44 }
45
46 get failure() {
47 const state = { ...this.state };
48 consumeLine(state, this.line);
49 return state.failure;
50 }
51}