diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts index 5bd81aea13c95c8c50e60da9adf4dd38411dc67a..24dc3c923772cc8956d8a7ac0026b409c5b7b5f8 100644 --- a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts @@ -104,6 +104,7 @@ vi.mock("./sdk.ts", () => ({ }, }, createAgentPlatform: vi.fn(), + SqliteLocalAgentStore: { open: vi.fn() }, })); const layerTest = CursorAgentSdk.layer.pipe( diff --git a/packages/provider-cursor/src/server/CursorSdk.test.ts b/packages/provider-cursor/src/server/CursorSdk.test.ts index 54b6b35cfcdcc59dcb432e97aaa5bd9f902935c9..53b9c67301409c0c3b83e5970295bcbf4ce8ad86 100644 --- a/packages/provider-cursor/src/server/CursorSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorSdk.test.ts @@ -25,7 +25,7 @@ writeFileSync( ); registerHooks({ resolve(specifier, context, next) { - if (specifier === "@cursor/sdk") { + if (specifier === "@cursor/sdk" || specifier === "@cursor/sdk/sqlite") { return { url: pathToFileURL(stub).href, shortCircuit: true }; } return next(specifier, context); diff --git a/packages/provider-cursor/src/server/CursorSdk.ts b/packages/provider-cursor/src/server/CursorSdk.ts index 0497bad3125f2657b2999834437a21c5968d16e9..2b9c02770d65bb7006e7e4f73c0b4562151ea47b 100644 --- a/packages/provider-cursor/src/server/CursorSdk.ts +++ b/packages/provider-cursor/src/server/CursorSdk.ts @@ -3,7 +3,7 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { Agent, createAgentPlatform } from "./sdk.ts"; +import { Agent, createAgentPlatform, SqliteLocalAgentStore } from "./sdk.ts"; /** * The Cursor Agent SDK runs in this process and spawns a shell for tool @@ -65,6 +65,7 @@ export class CursorSdk extends Context.Service< { readonly Agent: typeof Agent; readonly createAgentPlatform: typeof createAgentPlatform; + readonly SqliteLocalAgentStore: typeof SqliteLocalAgentStore; } >()("@t3tools/provider-cursor/server/CursorSdk") {} @@ -79,5 +80,5 @@ export const layer = Layer.effect( Effect.sync(() => { process.off("unhandledRejection", onUnhandledRejection); }), - ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform }))), + ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform, SqliteLocalAgentStore }))), ); diff --git a/packages/provider-cursor/src/server/sdk.ts b/packages/provider-cursor/src/server/sdk.ts index 602a0d0d2fd44dfd70f3eb94325d296f10329e0a..f9d3507e029eacb943465010302bd7450cd93dd3 100644 --- a/packages/provider-cursor/src/server/sdk.ts +++ b/packages/provider-cursor/src/server/sdk.ts @@ -12,3 +12,6 @@ export const { CursorSdkError, InMemoryCredentialStore, } = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk"); +export const { SqliteLocalAgentStore } = requireCursorSdk( + "@cursor/sdk/sqlite", +) as typeof import("@cursor/sdk/sqlite"); diff --git a/packages/provider-cursor/src/server/textGeneration.test.ts b/packages/provider-cursor/src/server/textGeneration.test.ts index 89d6ec895d13574b4175acb4be7f26320d3e5055..59fcfe67ed659e04b0ee5178d50b25f8f37485d0 100644 --- a/packages/provider-cursor/src/server/textGeneration.test.ts +++ b/packages/provider-cursor/src/server/textGeneration.test.ts @@ -1,8 +1,10 @@ -import type { RunResult } from "@cursor/sdk"; +import type { RunResult, SdkCredentialStore, StoredSdkCredentials } from "@cursor/sdk"; import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import { CursorSettings } from "../settings.ts"; import { describe, expect, it } from "@effect/vitest"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -11,6 +13,7 @@ import * as Schema from "effect/Schema"; import { createModelSelection } from "@t3tools/shared/model"; import { beforeEach, vi } from "vite-plus/test"; +import { makeCursorAuth } from "./auth.ts"; import { makeCursorTextGeneration } from "./textGeneration.ts"; import * as CursorSdk from "./CursorSdk.ts"; @@ -19,6 +22,8 @@ const cursorSdkMock = vi.hoisted(() => ({ send: vi.fn<(prompt: string) => Promise>(), close: vi.fn(), cancel: vi.fn(async () => {}), + storeOpen: vi.fn<(options: unknown) => Promise>(), + storeDispose: vi.fn(async () => {}), prompt: vi.fn<(prompt: string, options: unknown) => Promise>(async () => ({ id: "run-cursor-text-generation-test", status: "finished", @@ -30,6 +35,7 @@ const cursorSdkMock = vi.hoisted(() => ({ vi.mock("./sdk.ts", () => ({ Agent: { create: cursorSdkMock.create }, createAgentPlatform: vi.fn(), + SqliteLocalAgentStore: { open: cursorSdkMock.storeOpen }, })); let hasCustomPolicy = false; @@ -62,6 +68,9 @@ beforeEach(() => { }); cursorSdkMock.close.mockClear(); cursorSdkMock.cancel.mockClear(); + cursorSdkMock.storeDispose.mockClear(); + cursorSdkMock.storeOpen.mockReset(); + cursorSdkMock.storeOpen.mockResolvedValue({ dispose: cursorSdkMock.storeDispose }); cursorSdkMock.prompt.mockReset(); cursorSdkMock.prompt.mockResolvedValue({ id: "run-cursor-text-generation-test", @@ -139,6 +148,7 @@ describe("CursorTextGeneration", () => { }, local: { cwd: "/isolated-text-generation", + store: { dispose: cursorSdkMock.storeDispose }, autoReview: false, sandboxOptions: { enabled: true }, settingSources: [], @@ -148,6 +158,93 @@ describe("CursorTextGeneration", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("disposes the agent store after the agent, whether the run succeeds or fails", () => + Effect.gen(function* () { + const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { + CURSOR_API_KEY: "test-cursor-key", + }); + const input = { + cwd: process.cwd(), + branch: "feature/cursor-store", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), + }; + + yield* textGeneration.generateCommitMessage(input); + expect(cursorSdkMock.storeOpen).toHaveBeenCalledWith( + expect.objectContaining({ workspaceRef: "/isolated-text-generation" }), + ); + expect(cursorSdkMock.storeDispose).toHaveBeenCalledTimes(1); + expect(cursorSdkMock.close.mock.invocationCallOrder[0]).toBeLessThan( + cursorSdkMock.storeDispose.mock.invocationCallOrder[0]!, + ); + + cursorSdkMock.prompt.mockResolvedValueOnce({ + id: "run-cursor-text-generation-test", + status: "error", + }); + yield* Effect.flip(textGeneration.generateCommitMessage(input)); + expect(cursorSdkMock.storeDispose).toHaveBeenCalledTimes(2); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("closes the agent before its store when sign-out interrupts a run", () => + Effect.gen(function* () { + let credentials: StoredSdkCredentials | undefined = { + version: 1, + backendUrl: "https://api2.cursor.sh", + apiKey: "browser-key", + createdAtMs: 0, + }; + const store: SdkCredentialStore = { + load: async () => credentials, + save: async (next) => { + credentials = next; + }, + clear: async () => { + credentials = undefined; + }, + }; + const auth = yield* makeCursorAuth({ + instanceId: ProviderInstanceId.make("cursor"), + displayName: "Cursor", + enabled: true, + store, + onChanged: () => Effect.void, + }); + const started = Promise.withResolvers(); + cursorSdkMock.prompt.mockImplementationOnce(() => { + started.resolve(); + return new Promise(() => {}); + }); + const generation = yield* makeCursorTextGeneration( + cursorSettings, + {}, + auth.requireApiKey, + auth.withAccess, + ); + const request = yield* generation + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-sign-out", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), + }) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => started.promise); + + yield* auth.controller.logout(Effect.void); + + expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true); + expect(cursorSdkMock.storeDispose).toHaveBeenCalledTimes(1); + expect(cursorSdkMock.close.mock.invocationCallOrder[0]).toBeLessThan( + cursorSdkMock.storeDispose.mock.invocationCallOrder[0]!, + ); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(layerTest, NodeCrypto.layer))), + ); + it.effect("continues in the temp directory when the SDK cannot sandbox", () => Effect.gen(function* () { cursorSdkMock.create.mockImplementationOnce(async () => { @@ -175,6 +272,7 @@ describe("CursorTextGeneration", () => { expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({ local: { cwd: "/isolated-text-generation", + store: { dispose: cursorSdkMock.storeDispose }, autoReview: false, sandboxOptions: { enabled: false }, settingSources: [], diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts index e1cc3cf0d9198015ae4a5f51be11f25b05a514cd..10650abb902157965b19df7f2bb4bd980a24c5af 100644 --- a/packages/provider-cursor/src/server/textGeneration.ts +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -58,7 +58,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu withAccess?: CursorAuth["withAccess"], ) { const fs = yield* FileSystem.FileSystem; - const { Agent } = yield* CursorSdk.CursorSdk; + const { Agent, SqliteLocalAgentStore } = yield* CursorSdk.CursorSdk; const resolvedEnvironment = environment ?? process.env; const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) => @@ -98,12 +98,21 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu }); } const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" }); + // The SDK keeps SQLite handles open after `agent.close()` and only releases + // them from a store the caller disposes. Its state lives outside `cwd` so the + // agent's workspace stays empty. + const stateRoot = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-state-" }); + const store = yield* Effect.acquireRelease( + Effect.tryPromise(() => SqliteLocalAgentStore.open({ workspaceRef: cwd, stateRoot })), + (store) => Effect.tryPromise(() => store.dispose()).pipe(Effect.ignore({ log: true })), + ); const agentOptions = { apiKey, mode: "plan", model: cursorSdkModelSelection(modelSelection), local: { cwd, + store, autoReview: false, sandboxOptions: { enabled: true }, settingSources: [], @@ -184,6 +193,8 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult); }).pipe( + // Scope this call inside `withAccess`, so sign-out closes the agent before its store. + Effect.scoped, (effect) => (withAccess ? withAccess(effect) : effect), Effect.scoped, Effect.mapError((cause) =>