1import type { RunResult } from "@cursor/sdk";
2import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts";
3import { CursorSettings } from "../settings.ts";
4import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
10import { createModelSelection } from "@t3tools/shared/model";
11import { beforeEach, vi } from "vite-plus/test";
12
13import { makeCursorTextGeneration } from "./textGeneration.ts";
14import * as CursorSdk from "./CursorSdk.ts";
15
16const cursorSdkMock = vi.hoisted(() => ({
17 create: vi.fn<(options: unknown) => Promise<unknown>>(),
18 send: vi.fn<(prompt: string) => Promise<unknown>>(),
19 close: vi.fn(),
20 cancel: vi.fn(async () => {}),
21 storeOpen: vi.fn<(options: unknown) => Promise<unknown>>(),
22 storeDispose: vi.fn(async () => {}),
23 prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({
24 id: "run-cursor-text-generation-test",
25 status: "finished",
26 result:
27 '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}',
28 })),
29}));
30
31vi.mock("./sdk.ts", () => ({
32 loadCursorSdk: () => ({
33 Agent: { create: cursorSdkMock.create },
34 createAgentPlatform: vi.fn(),
35 }),
36 loadCursorSqlite: () => ({ SqliteLocalAgentStore: { open: cursorSdkMock.storeOpen } }),
37}));
38
39let hasCustomPolicy = false;
40const layerFs = FileSystem.layerNoop({
41 exists: () => Effect.succeed(hasCustomPolicy),
42 makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"),
43});
44const layerTest = CursorSdk.layer.pipe(Layer.provideMerge(Layer.merge(layerFs, Path.layer)));
45
46const decodeCursorSettings = Schema.decodeSync(CursorSettings);
47const cursorSettings = decodeCursorSettings({ enabled: true });
48
49beforeEach(() => {
50 hasCustomPolicy = false;
51 cursorSdkMock.create.mockReset();
52 cursorSdkMock.send.mockReset();
53 cursorSdkMock.create.mockImplementation(async (options) => {
54 cursorSdkMock.send.mockImplementation(async (prompt) => ({
55 status: "running",
56 cancel: cursorSdkMock.cancel,
57 wait: () => cursorSdkMock.prompt(prompt, options),
58 }));
59 return {
60 close: cursorSdkMock.close,
61 [Symbol.asyncDispose]: async () => {
62 cursorSdkMock.close();
63 },
64 send: cursorSdkMock.send,
65 };
66 });
67 cursorSdkMock.close.mockClear();
68 cursorSdkMock.cancel.mockClear();
69 cursorSdkMock.storeDispose.mockClear();
70 cursorSdkMock.storeOpen.mockReset();
71 cursorSdkMock.storeOpen.mockResolvedValue({ dispose: cursorSdkMock.storeDispose });
72 cursorSdkMock.prompt.mockReset();
73 cursorSdkMock.prompt.mockResolvedValue({
74 id: "run-cursor-text-generation-test",
75 status: "finished",
76 result:
77 '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}',
78 });
79});
80
81describe("CursorTextGeneration", () => {
82 it.effect("resolves the browser credential for every request after an account change", () =>
83 Effect.gen(function* () {
84 let apiKey = "first-browser-key";
85 const generation = yield* makeCursorTextGeneration(
86 cursorSettings,
87 {},
88 Effect.sync(() => apiKey),
89 );
90 const input = {
91 cwd: process.cwd(),
92 branch: "feature/cursor",
93 stagedSummary: "M file.ts",
94 stagedPatch: "diff",
95 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "auto"),
96 };
97 yield* generation.generateCommitMessage(input);
98 expect(cursorSdkMock.create).toHaveBeenLastCalledWith(
99 expect.objectContaining({ apiKey: "first-browser-key" }),
100 );
101 apiKey = "second-browser-key";
102 yield* generation.generateCommitMessage(input);
103 expect(cursorSdkMock.create).toHaveBeenLastCalledWith(
104 expect.objectContaining({ apiKey: "second-browser-key" }),
105 );
106 }).pipe(Effect.provide(layerTest)),
107 );
108
109 it.effect("continues in the temp directory when the SDK cannot sandbox", () =>
110 Effect.gen(function* () {
111 cursorSdkMock.create.mockImplementationOnce(async () => {
112 throw new Error(
113 "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.",
114 );
115 });
116 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
117 CURSOR_API_KEY: "test-cursor-key",
118 });
119
120 const generated = yield* textGeneration.generateCommitMessage({
121 cwd: process.cwd(),
122 branch: "feature/cursor-text-generation",
123 stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts",
124 stagedPatch: "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts",
125 modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"),
126 });
127
128 expect(generated.subject).toBe("Add generated commit message");
129 expect(cursorSdkMock.create).toHaveBeenCalledTimes(2);
130 expect(cursorSdkMock.create.mock.calls[0]?.[0]).toMatchObject({
131 local: { sandboxOptions: { enabled: true } },
132 });
133 expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({
134 local: {
135 cwd: "/isolated-text-generation",
136 store: { dispose: cursorSdkMock.storeDispose },
137 autoReview: false,
138 sandboxOptions: { enabled: false },
139 settingSources: [],
140 enableAgentRetries: true,
141 },
142 });
143 }).pipe(Effect.provide(layerTest)),
144 );
145
146 it.effect("accepts json objects with extra assistant text around them", () =>
147 Effect.gen(function* () {
148 cursorSdkMock.prompt.mockResolvedValueOnce({
149 id: "run-cursor-text-generation-test",
150 status: "finished",
151 result:
152 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.',
153 });
154 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
155 CURSOR_API_KEY: "test-cursor-key",
156 });
157
158 const generated = yield* textGeneration.generateCommitMessage({
159 cwd: process.cwd(),
160 branch: "feature/cursor-noisy-json",
161 stagedSummary: "M README.md",
162 stagedPatch: "diff --git a/README.md b/README.md",
163 modelSelection: {
164 instanceId: ProviderInstanceId.make("cursor"),
165 model: "composer-2",
166 },
167 });
168
169 expect(generated.subject).toBe("Update README dummy comment with attribution and date");
170 expect(generated.body).toBe("");
171 }).pipe(Effect.provide(layerTest)),
172 );
173
174 it.effect("generates thread titles through Cursor SDK text generation", () =>
175 Effect.gen(function* () {
176 cursorSdkMock.prompt.mockResolvedValueOnce({
177 id: "run-cursor-title-generation-test",
178 status: "finished",
179 result: '{"title":"\\"Trim reconnect spinner status after resume.\\""}',
180 });
181 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {
182 CURSOR_API_KEY: "test-cursor-key",
183 });
184
185 const generated = yield* textGeneration.generateThreadTitle({
186 cwd: process.cwd(),
187 message: "Fix the reconnect spinner after a resumed session.",
188 modelSelection: {
189 instanceId: ProviderInstanceId.make("cursor"),
190 model: "composer-2",
191 },
192 });
193
194 expect(generated.title).toBe("Trim reconnect spinner status after resume.");
195 }).pipe(Effect.provide(layerTest)),
196 );
197
198 it.effect.each([
199 {
200 status: "error",
201 error: { message: "Raw Cursor usage text", code: "PRO_USER_USAGE_LIMIT" },
202 detail:
203 "Cursor usage limit reached. Try again later or choose another text-generation provider.",
204 },
205 {
206 status: "error",
207 error: { message: "Raw Cursor backend text", code: "internal" },
208 detail: "Cursor SDK request finished with an error.",
209 },
210 { status: "cancelled", error: undefined, detail: "Cursor SDK request was cancelled." },
211 ] as const)(
212 "rejects a $status Cursor SDK run ($error.code) that includes valid title JSON",
213 ({ status, error, detail }) =>
214 Effect.gen(function* () {
215 const promptResult = {
216 id: "run-cursor-partial-title-test",
217 status,
218 result: '{"title":"Partial title from a failed run."}',
219 ...(error ? { error } : {}),
220 } satisfies RunResult;
221 cursorSdkMock.prompt.mockResolvedValueOnce(promptResult);
222 const generation = yield* makeCursorTextGeneration(cursorSettings, {
223 CURSOR_API_KEY: "test-cursor-key",
224 });
225 const failure = yield* Effect.flip(
226 generation.generateThreadTitle({
227 cwd: process.cwd(),
228 message: "Fix the reconnect spinner after a resumed session.",
229 modelSelection: {
230 instanceId: ProviderInstanceId.make("cursor"),
231 model: "composer-2",
232 },
233 }),
234 );
235 expect(failure).toBeInstanceOf(TextGenerationError);
236 expect(failure.operation).toBe("generateThreadTitle");
237 expect(failure.detail).toBe(detail);
238 expect(failure.message).not.toContain("Raw Cursor");
239 expect(failure.cause).toBe(error);
240 expect(cursorSdkMock.close).toHaveBeenCalledOnce();
241 }).pipe(Effect.provide(layerTest)),
242 );
243
244 it.effect("fails closed when ambient sandbox policy can expand write access", () =>
245 Effect.gen(function* () {
246 hasCustomPolicy = true;
247 const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" });
248 const failure = yield* Effect.flip(
249 generation.generateThreadTitle({
250 cwd: "/real-workspace",
251 message: "Title",
252 modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" },
253 }),
254 );
255 expect(failure.detail).toContain("custom ~/.cursor/sandbox.json");
256 expect(cursorSdkMock.prompt).not.toHaveBeenCalled();
257 }).pipe(Effect.provide(layerTest)),
258 );
259
260 it.effect("requires CURSOR_API_KEY before calling the SDK", () =>
261 Effect.gen(function* () {
262 const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {});
263
264 const error = yield* Effect.flip(
265 textGeneration.generateCommitMessage({
266 cwd: process.cwd(),
267 branch: "feature/cursor-api-key",
268 stagedSummary: "M README.md",
269 stagedPatch: "diff --git a/README.md b/README.md",
270 modelSelection: {
271 instanceId: ProviderInstanceId.make("cursor"),
272 model: "composer-2",
273 },
274 }),
275 );
276
277 expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings.");
278 expect(cursorSdkMock.prompt).not.toHaveBeenCalled();
279 }).pipe(Effect.provide(layerTest)),
280 );
281});