| author | |
| committer | |
| log | 6134b90ffb19c18e0b2f468aa172095a393470b0 |
| tree | 233d7dfa94b47b846d18ea124c3741591c7aace8 |
| parent | 4e969f3731aaa590889d74274b3b6dced6d86291 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 162 insertions(+), 0 deletions(-)
apps/server/src/provider/Layers/CursorAdapter.test.ts+36| ... | ... | @@ -162,6 +162,42 @@ const cursorAdapterTestLayer = it.layer( |
| 162 | 162 | ); |
| 163 | 163 | |
| 164 | 164 | cursorAdapterTestLayer("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 | ||
| 165 | 201 | it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => |
| 166 | 202 | Effect.gen(function* () { |
| 167 | 203 | const adapter = yield* CursorAdapter; |
apps/server/src/provider/Layers/CursorAdapter.ts+17| ... | ... | @@ -67,6 +67,7 @@ import { |
| 67 | 67 | } from "../acp/AcpRuntimeModel.ts"; |
| 68 | 68 | import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; |
| 69 | 69 | import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; |
| 70 | import { CursorTransportFailure } from "../acp/CursorTransportFailure.ts"; | |
| 70 | 71 | import { |
| 71 | 72 | CursorAskQuestionRequest, |
| 72 | 73 | CursorCreatePlanRequest, |
| ... | ... | @@ -144,6 +145,7 @@ interface CursorSessionContext { |
| 144 | 145 | * >0 means a turn is actively running, so a new sendTurn is a steer that |
| 145 | 146 | * continues it, and only the last remaining prompt settles the turn. */ |
| 146 | 147 | promptsInFlight: number; |
| 148 | assistantReply: CursorTransportFailure; | |
| 147 | 149 | stopped: boolean; |
| 148 | 150 | } |
| 149 | 151 | |
| ... | ... | @@ -788,6 +790,7 @@ export function makeCursorAdapter( |
| 788 | 790 | activeTurnId: undefined, |
| 789 | 791 | cursorSkillNames: undefined, |
| 790 | 792 | promptsInFlight: 0, |
| 793 | assistantReply: new CursorTransportFailure(), | |
| 791 | 794 | stopped: false, |
| 792 | 795 | }; |
| 793 | 796 | |
| ... | ... | @@ -801,6 +804,7 @@ export function makeCursorAdapter( |
| 801 | 804 | case "ModeChanged": |
| 802 | 805 | return; |
| 803 | 806 | case "AssistantItemStarted": |
| 807 | ctx.assistantReply = new CursorTransportFailure(); | |
| 804 | 808 | yield* offerRuntimeEvent( |
| 805 | 809 | makeAcpAssistantItemEvent({ |
| 806 | 810 | stamp: yield* makeEventStamp(), |
| ... | ... | @@ -858,6 +862,7 @@ export function makeCursorAdapter( |
| 858 | 862 | ); |
| 859 | 863 | return; |
| 860 | 864 | case "ContentDelta": |
| 865 | ctx.assistantReply.push(event.text); | |
| 861 | 866 | yield* logNative( |
| 862 | 867 | ctx.threadId, |
| 863 | 868 | "session/update", |
| ... | ... | @@ -957,6 +962,7 @@ export function makeCursorAdapter( |
| 957 | 962 | ctx.activeTurnId = turnId; |
| 958 | 963 | if (steeringTurnId === undefined) { |
| 959 | 964 | ctx.lastPlanFingerprint = undefined; |
| 965 | ctx.assistantReply = new CursorTransportFailure(); | |
| 960 | 966 | } |
| 961 | 967 | ctx.session = { |
| 962 | 968 | ...ctx.session, |
| ... | ... | @@ -1061,6 +1067,17 @@ export function makeCursorAdapter( |
| 1061 | 1067 | ), |
| 1062 | 1068 | ); |
| 1063 | 1069 | |
| 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 | ||
| 1064 | 1081 | const turnRecord = ctx.turns.find((turn) => turn.id === turnId); |
| 1065 | 1082 | if (turnRecord) { |
| 1066 | 1083 | turnRecord.items.push({ prompt: promptParts, result }); |
apps/server/src/provider/acp/CursorTransportFailure.test.ts created+58| ... | ... | @@ -0,0 +1,58 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { CursorTransportFailure } from "./CursorTransportFailure.ts"; | |
| 4 | ||
| 5 | const diagnostic = "Error: RetriableError: WritableIterable is closed"; | |
| 6 | ||
| 7 | function failureFor(chunks: string[]) { | |
| 8 | const reply = new CursorTransportFailure(); | |
| 9 | for (const chunk of chunks) reply.push(chunk); | |
| 10 | return reply.failure; | |
| 11 | } | |
| 12 | ||
| 13 | describe("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 @@ |
| 1 | const maxLineLength = 4096; | |
| 2 | const transportError = | |
| 3 | /^Error: (?:RetriableError: .+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; | |
| 4 | const serverError = "Something went wrong communicating with the server. Please try again."; | |
| 5 | ||
| 6 | interface ReplyState { | |
| 7 | disqualified: boolean; | |
| 8 | failure: string | undefined; | |
| 9 | } | |
| 10 | ||
| 11 | function 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. */ | |
| 25 | export 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 | } |