| ... | @@ -1,8 +1,10 @@ | ... | @@ -1,8 +1,10 @@ |
| 1 | import type { RunResult } from "@cursor/sdk"; | 1 | import type { RunResult, SdkCredentialStore, StoredSdkCredentials } from "@cursor/sdk"; |
| 2 | import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; | 2 | import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; |
| 3 | import { CursorSettings } from "../settings.ts"; | 3 | import { CursorSettings } from "../settings.ts"; |
| 4 | import { describe, expect, it } from "@effect/vitest"; | 4 | import { describe, expect, it } from "@effect/vitest"; |
| | 5 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| | 7 | import * as Exit from "effect/Exit"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | 8 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Fiber from "effect/Fiber"; | 9 | import * as Fiber from "effect/Fiber"; |
| 8 | import * as Layer from "effect/Layer"; | 10 | import * as Layer from "effect/Layer"; |
| ... | @@ -11,6 +13,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -11,6 +13,7 @@ import * as Schema from "effect/Schema"; |
| 11 | import { createModelSelection } from "@t3tools/shared/model"; | 13 | import { createModelSelection } from "@t3tools/shared/model"; |
| 12 | import { beforeEach, vi } from "vite-plus/test"; | 14 | import { beforeEach, vi } from "vite-plus/test"; |
| 13 | | 15 | |
| | 16 | import { makeCursorAuth } from "./auth.ts"; |
| 14 | import { makeCursorTextGeneration } from "./textGeneration.ts"; | 17 | import { makeCursorTextGeneration } from "./textGeneration.ts"; |
| 15 | import * as CursorSdk from "./CursorSdk.ts"; | 18 | import * as CursorSdk from "./CursorSdk.ts"; |
| 16 | | 19 | |
| ... | @@ -19,6 +22,8 @@ const cursorSdkMock = vi.hoisted(() => ({ | ... | @@ -19,6 +22,8 @@ const cursorSdkMock = vi.hoisted(() => ({ |
| 19 | send: vi.fn<(prompt: string) => Promise<unknown>>(), | 22 | send: vi.fn<(prompt: string) => Promise<unknown>>(), |
| 20 | close: vi.fn(), | 23 | close: vi.fn(), |
| 21 | cancel: vi.fn(async () => {}), | 24 | cancel: vi.fn(async () => {}), |
| | 25 | storeOpen: vi.fn<(options: unknown) => Promise<unknown>>(), |
| | 26 | storeDispose: vi.fn(async () => {}), |
| 22 | prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({ | 27 | prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({ |
| 23 | id: "run-cursor-text-generation-test", | 28 | id: "run-cursor-text-generation-test", |
| 24 | status: "finished", | 29 | status: "finished", |
| ... | @@ -30,6 +35,7 @@ const cursorSdkMock = vi.hoisted(() => ({ | ... | @@ -30,6 +35,7 @@ const cursorSdkMock = vi.hoisted(() => ({ |
| 30 | vi.mock("./sdk.ts", () => ({ | 35 | vi.mock("./sdk.ts", () => ({ |
| 31 | Agent: { create: cursorSdkMock.create }, | 36 | Agent: { create: cursorSdkMock.create }, |
| 32 | createAgentPlatform: vi.fn(), | 37 | createAgentPlatform: vi.fn(), |
| | 38 | SqliteLocalAgentStore: { open: cursorSdkMock.storeOpen }, |
| 33 | })); | 39 | })); |
| 34 | | 40 | |
| 35 | let hasCustomPolicy = false; | 41 | let hasCustomPolicy = false; |
| ... | @@ -62,6 +68,9 @@ beforeEach(() => { | ... | @@ -62,6 +68,9 @@ beforeEach(() => { |
| 62 | }); | 68 | }); |
| 63 | cursorSdkMock.close.mockClear(); | 69 | cursorSdkMock.close.mockClear(); |
| 64 | cursorSdkMock.cancel.mockClear(); | 70 | cursorSdkMock.cancel.mockClear(); |
| | 71 | cursorSdkMock.storeDispose.mockClear(); |
| | 72 | cursorSdkMock.storeOpen.mockReset(); |
| | 73 | cursorSdkMock.storeOpen.mockResolvedValue({ dispose: cursorSdkMock.storeDispose }); |
| 65 | cursorSdkMock.prompt.mockReset(); | 74 | cursorSdkMock.prompt.mockReset(); |
| 66 | cursorSdkMock.prompt.mockResolvedValue({ | 75 | cursorSdkMock.prompt.mockResolvedValue({ |
| 67 | id: "run-cursor-text-generation-test", | 76 | id: "run-cursor-text-generation-test", |
| ... | @@ -139,6 +148,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -139,6 +148,7 @@ describe("CursorTextGeneration", () => { |
| 139 | }, | 148 | }, |
| 140 | local: { | 149 | local: { |
| 141 | cwd: "/isolated-text-generation", | 150 | cwd: "/isolated-text-generation", |
| | 151 | store: { dispose: cursorSdkMock.storeDispose }, |
| 142 | autoReview: false, | 152 | autoReview: false, |
| 143 | sandboxOptions: { enabled: true }, | 153 | sandboxOptions: { enabled: true }, |
| 144 | settingSources: [], | 154 | settingSources: [], |
| ... | @@ -148,6 +158,93 @@ describe("CursorTextGeneration", () => { | ... | @@ -148,6 +158,93 @@ describe("CursorTextGeneration", () => { |
| 148 | }).pipe(Effect.provide(layerTest)), | 158 | }).pipe(Effect.provide(layerTest)), |
| 149 | ); | 159 | ); |
| 150 | | 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 | |
| 151 | it.effect("continues in the temp directory when the SDK cannot sandbox", () => | 248 | it.effect("continues in the temp directory when the SDK cannot sandbox", () => |
| 152 | Effect.gen(function* () { | 249 | Effect.gen(function* () { |
| 153 | cursorSdkMock.create.mockImplementationOnce(async () => { | 250 | cursorSdkMock.create.mockImplementationOnce(async () => { |
| ... | @@ -175,6 +272,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -175,6 +272,7 @@ describe("CursorTextGeneration", () => { |
| 175 | expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({ | 272 | expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({ |
| 176 | local: { | 273 | local: { |
| 177 | cwd: "/isolated-text-generation", | 274 | cwd: "/isolated-text-generation", |
| | 275 | store: { dispose: cursorSdkMock.storeDispose }, |
| 178 | autoReview: false, | 276 | autoReview: false, |
| 179 | sandboxOptions: { enabled: false }, | 277 | sandboxOptions: { enabled: false }, |
| 180 | settingSources: [], | 278 | settingSources: [], |