| 1 | import type { RunResult, SdkCredentialStore, StoredSdkCredentials } from "@cursor/sdk"; |
| 2 | import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; |
| 3 | import { CursorSettings } from "../settings.ts"; |
| 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Exit from "effect/Exit"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Fiber from "effect/Fiber"; |
| 10 | import * as Layer from "effect/Layer"; |
| 11 | import * as TestClock from "effect/testing/TestClock"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | import { createModelSelection } from "@t3tools/shared/model"; |
| 14 | import { beforeEach, vi } from "vite-plus/test"; |
| 15 | |
| 16 | import { makeCursorAuth } from "./auth.ts"; |
| 17 | import { makeCursorTextGeneration } from "./textGeneration.ts"; |
| 18 | import * as CursorSdk from "./CursorSdk.ts"; |
| 19 | |
| 20 | const cursorSdkMock = vi.hoisted(() => ({ |
| 21 | create: vi.fn<(options: unknown) => Promise<unknown>>(), |
| 22 | send: vi.fn<(prompt: string) => Promise<unknown>>(), |
| 23 | close: vi.fn(), |
| 24 | cancel: vi.fn(async () => {}), |
| 25 | storeOpen: vi.fn<(options: unknown) => Promise<unknown>>(), |
| 26 | storeDispose: vi.fn(async () => {}), |
| 27 | prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({ |
| 28 | id: "run-cursor-text-generation-test", |
| 29 | status: "finished", |
| 30 | result: |
| 31 | '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}', |
| 32 | })), |
| 33 | })); |
| 34 | |
| 35 | vi.mock("./sdk.ts", () => ({ |
| 36 | Agent: { create: cursorSdkMock.create }, |
| 37 | createAgentPlatform: vi.fn(), |
| 38 | SqliteLocalAgentStore: { open: cursorSdkMock.storeOpen }, |
| 39 | })); |
| 40 | |
| 41 | let hasCustomPolicy = false; |
| 42 | const layerFs = FileSystem.layerNoop({ |
| 43 | exists: () => Effect.succeed(hasCustomPolicy), |
| 44 | makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"), |
| 45 | }); |
| 46 | const layerTest = Layer.merge(layerFs, CursorSdk.layer); |
| 47 | |
| 48 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 49 | const cursorSettings = decodeCursorSettings({ enabled: true }); |
| 50 | |
| 51 | beforeEach(() => { |
| 52 | hasCustomPolicy = false; |
| 53 | cursorSdkMock.create.mockReset(); |
| 54 | cursorSdkMock.send.mockReset(); |
| 55 | cursorSdkMock.create.mockImplementation(async (options) => { |
| 56 | cursorSdkMock.send.mockImplementation(async (prompt) => ({ |
| 57 | status: "running", |
| 58 | cancel: cursorSdkMock.cancel, |
| 59 | wait: () => cursorSdkMock.prompt(prompt, options), |
| 60 | })); |
| 61 | return { |
| 62 | close: cursorSdkMock.close, |
| 63 | [Symbol.asyncDispose]: async () => { |
| 64 | cursorSdkMock.close(); |
| 65 | }, |
| 66 | send: cursorSdkMock.send, |
| 67 | }; |
| 68 | }); |
| 69 | cursorSdkMock.close.mockClear(); |
| 70 | cursorSdkMock.cancel.mockClear(); |
| 71 | cursorSdkMock.storeDispose.mockClear(); |
| 72 | cursorSdkMock.storeOpen.mockReset(); |
| 73 | cursorSdkMock.storeOpen.mockResolvedValue({ dispose: cursorSdkMock.storeDispose }); |
| 74 | cursorSdkMock.prompt.mockReset(); |
| 75 | cursorSdkMock.prompt.mockResolvedValue({ |
| 76 | id: "run-cursor-text-generation-test", |
| 77 | status: "finished", |
| 78 | result: |
| 79 | '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}', |
| 80 | }); |
| 81 | }); |
| 82 | |
| 83 | describe("CursorTextGeneration", () => { |
| 84 | it.effect("resolves the browser credential for every request after an account change", () => |
| 85 | Effect.gen(function* () { |
| 86 | let apiKey = "first-browser-key"; |
| 87 | const generation = yield* makeCursorTextGeneration( |
| 88 | cursorSettings, |
| 89 | {}, |
| 90 | Effect.sync(() => apiKey), |
| 91 | ); |
| 92 | const input = { |
| 93 | cwd: process.cwd(), |
| 94 | branch: "feature/cursor", |
| 95 | stagedSummary: "M file.ts", |
| 96 | stagedPatch: "diff", |
| 97 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "auto"), |
| 98 | }; |
| 99 | yield* generation.generateCommitMessage(input); |
| 100 | expect(cursorSdkMock.create).toHaveBeenLastCalledWith( |
| 101 | expect.objectContaining({ apiKey: "first-browser-key" }), |
| 102 | ); |
| 103 | apiKey = "second-browser-key"; |
| 104 | yield* generation.generateCommitMessage(input); |
| 105 | expect(cursorSdkMock.create).toHaveBeenLastCalledWith( |
| 106 | expect.objectContaining({ apiKey: "second-browser-key" }), |
| 107 | ); |
| 108 | }).pipe(Effect.provide(layerTest)), |
| 109 | ); |
| 110 | |
| 111 | it.effect("uses the Cursor SDK prompt API with model parameters and API key", () => |
| 112 | Effect.gen(function* () { |
| 113 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { |
| 114 | CURSOR_API_KEY: "test-cursor-key", |
| 115 | }); |
| 116 | |
| 117 | const generated = yield* textGeneration.generateCommitMessage({ |
| 118 | cwd: process.cwd(), |
| 119 | branch: "feature/cursor-text-generation", |
| 120 | stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", |
| 121 | stagedPatch: |
| 122 | "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", |
| 123 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ |
| 124 | { id: "thinking", value: "high" }, |
| 125 | { id: "contextWindow", value: "1m" }, |
| 126 | { id: "fastMode", value: true }, |
| 127 | ]), |
| 128 | }); |
| 129 | |
| 130 | expect(generated.subject).toBe("Add generated commit message"); |
| 131 | expect(generated.body).toBe("- verify cursor sdk text generation"); |
| 132 | |
| 133 | expect(cursorSdkMock.prompt).toHaveBeenCalledTimes(1); |
| 134 | const [prompt, options] = ( |
| 135 | cursorSdkMock.prompt.mock.calls as unknown as Array<[string, unknown]> |
| 136 | )[0]!; |
| 137 | expect(prompt).toContain("Staged patch:"); |
| 138 | expect(options).toEqual({ |
| 139 | apiKey: "test-cursor-key", |
| 140 | mode: "plan", |
| 141 | model: { |
| 142 | id: "gpt-5.4", |
| 143 | params: [ |
| 144 | { id: "thinking", value: "high" }, |
| 145 | { id: "context", value: "1m" }, |
| 146 | { id: "fast", value: "true" }, |
| 147 | ], |
| 148 | }, |
| 149 | local: { |
| 150 | cwd: "/isolated-text-generation", |
| 151 | store: { dispose: cursorSdkMock.storeDispose }, |
| 152 | autoReview: false, |
| 153 | sandboxOptions: { enabled: true }, |
| 154 | settingSources: [], |
| 155 | enableAgentRetries: true, |
| 156 | }, |
| 157 | }); |
| 158 | }).pipe(Effect.provide(layerTest)), |
| 159 | ); |
| 160 | |
| 161 | it.effect("disposes the agent store after the agent, whether the run succeeds or fails", () => |
| 162 | Effect.gen(function* () { |
| 163 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { |
| 164 | CURSOR_API_KEY: "test-cursor-key", |
| 165 | }); |
| 166 | const input = { |
| 167 | cwd: process.cwd(), |
| 168 | branch: "feature/cursor-store", |
| 169 | stagedSummary: "M README.md", |
| 170 | stagedPatch: "diff --git a/README.md b/README.md", |
| 171 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), |
| 172 | }; |
| 173 | |
| 174 | yield* textGeneration.generateCommitMessage(input); |
| 175 | expect(cursorSdkMock.storeOpen).toHaveBeenCalledWith( |
| 176 | expect.objectContaining({ workspaceRef: "/isolated-text-generation" }), |
| 177 | ); |
| 178 | expect(cursorSdkMock.storeDispose).toHaveBeenCalledTimes(1); |
| 179 | expect(cursorSdkMock.close.mock.invocationCallOrder[0]).toBeLessThan( |
| 180 | cursorSdkMock.storeDispose.mock.invocationCallOrder[0]!, |
| 181 | ); |
| 182 | |
| 183 | cursorSdkMock.prompt.mockResolvedValueOnce({ |
| 184 | id: "run-cursor-text-generation-test", |
| 185 | status: "error", |
| 186 | }); |
| 187 | yield* Effect.flip(textGeneration.generateCommitMessage(input)); |
| 188 | expect(cursorSdkMock.storeDispose).toHaveBeenCalledTimes(2); |
| 189 | }).pipe(Effect.provide(layerTest)), |
| 190 | ); |
| 191 | |
| 192 | it.effect("closes the agent before its store when sign-out interrupts a run", () => |
| 193 | Effect.gen(function* () { |
| 194 | let credentials: StoredSdkCredentials | undefined = { |
| 195 | version: 1, |
| 196 | backendUrl: "https://api2.cursor.sh", |
| 197 | apiKey: "browser-key", |
| 198 | createdAtMs: 0, |
| 199 | }; |
| 200 | const store: SdkCredentialStore = { |
| 201 | load: async () => credentials, |
| 202 | save: async (next) => { |
| 203 | credentials = next; |
| 204 | }, |
| 205 | clear: async () => { |
| 206 | credentials = undefined; |
| 207 | }, |
| 208 | }; |
| 209 | const auth = yield* makeCursorAuth({ |
| 210 | instanceId: ProviderInstanceId.make("cursor"), |
| 211 | displayName: "Cursor", |
| 212 | enabled: true, |
| 213 | store, |
| 214 | onChanged: () => Effect.void, |
| 215 | }); |
| 216 | const started = Promise.withResolvers<void>(); |
| 217 | cursorSdkMock.prompt.mockImplementationOnce(() => { |
| 218 | started.resolve(); |
| 219 | return new Promise(() => {}); |
| 220 | }); |
| 221 | const generation = yield* makeCursorTextGeneration( |
| 222 | cursorSettings, |
| 223 | {}, |
| 224 | auth.requireApiKey, |
| 225 | auth.withAccess, |
| 226 | ); |
| 227 | const request = yield* generation |
| 228 | .generateCommitMessage({ |
| 229 | cwd: process.cwd(), |
| 230 | branch: "feature/cursor-sign-out", |
| 231 | stagedSummary: "M README.md", |
| 232 | stagedPatch: "diff --git a/README.md b/README.md", |
| 233 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), |
| 234 | }) |
| 235 | .pipe(Effect.forkScoped); |
| 236 | yield* Effect.promise(() => started.promise); |
| 237 | |
| 238 | yield* auth.controller.logout(Effect.void); |
| 239 | |
| 240 | expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true); |
| 241 | expect(cursorSdkMock.storeDispose).toHaveBeenCalledTimes(1); |
| 242 | expect(cursorSdkMock.close.mock.invocationCallOrder[0]).toBeLessThan( |
| 243 | cursorSdkMock.storeDispose.mock.invocationCallOrder[0]!, |
| 244 | ); |
| 245 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, NodeCrypto.layer))), |
| 246 | ); |
| 247 | |
| 248 | it.effect("continues in the temp directory when the SDK cannot sandbox", () => |
| 249 | Effect.gen(function* () { |
| 250 | cursorSdkMock.create.mockImplementationOnce(async () => { |
| 251 | throw new Error( |
| 252 | "Local SDK sandboxing was requested, but sandboxing is not supported in this environment. Disable local.sandboxOptions.enabled or remove ~/.cursor/sandbox.json to run without sandboxing.", |
| 253 | ); |
| 254 | }); |
| 255 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { |
| 256 | CURSOR_API_KEY: "test-cursor-key", |
| 257 | }); |
| 258 | |
| 259 | const generated = yield* textGeneration.generateCommitMessage({ |
| 260 | cwd: process.cwd(), |
| 261 | branch: "feature/cursor-text-generation", |
| 262 | stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", |
| 263 | stagedPatch: "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts", |
| 264 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), |
| 265 | }); |
| 266 | |
| 267 | expect(generated.subject).toBe("Add generated commit message"); |
| 268 | expect(cursorSdkMock.create).toHaveBeenCalledTimes(2); |
| 269 | expect(cursorSdkMock.create.mock.calls[0]?.[0]).toMatchObject({ |
| 270 | local: { sandboxOptions: { enabled: true } }, |
| 271 | }); |
| 272 | expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({ |
| 273 | local: { |
| 274 | cwd: "/isolated-text-generation", |
| 275 | store: { dispose: cursorSdkMock.storeDispose }, |
| 276 | autoReview: false, |
| 277 | sandboxOptions: { enabled: false }, |
| 278 | settingSources: [], |
| 279 | enableAgentRetries: true, |
| 280 | }, |
| 281 | }); |
| 282 | }).pipe(Effect.provide(layerTest)), |
| 283 | ); |
| 284 | |
| 285 | it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () => |
| 286 | Effect.gen(function* () { |
| 287 | cursorSdkMock.create.mockImplementationOnce(async () => { |
| 288 | throw new Error("Cursor SDK network down"); |
| 289 | }); |
| 290 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { |
| 291 | CURSOR_API_KEY: "test-cursor-key", |
| 292 | }); |
| 293 | |
| 294 | const error = yield* Effect.flip( |
| 295 | textGeneration.generateCommitMessage({ |
| 296 | cwd: process.cwd(), |
| 297 | branch: "feature/cursor-text-generation", |
| 298 | stagedSummary: "M README.md", |
| 299 | stagedPatch: "diff --git a/README.md b/README.md", |
| 300 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), |
| 301 | }), |
| 302 | ); |
| 303 | |
| 304 | expect(error.detail).toBe("Cursor SDK text generation failed."); |
| 305 | expect(cursorSdkMock.create).toHaveBeenCalledTimes(1); |
| 306 | }).pipe(Effect.provide(layerTest)), |
| 307 | ); |
| 308 | |
| 309 | it.effect("accepts json objects with extra assistant text around them", () => |
| 310 | Effect.gen(function* () { |
| 311 | cursorSdkMock.prompt.mockResolvedValueOnce({ |
| 312 | id: "run-cursor-text-generation-test", |
| 313 | status: "finished", |
| 314 | result: |
| 315 | 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', |
| 316 | }); |
| 317 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { |
| 318 | CURSOR_API_KEY: "test-cursor-key", |
| 319 | }); |
| 320 | |
| 321 | const generated = yield* textGeneration.generateCommitMessage({ |
| 322 | cwd: process.cwd(), |
| 323 | branch: "feature/cursor-noisy-json", |
| 324 | stagedSummary: "M README.md", |
| 325 | stagedPatch: "diff --git a/README.md b/README.md", |
| 326 | modelSelection: { |
| 327 | instanceId: ProviderInstanceId.make("cursor"), |
| 328 | model: "composer-2", |
| 329 | }, |
| 330 | }); |
| 331 | |
| 332 | expect(generated.subject).toBe("Update README dummy comment with attribution and date"); |
| 333 | expect(generated.body).toBe(""); |
| 334 | }).pipe(Effect.provide(layerTest)), |
| 335 | ); |
| 336 | |
| 337 | it.effect("generates thread titles through Cursor SDK text generation", () => |
| 338 | Effect.gen(function* () { |
| 339 | cursorSdkMock.prompt.mockResolvedValueOnce({ |
| 340 | id: "run-cursor-title-generation-test", |
| 341 | status: "finished", |
| 342 | result: '{"title":"\\"Trim reconnect spinner status after resume.\\""}', |
| 343 | }); |
| 344 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { |
| 345 | CURSOR_API_KEY: "test-cursor-key", |
| 346 | }); |
| 347 | |
| 348 | const generated = yield* textGeneration.generateThreadTitle({ |
| 349 | cwd: process.cwd(), |
| 350 | message: "Fix the reconnect spinner after a resumed session.", |
| 351 | modelSelection: { |
| 352 | instanceId: ProviderInstanceId.make("cursor"), |
| 353 | model: "composer-2", |
| 354 | }, |
| 355 | }); |
| 356 | |
| 357 | expect(generated.title).toBe("Trim reconnect spinner status after resume."); |
| 358 | }).pipe(Effect.provide(layerTest)), |
| 359 | ); |
| 360 | |
| 361 | it.effect.each(["error", "cancelled"] as const)( |
| 362 | "rejects a %s Cursor SDK run that includes valid title JSON", |
| 363 | (status) => |
| 364 | Effect.gen(function* () { |
| 365 | const promptResult = { |
| 366 | id: "run-cursor-partial-title-test", |
| 367 | status, |
| 368 | result: '{"title":"Partial title from a failed run."}', |
| 369 | } satisfies RunResult; |
| 370 | cursorSdkMock.prompt.mockResolvedValueOnce(promptResult); |
| 371 | const generation = yield* makeCursorTextGeneration(cursorSettings, { |
| 372 | CURSOR_API_KEY: "test-cursor-key", |
| 373 | }); |
| 374 | const failure = yield* Effect.flip( |
| 375 | generation.generateThreadTitle({ |
| 376 | cwd: process.cwd(), |
| 377 | message: "Fix the reconnect spinner after a resumed session.", |
| 378 | modelSelection: { |
| 379 | instanceId: ProviderInstanceId.make("cursor"), |
| 380 | model: "composer-2", |
| 381 | }, |
| 382 | }), |
| 383 | ); |
| 384 | expect(failure).toBeInstanceOf(TextGenerationError); |
| 385 | expect(failure.operation).toBe("generateThreadTitle"); |
| 386 | expect(failure.detail).toBe( |
| 387 | status === "cancelled" |
| 388 | ? "Cursor SDK request was cancelled." |
| 389 | : "Cursor SDK request finished with an error.", |
| 390 | ); |
| 391 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); |
| 392 | }).pipe(Effect.provide(layerTest)), |
| 393 | ); |
| 394 | |
| 395 | it.effect("fails closed when ambient sandbox policy can expand write access", () => |
| 396 | Effect.gen(function* () { |
| 397 | hasCustomPolicy = true; |
| 398 | const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" }); |
| 399 | const failure = yield* Effect.flip( |
| 400 | generation.generateThreadTitle({ |
| 401 | cwd: "/real-workspace", |
| 402 | message: "Title", |
| 403 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, |
| 404 | }), |
| 405 | ); |
| 406 | expect(failure.detail).toContain("custom ~/.cursor/sandbox.json"); |
| 407 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); |
| 408 | }).pipe(Effect.provide(layerTest)), |
| 409 | ); |
| 410 | |
| 411 | it.effect("cancels the native run when text generation times out", () => |
| 412 | Effect.gen(function* () { |
| 413 | let started!: () => void; |
| 414 | const called = new Promise<void>((resolve) => { |
| 415 | started = resolve; |
| 416 | }); |
| 417 | cursorSdkMock.prompt.mockImplementationOnce(() => { |
| 418 | started(); |
| 419 | return new Promise(() => {}); |
| 420 | }); |
| 421 | const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" }); |
| 422 | const result = yield* generation |
| 423 | .generateThreadTitle({ |
| 424 | cwd: "/real-workspace", |
| 425 | message: "Title", |
| 426 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, |
| 427 | }) |
| 428 | .pipe(Effect.flip, Effect.forkScoped); |
| 429 | yield* Effect.promise(() => called); |
| 430 | yield* TestClock.adjust("180 seconds"); |
| 431 | expect((yield* Fiber.join(result)).detail).toContain("timed out"); |
| 432 | expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); |
| 433 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); |
| 434 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 435 | ); |
| 436 | |
| 437 | it.effect.each(["create", "send"] as const)( |
| 438 | "times out pending %s and releases its late SDK resource", |
| 439 | (phase) => |
| 440 | Effect.gen(function* () { |
| 441 | let started!: () => void; |
| 442 | const called = new Promise<void>((resolve) => { |
| 443 | started = resolve; |
| 444 | }); |
| 445 | let resolveLate!: (resource: unknown) => void; |
| 446 | const pending = new Promise<unknown>((resolve) => { |
| 447 | resolveLate = resolve; |
| 448 | }); |
| 449 | const wait = vi.fn(); |
| 450 | const agent = { |
| 451 | close: cursorSdkMock.close, |
| 452 | [Symbol.asyncDispose]: async () => { |
| 453 | cursorSdkMock.close(); |
| 454 | }, |
| 455 | send: async () => { |
| 456 | started(); |
| 457 | return pending; |
| 458 | }, |
| 459 | }; |
| 460 | cursorSdkMock.create.mockImplementation(async () => { |
| 461 | if (phase === "create") { |
| 462 | started(); |
| 463 | return pending; |
| 464 | } |
| 465 | return agent; |
| 466 | }); |
| 467 | const generation = yield* makeCursorTextGeneration(cursorSettings, { |
| 468 | CURSOR_API_KEY: "key", |
| 469 | }); |
| 470 | const result = yield* generation |
| 471 | .generateThreadTitle({ |
| 472 | cwd: "/real-workspace", |
| 473 | message: "Title", |
| 474 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, |
| 475 | }) |
| 476 | .pipe(Effect.flip, Effect.forkScoped); |
| 477 | yield* Effect.promise(() => called); |
| 478 | yield* TestClock.adjust("180 seconds"); |
| 479 | expect((yield* Fiber.join(result)).detail).toContain("timed out"); |
| 480 | let cleanup!: () => void; |
| 481 | const cleaned = new Promise<void>((resolve) => { |
| 482 | cleanup = resolve; |
| 483 | }); |
| 484 | if (phase === "create") cursorSdkMock.close.mockImplementationOnce(cleanup); |
| 485 | else |
| 486 | cursorSdkMock.cancel.mockImplementationOnce(async () => { |
| 487 | cleanup(); |
| 488 | }); |
| 489 | resolveLate( |
| 490 | phase === "create" ? agent : { status: "running", cancel: cursorSdkMock.cancel, wait }, |
| 491 | ); |
| 492 | yield* Effect.promise(() => cleaned); |
| 493 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); |
| 494 | expect(wait).not.toHaveBeenCalled(); |
| 495 | if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); |
| 496 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 497 | ); |
| 498 | |
| 499 | it.effect("requires CURSOR_API_KEY before calling the SDK", () => |
| 500 | Effect.gen(function* () { |
| 501 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {}); |
| 502 | |
| 503 | const error = yield* Effect.flip( |
| 504 | textGeneration.generateCommitMessage({ |
| 505 | cwd: process.cwd(), |
| 506 | branch: "feature/cursor-api-key", |
| 507 | stagedSummary: "M README.md", |
| 508 | stagedPatch: "diff --git a/README.md b/README.md", |
| 509 | modelSelection: { |
| 510 | instanceId: ProviderInstanceId.make("cursor"), |
| 511 | model: "composer-2", |
| 512 | }, |
| 513 | }), |
| 514 | ); |
| 515 | |
| 516 | expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings."); |
| 517 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); |
| 518 | }).pipe(Effect.provide(layerTest)), |
| 519 | ); |
| 520 | }); |