| 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,6 +162,42 @@ const cursorAdapterTestLayer = it.layer( |
| 162 | ); | 162 | ); |
| 163 | 163 | ||
| 164 | cursorAdapterTestLayer("CursorAdapterLive", (it) => { | 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 | it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => | 201 | it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => |
| 166 | Effect.gen(function* () { | 202 | Effect.gen(function* () { |
| 167 | const adapter = yield* CursorAdapter; | 203 | const adapter = yield* CursorAdapter; |
apps/server/src/provider/Layers/CursorAdapter.ts+17| ... | @@ -67,6 +67,7 @@ import { | ... | @@ -67,6 +67,7 @@ import { |
| 67 | } from "../acp/AcpRuntimeModel.ts"; | 67 | } from "../acp/AcpRuntimeModel.ts"; |
| 68 | import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; | 68 | import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; |
| 69 | import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; | 69 | import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; |
| 70 | import { CursorTransportFailure } from "../acp/CursorTransportFailure.ts"; | ||
| 70 | import { | 71 | import { |
| 71 | CursorAskQuestionRequest, | 72 | CursorAskQuestionRequest, |
| 72 | CursorCreatePlanRequest, | 73 | CursorCreatePlanRequest, |
| ... | @@ -144,6 +145,7 @@ interface CursorSessionContext { | ... | @@ -144,6 +145,7 @@ interface CursorSessionContext { |
| 144 | * >0 means a turn is actively running, so a new sendTurn is a steer that | 145 | * >0 means a turn is actively running, so a new sendTurn is a steer that |
| 145 | * continues it, and only the last remaining prompt settles the turn. */ | 146 | * continues it, and only the last remaining prompt settles the turn. */ |
| 146 | promptsInFlight: number; | 147 | promptsInFlight: number; |
| 148 | assistantReply: CursorTransportFailure; | ||
| 147 | stopped: boolean; | 149 | stopped: boolean; |
| 148 | } | 150 | } |
| 149 | 151 | ||
| ... | @@ -788,6 +790,7 @@ export function makeCursorAdapter( | ... | @@ -788,6 +790,7 @@ export function makeCursorAdapter( |
| 788 | activeTurnId: undefined, | 790 | activeTurnId: undefined, |
| 789 | cursorSkillNames: undefined, | 791 | cursorSkillNames: undefined, |
| 790 | promptsInFlight: 0, | 792 | promptsInFlight: 0, |
| 793 | assistantReply: new CursorTransportFailure(), | ||
| 791 | stopped: false, | 794 | stopped: false, |
| 792 | }; | 795 | }; |
| 793 | 796 | ||
| ... | @@ -801,6 +804,7 @@ export function makeCursorAdapter( | ... | @@ -801,6 +804,7 @@ export function makeCursorAdapter( |
| 801 | case "ModeChanged": | 804 | case "ModeChanged": |
| 802 | return; | 805 | return; |
| 803 | case "AssistantItemStarted": | 806 | case "AssistantItemStarted": |
| 807 | ctx.assistantReply = new CursorTransportFailure(); | ||
| 804 | yield* offerRuntimeEvent( | 808 | yield* offerRuntimeEvent( |
| 805 | makeAcpAssistantItemEvent({ | 809 | makeAcpAssistantItemEvent({ |
| 806 | stamp: yield* makeEventStamp(), | 810 | stamp: yield* makeEventStamp(), |
| ... | @@ -858,6 +862,7 @@ export function makeCursorAdapter( | ... | @@ -858,6 +862,7 @@ export function makeCursorAdapter( |
| 858 | ); | 862 | ); |
| 859 | return; | 863 | return; |
| 860 | case "ContentDelta": | 864 | case "ContentDelta": |
| 865 | ctx.assistantReply.push(event.text); | ||
| 861 | yield* logNative( | 866 | yield* logNative( |
| 862 | ctx.threadId, | 867 | ctx.threadId, |
| 863 | "session/update", | 868 | "session/update", |
| ... | @@ -957,6 +962,7 @@ export function makeCursorAdapter( | ... | @@ -957,6 +962,7 @@ export function makeCursorAdapter( |
| 957 | ctx.activeTurnId = turnId; | 962 | ctx.activeTurnId = turnId; |
| 958 | if (steeringTurnId === undefined) { | 963 | if (steeringTurnId === undefined) { |
| 959 | ctx.lastPlanFingerprint = undefined; | 964 | ctx.lastPlanFingerprint = undefined; |
| 965 | ctx.assistantReply = new CursorTransportFailure(); | ||
| 960 | } | 966 | } |
| 961 | ctx.session = { | 967 | ctx.session = { |
| 962 | ...ctx.session, | 968 | ...ctx.session, |
| ... | @@ -1061,6 +1067,17 @@ export function makeCursorAdapter( | ... | @@ -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 | const turnRecord = ctx.turns.find((turn) => turn.id === turnId); | 1081 | const turnRecord = ctx.turns.find((turn) => turn.id === turnId); |
| 1065 | if (turnRecord) { | 1082 | if (turnRecord) { |
| 1066 | turnRecord.items.push({ prompt: promptParts, result }); | 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 | } | ||