1import type { RunResult, SdkCredentialStore, StoredSdkCredentials } from "@cursor/sdk";
2import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
3import { CursorSettings } from "../settings.ts";
4import { describe, expect, it } from "@effect/vitest";
5import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
6import * as Effect from "effect/Effect";
7import * as Exit from "effect/Exit";
8import * as FileSystem from "effect/FileSystem";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as TestClock from "effect/testing/TestClock";
12import * as Schema from "effect/Schema";
13import { createModelSelection } from "@t3tools/shared/model";
14import { beforeEach, vi } from "vite-plus/test";
15
16import { makeCursorAuth } from "./auth.ts";
17import { makeCursorTextGeneration } from "./textGeneration.ts";
18import * as CursorSdk from "./CursorSdk.ts";
19
20const 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
35vi.mock("./sdk.ts", () => ({
36 Agent: { create: cursorSdkMock.create },
37 createAgentPlatform: vi.fn(),
38 SqliteLocalAgentStore: { open: cursorSdkMock.storeOpen },
39}));
40
41let hasCustomPolicy = false;
42const layerFs = FileSystem.layerNoop({
43 exists: () => Effect.succeed(hasCustomPolicy),
44 makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"),
45});
46const layerTest = Layer.merge(layerFs, CursorSdk.layer);
47
48const decodeCursorSettings = Schema.decodeSync(CursorSettings);
49const cursorSettings = decodeCursorSettings({ enabled: true });
50
51beforeEach(() => {
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
83describe("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});