diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 02ba1b20ae859c7a7dd833f2241fc67cd3a1e78c..2b73304bebd16a7eed4001419783cc891f565711 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -70,6 +70,7 @@ "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index dda6e6f3b544c2bbe559a2c3c1f5a906b36a2a7b..f2eb4e3c7f70fd2da7b5f4078278536ba8ba33b5 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -109,17 +109,6 @@ export function ProviderIcon(props: ProviderIconProps) { ); } - if (props.provider === "cursor") { - return ( - - - - ); - } - const packageIcon = getProviderClient(props.provider)?.icon; if (packageIcon) { const themeFill = (fill: { readonly light: string; readonly dark: string }) => diff --git a/apps/mobile/src/lib/providerClients.ts b/apps/mobile/src/lib/providerClients.ts index 11f7e13a3a78dcf6f44ea15cf350deee24b4745a..cb469a28706c86a7c93020238c84ea2d348dc80e 100644 --- a/apps/mobile/src/lib/providerClients.ts +++ b/apps/mobile/src/lib/providerClients.ts @@ -1,11 +1,17 @@ import { isProviderDriverKind } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { cursorClient } from "@t3tools/provider-cursor/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; /** The provider client definitions this mobile build ships. */ -const providerClients = makeProviderClientRegistry([museClient, openCodeClient, piClient]); +const providerClients = makeProviderClientRegistry([ + cursorClient, + museClient, + openCodeClient, + piClient, +]); /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ export function getProviderClient(driver: string | null | undefined) { diff --git a/apps/server/package.json b/apps/server/package.json index 11f202120f85a6d19160255608e90e8c08d7b334..de20a01de3b9ce3f30514cded1e4902c132fdd0a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -39,6 +39,7 @@ "@noble/hashes": "catalog:", "@opencode-ai/sdk": "^1.3.15", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts deleted file mode 100644 index cf37340d0e181ae3dc9fcd3f839ba6eeccaaac74..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts +++ /dev/null @@ -1,956 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import type { InteractionUpdate } from "@cursor/sdk"; -import { - CursorSettings, - EnvironmentId, - MessageId, - NodeId, - ProjectId, - ProviderInstanceId, - ProviderSessionId, - RunAttemptId, - RunId, - ThreadId, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; - -import * as ServerConfig from "../../config.ts"; -import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; -import { - cursorMcpServers, - cursorRuntimeAgentPolicy, - cursorSdkModelSelection, - makeCursorAgentOptions, - makeCursorAdapterV2, - nestedToolCallFromEnvelope, -} from "./CursorAdapterV2.ts"; -import { isCursorCancellationError, loggedCursorAgentOptions } from "./CursorAgentSdk.ts"; - -const decodeCursorSettings = Schema.decodeEffect(CursorSettings); - -describe("CursorAdapterV2", () => { - it.effect.each([ - { status: "finished", model: undefined, lateModel: undefined }, - { status: "cancelled", model: "claude-opus-4-6", lateModel: undefined }, - { status: "error", model: "custom-fable", lateModel: undefined }, - { status: "finished", model: undefined, lateModel: "gpt-6-sol" }, - { status: "finished", model: "gpt-6-sol", lateModel: null }, - ] as const)( - "projects Cursor tasks: $status, late model $lateModel", - ({ status, model, lateModel }) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "cursor-v2-lifecycle-", - }); - const instanceId = ProviderInstanceId.make("cursor"); - const threadId = ThreadId.make("cursor-lifecycle-thread"); - const modelSelection = { instanceId, model: "composer-2.5" }; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: workspace, - }); - const adapter = makeCursorAdapterV2({ - instanceId, - settings: yield* decodeCursorSettings({}), - environment: { HOME: workspace }, - fileSystem, - path, - idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig.pipe( - Effect.provide( - ServerConfig.layerTest(workspace, { prefix: "cursor-v2-lifecycle-config-" }), - ), - ), - runner: { - assertComplete: Effect.void, - open: () => - Effect.succeed({ - agentId: "native-cursor-lifecycle", - listMessages: Effect.succeed([]), - close: Effect.void, - send: (input) => - Effect.gen(function* () { - // Recorded Cursor task calls (see fixtures/subagent) stream a - // partial-tool-call before tool-call-started with the same args. - // The run then ends without tool-call-completed, which a live - // run cannot produce on demand. - const taskToolCall = { - type: "task" as const, - args: { - description: "Review", - prompt: "Review the code.", - subagentType: { kind: "unspecified" }, - ...(model === undefined ? {} : { model }), - agentId: "cursor-task-agent", - mode: "unspecified" as const, - }, - }; - const updates = - lateModel !== undefined - ? (["tool-call-started", "tool-call-completed"] as const) - : (["partial-tool-call", "tool-call-started"] as const); - for (const type of updates) { - yield* input.onDelta!({ - type, - modelCallId: "model-call", - callId: "task-call", - toolCall: { - ...taskToolCall, - args: { - ...taskToolCall.args, - ...(type === "tool-call-completed" - ? { model: lateModel ?? undefined } - : {}), - }, - }, - }).pipe(Effect.orDie); - } - return { - agentId: "native-cursor-lifecycle", - runId: "native-cursor-run", - wait: Effect.succeed({ - id: "native-cursor-run", - requestId: "native-request", - status, - model: { id: "composer-2.5" }, - durationMs: 1, - }), - cancel: Effect.void, - }; - }), - }), - }, - }); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("cursor-lifecycle-session"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn({ - threadId, - providerThread, - modelSelection, - runtimePolicy, - runId: RunId.make("cursor-lifecycle-run"), - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: RunAttemptId.make("cursor-lifecycle-attempt"), - rootNodeId: NodeId.make("cursor-lifecycle-root"), - appThread: { - id: threadId, - projectId: ProjectId.make("cursor-lifecycle-project"), - createdBy: "user", - creationSource: "web", - title: "Cursor lifecycle", - providerInstanceId: instanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: providerThread.id, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - message: { - messageId: MessageId.make("cursor-lifecycle-message"), - createdBy: "user", - creationSource: "web", - text: "Review the code.", - attachments: [], - }, - }); - const events = yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - ); - const rows = events.filter((event) => event.type === "subagent.updated"); - assert.equal(rows[0]?.subagent.status, "running"); - assert.equal(rows[0]?.subagent.model, model ?? null); - assert.equal(rows.at(-1)?.subagent.model, lateModel ?? model ?? null); - assert.equal( - rows.at(-1)?.subagent.status, - lateModel !== undefined - ? "completed" - : status === "finished" - ? "idle" - : status === "cancelled" - ? "cancelled" - : "failed", - ); - assert.isNotNull(rows.at(-1)?.subagent.completedAt); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), - ); - - it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-errors-" }); - const sentMessages: Array = []; - let sendCalls = 0; - const instanceId = ProviderInstanceId.make("cursor"); - const threadId = ThreadId.make("cursor-transport-error-thread"); - const modelSelection = { instanceId, model: "composer-2.5" }; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: workspace, - }); - const adapter = makeCursorAdapterV2({ - instanceId, - settings: yield* decodeCursorSettings({}), - environment: { HOME: workspace }, - fileSystem, - path, - idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig.pipe( - Effect.provide(ServerConfig.layerTest(workspace, { prefix: "cursor-v2-error-config-" })), - ), - runner: { - assertComplete: Effect.void, - open: () => - Effect.succeed({ - agentId: "native-cursor-error", - listMessages: Effect.succeed([]), - close: Effect.void, - send: (input) => - Effect.sync(() => { - sendCalls += 1; - sentMessages.push( - typeof input.message === "string" ? input.message : input.message.text, - ); - const runId = `native-cursor-run-${sendCalls}`; - return { - agentId: "native-cursor-error", - runId, - wait: Effect.succeed({ - id: runId, - requestId: `native-request-${sendCalls}`, - status: "finished" as const, - model: { id: "composer-2.5" }, - durationMs: 1, - ...(sendCalls === 1 - ? { result: "Error: RetriableError: WritableIterable is closed" } - : {}), - }), - cancel: Effect.void, - }; - }), - }), - }, - }); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("cursor-error-session"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - const appThread = { - id: threadId, - projectId: ProjectId.make("cursor-error-project"), - createdBy: "user" as const, - creationSource: "web" as const, - title: "Cursor transport failure", - providerInstanceId: instanceId, - modelSelection, - runtimeMode: "full-access" as const, - interactionMode: "default" as const, - branch: null, - worktreePath: null, - activeProviderThreadId: providerThread.id, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }; - yield* runtime.startTurn({ - threadId, - providerThread, - modelSelection, - runtimePolicy, - runId: RunId.make("cursor-error-run"), - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: RunAttemptId.make("cursor-error-attempt"), - rootNodeId: NodeId.make("cursor-error-root"), - appThread, - message: { - messageId: MessageId.make("cursor-error-message"), - createdBy: "user", - creationSource: "web", - text: "continue", - attachments: [], - }, - }); - const first = yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, - ); - assert.isTrue(Option.isSome(first)); - if (Option.isSome(first)) { - assert.equal(first.value.status, "failed"); - assert.equal(first.value.failure?.class, "transport_error"); - } - - assert.isDefined(runtime.compactThread); - yield* runtime.compactThread!({ - threadId, - providerThread, - modelSelection, - runtimePolicy, - runId: RunId.make("cursor-compact-run"), - runOrdinal: 2, - providerTurnOrdinal: 2, - attemptId: RunAttemptId.make("cursor-compact-attempt"), - rootNodeId: NodeId.make("cursor-compact-root"), - appThread, - message: { - messageId: MessageId.make("cursor-compact-message"), - createdBy: "user", - creationSource: "web", - text: "ignored by compactThread", - attachments: [], - }, - }); - yield* runtime.events.pipe( - Stream.filter((event) => event.type === "turn.terminal"), - Stream.runHead, - ); - assert.equal(sentMessages[1], "/compress"); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), - ); - - it.effect("projects Cursor directory trees and lint diagnostics as file search results", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-search-" }); - const instanceId = ProviderInstanceId.make("cursor"); - const threadId = ThreadId.make("cursor-search-thread"); - const modelSelection = { instanceId, model: "composer-2.5" }; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: workspace, - }); - const emptyLsNode = { - childrenDirs: [], - childrenFiles: [], - childrenWereProcessed: true, - fullSubtreeExtensionCounts: {}, - numFiles: 0, - }; - const updates: ReadonlyArray = [ - ...(["tool-call-started", "tool-call-completed"] as const).map((type) => ({ - type, - modelCallId: "native-model-call", - callId: "mcp-weather", - toolCall: { - type: "mcp" as const, - args: { - providerIdentifier: "weather", - toolName: "get_weather", - args: { city: "Berlin" }, - }, - ...(type === "tool-call-completed" - ? { result: { status: "success" as const, value: { content: [], isError: false } } } - : {}), - }, - })), - { - type: "tool-call-completed", - modelCallId: "native-model-call", - callId: "read-file", - toolCall: { - type: "read", - args: { path: "src/env.ts" }, - result: { - status: "success", - value: { fileSize: 12, content: "---\nfile body", totalLines: 2 }, - }, - }, - }, - { - type: "tool-call-completed", - modelCallId: "native-model-call", - callId: "ls-nested", - toolCall: { - type: "ls", - args: { path: workspace }, - result: { - status: "success", - value: { - directoryTreeRoot: { - ...emptyLsNode, - absPath: workspace, - childrenFiles: [{ name: "README.md" }], - childrenDirs: [ - { - ...emptyLsNode, - absPath: path.join(workspace, "src"), - childrenFiles: [{ name: "index.ts" }], - childrenDirs: [ - { - ...emptyLsNode, - absPath: path.join(workspace, "src", "nested"), - childrenFiles: [{ name: "util.ts" }], - }, - ], - }, - ], - }, - }, - }, - }, - }, - { - type: "tool-call-completed", - modelCallId: "native-model-call", - callId: "ls-empty", - toolCall: { - type: "ls", - args: { path: path.join(workspace, "empty") }, - result: { - status: "success", - value: { - directoryTreeRoot: { ...emptyLsNode, absPath: path.join(workspace, "empty") }, - }, - }, - }, - }, - { - type: "tool-call-completed", - modelCallId: "native-model-call", - callId: "ls-failed", - toolCall: { - type: "ls", - args: { path: path.join(workspace, "missing") }, - result: { status: "error", error: "ENOENT" }, - }, - }, - { - type: "tool-call-completed", - modelCallId: "native-model-call", - callId: "grep-failed", - toolCall: { - type: "grep", - args: { pattern: "TODO", path: "src" }, - result: { status: "error", error: "search failed" }, - }, - }, - { - type: "tool-call-completed", - modelCallId: "native-model-call", - callId: "glob-failed", - toolCall: { - type: "glob", - args: { globPattern: "*.ts" }, - result: { status: "error", error: "search failed" }, - }, - }, - { - type: "tool-call-completed", - modelCallId: "native-model-call", - callId: "lints", - toolCall: { - type: "readLints", - args: { paths: ["src/a.ts", "src/b.ts"] }, - result: { - status: "success", - value: { - totalFiles: 2, - totalDiagnostics: 3, - fileDiagnostics: [ - { - path: "src/a.ts", - diagnosticsCount: 2, - diagnostics: [ - { - message: "Unused variable", - code: "TS6133", - source: "ts", - severity: "warning", - range: { start: { line: 0, character: 4 } }, - }, - { - message: "Cannot find name", - code: "TS2304", - source: "ts", - severity: "error", - range: { start: { line: 11 } }, - }, - ], - }, - { - path: "src/b.ts", - diagnosticsCount: 1, - diagnostics: [ - { - message: "File-level diagnostic", - code: "TS0", - source: "ts", - severity: "information", - }, - ], - }, - ], - }, - }, - }, - }, - { - type: "tool-call-completed", - modelCallId: "native-model-call", - callId: "lints-empty", - toolCall: { - type: "readLints", - args: { paths: ["src/clean.ts"] }, - result: { - status: "success", - value: { - totalFiles: 1, - totalDiagnostics: 0, - fileDiagnostics: [{ path: "src/clean.ts", diagnosticsCount: 0, diagnostics: [] }], - }, - }, - }, - }, - { - type: "tool-call-completed", - modelCallId: "native-model-call", - callId: "lints-failed", - toolCall: { - type: "readLints", - args: { paths: ["src/a.ts"] }, - result: { status: "error", error: "lint failed" }, - }, - }, - ]; - const adapter = makeCursorAdapterV2({ - instanceId, - settings: yield* decodeCursorSettings({}), - environment: { HOME: workspace }, - fileSystem, - path, - idAllocator: yield* IdAllocator.IdAllocatorV2, - serverConfig: yield* ServerConfig.ServerConfig.pipe( - Effect.provide(ServerConfig.layerTest(workspace, { prefix: "cursor-v2-search-config-" })), - ), - runner: { - assertComplete: Effect.void, - open: () => - Effect.succeed({ - agentId: "native-cursor-search", - listMessages: Effect.succeed([]), - close: Effect.void, - send: (input) => - Effect.gen(function* () { - for (const update of updates) { - if (input.onDelta !== undefined) { - yield* input.onDelta(update).pipe(Effect.orDie); - } - } - return { - agentId: "native-cursor-search", - runId: "native-cursor-run", - wait: Effect.succeed({ - id: "native-cursor-run", - requestId: "native-request", - status: "finished" as const, - model: { id: "composer-2.5" }, - durationMs: 1, - }), - cancel: Effect.void, - }; - }), - }), - }, - }); - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("cursor-search-session"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn({ - threadId, - providerThread, - modelSelection, - runtimePolicy, - runId: RunId.make("cursor-search-run"), - runOrdinal: 1, - providerTurnOrdinal: 1, - attemptId: RunAttemptId.make("cursor-search-attempt"), - rootNodeId: NodeId.make("cursor-search-root"), - appThread: { - id: threadId, - projectId: ProjectId.make("cursor-search-project"), - createdBy: "user", - creationSource: "web", - title: "Cursor search results", - providerInstanceId: instanceId, - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - activeProviderThreadId: providerThread.id, - lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, - forkedFrom: null, - createdAt: now, - updatedAt: now, - archivedAt: null, - settledOverride: null, - settledAt: null, - lastVisitedAt: null, - deletedAt: null, - }, - message: { - messageId: MessageId.make("cursor-search-message"), - createdBy: "user", - creationSource: "web", - text: "inspect the workspace", - attachments: [], - }, - }); - const events = yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - ); - const mcpItems = events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.toolName === "mcp__weather__get_weather" - ? [event.turnItem] - : [], - ); - assert.deepEqual( - mcpItems.map((item) => item.status), - ["running", "completed"], - ); - for (const item of mcpItems) { - assert.equal(item.title, "get weather"); - assert.deepEqual(item.toolSource, { - key: "mcp:weather", - name: "weather", - kind: "integration", - }); - assert.deepEqual(item.input, { - providerIdentifier: "weather", - toolName: "get_weather", - args: { city: "Berlin" }, - }); - } - const fileSearchItems = events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "file_search" && - event.turnItem.status !== "running" - ? [event.turnItem] - : [], - ); - const readItems = events.flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.toolName === "Read" && - event.turnItem.status === "completed" - ? [event.turnItem] - : [], - ); - assert.deepEqual( - readItems.map((item) => ({ title: item.title, input: item.input })), - [{ title: "Read src/env.ts", input: { path: "src/env.ts" } }], - ); - assert.deepEqual( - fileSearchItems.map((item) => ({ - pattern: item.pattern, - status: item.status, - results: item.results, - })), - [ - { - pattern: workspace, - status: "completed", - results: [ - { fileName: path.join(workspace, "README.md") }, - { fileName: path.join(workspace, "src", "index.ts") }, - { fileName: path.join(workspace, "src", "nested", "util.ts") }, - ], - }, - { - pattern: path.join(workspace, "empty"), - status: "completed", - results: undefined, - }, - { - pattern: path.join(workspace, "missing"), - status: "failed", - results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }], - }, - { - pattern: "TODO", - status: "failed", - results: [{ fileName: "src", preview: "search failed" }], - }, - { - pattern: "*.ts", - status: "failed", - results: [{ fileName: ".", preview: "search failed" }], - }, - { - pattern: "src/a.ts, src/b.ts", - status: "completed", - results: [ - { fileName: "src/a.ts", line: 1, column: 5, preview: "Unused variable" }, - { fileName: "src/a.ts", line: 12, preview: "Cannot find name" }, - { fileName: "src/b.ts", preview: "File-level diagnostic" }, - ], - }, - { - pattern: "src/clean.ts", - status: "completed", - results: undefined, - }, - { - pattern: "src/a.ts", - status: "failed", - results: [{ fileName: "src/a.ts", preview: "lint failed" }], - }, - ], - ); - }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), - ); - - it("maps Cursor auto and model parameters to SDK selections", () => { - assert.deepEqual( - cursorSdkModelSelection({ - instanceId: ProviderInstanceId.make("cursor"), - model: "auto", - options: [ - { id: "thinking", value: "high" }, - { id: "contextWindow", value: "1m" }, - { id: "fastMode", value: true }, - ], - }), - { - id: "default", - params: [ - { id: "thinking", value: "high" }, - { id: "context", value: "1m" }, - { id: "fast", value: "true" }, - ], - }, - ); - }); - - it("maps runtime modes to the SDK sandbox and auto-review controls", () => { - const base = { - interactionMode: "default" as const, - cwd: "/tmp/cursor-adapter", - }; - assert.deepEqual( - cursorRuntimeAgentPolicy({ - ...base, - runtimeMode: "full-access", - }), - { - autoReview: false, - sandboxEnabled: false, - }, - ); - assert.deepEqual( - cursorRuntimeAgentPolicy({ - ...base, - runtimeMode: "auto-accept-edits", - }), - { - autoReview: false, - sandboxEnabled: true, - }, - ); - assert.deepEqual( - cursorRuntimeAgentPolicy({ - ...base, - runtimeMode: "approval-required", - }), - { - autoReview: true, - sandboxEnabled: true, - }, - ); - assert.deepEqual( - cursorRuntimeAgentPolicy({ - ...base, - runtimeMode: "full-access", - approvalPolicy: "never", - sandboxPolicy: { type: "readOnly" }, - }), - { - autoReview: false, - sandboxEnabled: true, - }, - ); - assert.deepEqual( - cursorRuntimeAgentPolicy({ - ...base, - runtimeMode: "approval-required", - approvalPolicy: "never", - sandboxPolicy: { type: "dangerFullAccess" }, - }), - { - autoReview: false, - sandboxEnabled: false, - }, - ); - }); - - it("loads the user's Cursor settings layers in every runtime mode", () => { - // The SDK loads no rules, skills, hooks, or MCP config from disk unless - // settingSources names them, so an omitted list silently drops AGENTS.md. - for (const runtimeMode of ["full-access", "auto-accept-edits", "approval-required"] as const) { - const options = makeCursorAgentOptions({ - modelSelection: { - instanceId: ProviderInstanceId.make("cursor"), - model: "composer-2.5", - }, - runtimePolicy: { runtimeMode, interactionMode: "default", cwd: "/workspace" }, - threadId: ThreadId.make("thread-cursor-setting-sources"), - }); - assert.deepEqual(options.local?.settingSources, [ - "project", - "user", - "team", - "mdm", - "plugins", - ]); - } - }); - - it("injects thread-scoped MCP credentials without logging them", () => { - const threadId = ThreadId.make("thread-cursor-mcp"); - McpProviderSession.setMcpProviderSession({ - environmentId: EnvironmentId.make("environment-cursor-mcp"), - threadId, - providerSessionId: "mcp-session-cursor", - providerInstanceId: ProviderInstanceId.make("cursor"), - endpoint: "http://127.0.0.1:43123/mcp", - authorizationHeader: "Bearer secret-cursor-mcp-token", - browserToolsAvailable: true, - }); - - try { - assert.deepEqual(cursorMcpServers(threadId), { - "t3-code": { - type: "http", - url: "http://127.0.0.1:43123/mcp", - headers: { - Authorization: "Bearer secret-cursor-mcp-token", - }, - }, - }); - - const options = makeCursorAgentOptions({ - apiKey: "secret-cursor-api-key", - modelSelection: { - instanceId: ProviderInstanceId.make("cursor"), - model: "composer-2.5", - }, - runtimePolicy: { - runtimeMode: "full-access", - interactionMode: "default", - cwd: "/workspace", - }, - threadId, - }); - assert.deepEqual(options.mcpServers, cursorMcpServers(threadId)); - - const logged = JSON.stringify(loggedCursorAgentOptions(options)); - assert.notInclude(logged, "secret-cursor-api-key"); - assert.notInclude(logged, "secret-cursor-mcp-token"); - } finally { - McpProviderSession.clearMcpProviderSession(threadId); - } - }); - - it("recognizes direct and SDK-wrapped abort failures as cancellation", () => { - assert.isTrue(isCursorCancellationError({ name: "AbortError" })); - assert.isTrue( - isCursorCancellationError({ - name: "ConnectError", - cause: { - name: "ConnectError", - cause: { name: "AbortError" }, - }, - }), - ); - assert.isFalse(isCursorCancellationError(new Error("request failed"))); - assert.isFalse(isCursorCancellationError(null)); - }); - - it("preserves failed nested read calls when Cursor omits their path", () => { - assert.deepEqual( - nestedToolCallFromEnvelope({ - toolCallId: "tool:failed-read", - readToolCall: { - args: {}, - result: { error: "File path was not provided." }, - }, - }), - { - callId: "tool:failed-read", - toolCall: { - type: "read", - args: { path: "" }, - result: { - status: "error", - error: "File path was not provided.", - }, - }, - }, - ); - }); -}); diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts index a3a72f7411380e5a5d64eac4fe3c0b4af778d706..3f2f6581f90d4af73b1471166b85ef07eef59115 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts @@ -1,6 +1,4 @@ import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; -import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; import type { InteractionUpdate, RunResult } from "@cursor/sdk"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { @@ -15,23 +13,21 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; -import * as ServerConfig from "../../config.ts"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; -import * as CursorAgentSdk from "./CursorAgentSdk.ts"; +import { CursorAdapterV2Driver } from "@t3tools/provider-cursor/server"; +import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; import { CURSOR_DEFAULT_INSTANCE_ID, CURSOR_DRIVER_KIND, - CursorAdapterV2Driver, cursorSdkModelSelection, makeCursorAgentOptions, -} from "./CursorAdapterV2.ts"; +} from "@t3tools/provider-cursor/testing"; import type { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; import type { RuntimePolicyV2Override } from "../RuntimePolicy.ts"; @@ -513,91 +509,6 @@ function layerCursorAgentSdkReplay( ); } -function makeReplayServerConfig( - scenario: string, -): Effect.Effect< - ServerConfig.ServerConfig["Service"], - PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* fs.makeTempDirectory({ - prefix: `t3-orchestration-v2-cursor-${scenario}-`, - }); - const stateDir = path.join(baseDir, "userdata"); - const logsDir = path.join(stateDir, "logs"); - const providerLogsDir = path.join(logsDir, "provider"); - const terminalLogsDir = path.join(logsDir, "terminals"); - const attachmentsDir = path.join(stateDir, "attachments"); - const environmentThemesDir = path.join(stateDir, "themes"); - const worktreesDir = path.join(baseDir, "worktrees"); - const providerStatusCacheDir = path.join(baseDir, "caches"); - for (const directory of [ - stateDir, - logsDir, - providerLogsDir, - terminalLogsDir, - attachmentsDir, - environmentThemesDir, - worktreesDir, - providerStatusCacheDir, - ]) { - yield* fs.makeDirectory(directory, { recursive: true }); - } - return { - logLevel: "Error", - traceMinLevel: "Info", - traceTimingEnabled: true, - traceBatchWindowMs: 200, - traceMaxBytes: 10 * 1024 * 1024, - traceMaxFiles: 10, - otelEnvironment: OtelEnvironment.none, - otlpTracesUrl: undefined, - otlpMetricsUrl: undefined, - otlpLogsUrl: undefined, - otlpTracesExport: DEFAULT_SIGNAL_EXPORT, - otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, - otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - mode: "web", - port: 0, - host: undefined, - cwd: process.cwd(), - baseDir, - staticDir: undefined, - devUrl: undefined, - devAllowedOrigins: [], - noBrowser: false, - startupPresentation: "browser", - tailscaleServeEnabled: false, - tailscaleServePort: 443, - desktopBootstrapToken: undefined, - autoBootstrapProjectFromCwd: false, - logWebSocketEvents: false, - stateDir, - dbPath: path.join(stateDir, "state.sqlite"), - keybindingsConfigPath: path.join(stateDir, "keybindings.json"), - settingsPath: path.join(stateDir, "settings.json"), - providerStatusCacheDir, - worktreesDir, - attachmentsDir, - browserArtifactsDir: path.join(stateDir, "browser-artifacts"), - environmentThemesDir, - logsDir, - serverLogPath: path.join(logsDir, "server.log"), - serverTracePath: path.join(logsDir, "server.trace.ndjson"), - providerLogsDir, - providerEventLogPath: path.join(providerLogsDir, "events.log"), - terminalLogsDir, - anonymousIdPath: path.join(stateDir, "anonymous-id"), - environmentIdPath: path.join(stateDir, "environment-id"), - serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"), - secretsDir: path.join(stateDir, "secrets"), - }; - }); -} - export function layer( transcript: CursorAgentSdkReplayTranscript, options?: { @@ -605,10 +516,6 @@ export function layer( readonly assertCompleteOnFinalize?: boolean; }, ) { - const layerServerConfig = Layer.effect( - ServerConfig.ServerConfig, - makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), - ).pipe(Layer.provide(NodeServices.layer)); // Skill discovery also scans user roots under HOME; an empty HOME keeps // replays from picking up the host's own skills. const layerHostEnvironment = Layer.effect( @@ -630,7 +537,7 @@ export function layer( Layer.provide( Layer.mergeAll( layerCursorAgentSdkReplay(transcript, options), - layerServerConfig, + layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), layerHostEnvironment, NodeServices.layer, IdAllocator.layer, diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts deleted file mode 100644 index 273564e786c4949dd90b0814471accf62ea601d7..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ /dev/null @@ -1,2685 +0,0 @@ -import type { - AgentMessage, - AgentOptions, - InteractionUpdate, - McpServerConfig, - RunResult, - SDKUserMessage, - SettingSource, - ToolCall, -} from "@cursor/sdk"; -import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; -import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { - CursorSettings, - isOrchestrationV2WorkActive, - defaultInstanceIdForDriver, - type ChatAttachment, - type ModelSelection, - type OrchestrationV2ConversationMessage, - type OrchestrationV2ExecutionNode, - type OrchestrationV2PlanArtifact, - type OrchestrationV2PlanStep, - type OrchestrationV2ProviderCapabilities, - type OrchestrationV2ProviderFailure, - type OrchestrationV2ProviderSession, - type OrchestrationV2ProviderThread, - type OrchestrationV2ProviderTurn, - type OrchestrationV2Subagent, - type OrchestrationV2TurnItem, - type ProviderInstanceId, - type ThreadId, -} from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Queue from "effect/Queue"; -import * as Ref from "effect/Ref"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; - -import { resolveAttachmentPath } from "../../attachmentStore.ts"; -import * as ServerConfig from "../../config.ts"; -import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; -import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import { CursorTransportFailure } from "../../provider/acp/CursorTransportFailure.ts"; -import { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; -import { - discoverCursorSkills, - hasCursorSkillMention, - rewriteCursorSkillMentions, -} from "../../provider/Drivers/CursorSkills.ts"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions"; -import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; -import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; -import { - isProviderNativeImageAttachment, - providerMessageTextWithAttachmentPaths, -} from "@t3tools/provider-core/server/attachmentPrompt"; -import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; -import { - ProviderAdapterDriverCreateError, - type ProviderAdapterDriver, - type ProviderAdapterDriverCreateInput, -} from "@t3tools/provider-core/server/adapterDriver"; -import { - makeSubagentChildThread, - makeSubagentConversationArtifacts, - subagentThreadTitle, -} from "@t3tools/provider-core/server/subagentProjection"; -import * as CursorAgentSdk from "./CursorAgentSdk.ts"; -export { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; - -export const CURSOR_DRIVER_KIND = CursorAgentSdk.CURSOR_PROVIDER; -export const CURSOR_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CURSOR_DRIVER_KIND); -const DEFAULT_CURSOR_SETTINGS = Schema.decodeSync(CursorSettings)({}); - -export const CursorProviderCapabilitiesV2 = { - sessions: { - supportsMultipleProviderThreadsPerSession: false, - supportsModelSwitchInSession: true, - supportsProviderSwitchingViaHandoff: true, - supportsRuntimeModeSwitchInSession: false, - pendingRequestsSurviveRestart: false, - }, - threads: { - canCreateEmptyThread: true, - canReadThreadSnapshot: true, - canRollbackThread: false, - canForkThread: false, - canForkFromTurn: false, - canForkFromSubagentThread: false, - exposesNativeThreadId: true, - }, - turns: { - exposesNativeTurnId: true, - emitsTurnStarted: true, - emitsTurnCompleted: true, - supportsInterrupt: true, - supportsActiveSteering: false, - supportsSteeringByInterruptRestart: true, - supportsQueuedMessages: true, - terminalStatusQuality: "strong", - }, - streaming: { - streamsAssistantText: true, - streamsReasoning: true, - streamsToolOutput: true, - streamsPlanText: true, - emitsMessageCompleted: true, - }, - tools: { - exposesToolItemIds: true, - emitsToolStarted: true, - emitsToolCompleted: true, - emitsToolOutput: true, - supportsMcpTools: true, - supportsDynamicToolCallbacks: false, - }, - approvals: { - supportsCommandApproval: false, - supportsFileReadApproval: false, - supportsFileChangeApproval: false, - supportsApplyPatchApproval: false, - approvalsHaveNativeRequestIds: false, - approvalCallbacksAreLiveOnly: false, - approvalsCanOriginateFromSubagents: false, - }, - planning: { - emitsPlanUpdated: true, - emitsTodoList: true, - emitsProposedPlan: true, - supportsStructuredQuestions: false, - planDeltasHaveItemIds: true, - }, - subagents: { - supportsSubagents: true, - exposesSubagentThreadIds: false, - emitsSubagentLifecycle: true, - canWaitForSubagents: true, - canCloseSubagents: false, - canForkSubagentThread: false, - }, - context: { - acceptsSystemContext: false, - acceptsDeveloperContext: false, - acceptsSyntheticUserContext: true, - canGenerateSummaries: true, - canConsumeHandoffSummaries: true, - supportsDeltaHandoff: true, - supportsFullThreadHandoff: true, - maxRecommendedHandoffChars: null, - }, - checkpointing: { - appCanCheckpointFilesystem: true, - supportsNestedCheckpointScopes: true, - providerCanRollbackConversation: false, - providerRollbackReturnsSnapshot: false, - providerCanReadConversationSnapshot: true, - }, - identity: { - nativeThreadIds: "strong", - nativeTurnIds: "strong", - nativeItemIds: "weak", - nativeRequestIds: "none", - }, - runtimePolicy: { - enforcement: "native", - }, -} satisfies OrchestrationV2ProviderCapabilities; - -export interface CursorRuntimeAgentPolicy { - readonly autoReview: boolean; - readonly sandboxEnabled: boolean; -} - -export function cursorRuntimeAgentPolicy( - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, -): CursorRuntimeAgentPolicy { - const sandboxPolicyType = - typeof runtimePolicy.sandboxPolicy === "object" && - runtimePolicy.sandboxPolicy !== null && - "type" in runtimePolicy.sandboxPolicy && - typeof runtimePolicy.sandboxPolicy.type === "string" - ? runtimePolicy.sandboxPolicy.type - : undefined; - return { - autoReview: - runtimePolicy.approvalPolicy === undefined - ? runtimePolicy.runtimeMode === "approval-required" - : runtimePolicy.approvalPolicy !== "never", - sandboxEnabled: - runtimePolicy.sandboxPolicy === undefined - ? runtimePolicy.runtimeMode !== "full-access" - : sandboxPolicyType !== "dangerFullAccess", - }; -} - -export function cursorMcpServers(threadId: ThreadId): Record | undefined { - const session = McpProviderSession.readMcpProviderSession(threadId); - if (session === undefined) { - return undefined; - } - return { - "t3-code": { - type: "http", - url: session.endpoint, - headers: { - Authorization: session.authorizationHeader, - }, - }, - }; -} - -function providerSession(input: { - readonly providerSessionId: OrchestrationV2ProviderSession["id"]; - readonly providerInstanceId: ProviderInstanceId; - readonly cwd: string | null; - readonly model: string; - readonly now: DateTime.Utc; -}): OrchestrationV2ProviderSession { - return { - id: input.providerSessionId, - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerInstanceId: input.providerInstanceId, - status: "ready", - cwd: input.cwd ?? process.cwd(), - model: input.model, - capabilities: CursorProviderCapabilitiesV2, - createdAt: input.now, - updatedAt: input.now, - lastError: null, - }; -} - -function makeProviderThread(input: { - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly providerInstanceId: ProviderInstanceId; - readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; - readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; - readonly nativeThreadId: string; - readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; - readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; - readonly now: DateTime.Utc; -}): OrchestrationV2ProviderThread { - return { - id: input.idAllocator.derive.providerThread({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeThreadId: input.nativeThreadId, - }), - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerInstanceId: input.providerInstanceId, - providerSessionId: input.providerSessionId, - appThreadId: input.appThreadId, - ownerNodeId: input.ownerNodeId ?? null, - nativeThreadRef: { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: input.nativeThreadId, - strength: "strong", - }, - nativeConversationHeadRef: null, - status: "idle", - firstRunOrdinal: null, - lastRunOrdinal: null, - handoffIds: [], - forkedFrom: input.forkedFrom ?? null, - createdAt: input.now, - updatedAt: input.now, - }; -} - -function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { - const id = providerThread.nativeThreadRef?.nativeId; - if (id === null || id === undefined) { - throw new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - detail: `Provider thread ${providerThread.id} is missing its Cursor agent id.`, - }); - } - return id; -} - -/** - * Every Cursor settings layer the Cursor CLI loads: project and user rules, - * skills, hooks, and MCP servers, team and MDM admin policy, and account - * plugins. The SDK loads none of them when `settingSources` is omitted. - * Sandbox policy files are read either way, and hooks can only deny or ask - * (which local SDK runs reject), so these layers do not loosen the sandbox or - * approval mode T3 sets. - */ -const CURSOR_AGENT_SETTING_SOURCES = [ - "project", - "user", - "team", - "mdm", - "plugins", -] as const satisfies ReadonlyArray; - -export function makeCursorAgentOptions(input: { - readonly apiKey?: string; - readonly modelSelection: ModelSelection; - readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; - readonly threadId: ThreadId; -}): AgentOptions { - const policy = cursorRuntimeAgentPolicy(input.runtimePolicy); - const mcpServers = cursorMcpServers(input.threadId); - return { - model: cursorSdkModelSelection(input.modelSelection), - name: `T3 Code ${input.threadId}`, - mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", - ...(input.apiKey === undefined ? {} : { apiKey: input.apiKey }), - local: { - ...(input.runtimePolicy.cwd === null ? {} : { cwd: input.runtimePolicy.cwd }), - autoReview: policy.autoReview, - settingSources: [...CURSOR_AGENT_SETTING_SOURCES], - sandboxOptions: { - enabled: policy.sandboxEnabled, - }, - enableAgentRetries: true, - }, - ...(mcpServers === undefined ? {} : { mcpServers }), - }; -} - -function stableJson(value: unknown): string { - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -function unknownRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) - ? (value as Record) - : undefined; -} - -function nestedString(value: unknown, keys: ReadonlyArray): string | undefined { - let current: unknown = value; - for (const key of keys) { - current = unknownRecord(current)?.[key]; - } - return typeof current === "string" ? current : undefined; -} - -function cursorToolFailed(toolCall: ToolCall): boolean { - if (toolCall.result?.status === "error") { - return true; - } - return toolCall.type === "mcp" && toolCall.result?.status === "success" - ? toolCall.result.value.isError - : false; -} - -function cursorToolOutput(toolCall: ToolCall): unknown { - const result = toolCall.result; - if (result === undefined) { - return undefined; - } - return result.status === "success" ? result.value : result.error; -} - -function cursorToolOutputText(toolCall: ToolCall): string { - if (toolCall.type === "shell" && toolCall.result?.status === "success") { - return [toolCall.result.value.stdout, toolCall.result.value.stderr] - .filter((part) => part.length > 0) - .join("\n"); - } - if (toolCall.type === "mcp" && toolCall.result?.status === "success") { - return toolCall.result.value.content - .flatMap((part) => (part.text === undefined ? [] : [part.text.text])) - .join("\n"); - } - const output = cursorToolOutput(toolCall); - if (output === undefined) { - return ""; - } - return typeof output === "string" ? output : stableJson(output); -} - -function cursorToolName(toolCall: ToolCall): string { - if (toolCall.type !== "mcp") { - return toolCall.type; - } - const provider = toolCall.args.providerIdentifier ?? "mcp"; - const tool = toolCall.args.toolName ?? "unknown"; - return `mcp__${provider}__${tool}`; -} - -function cursorToolFileName(toolCall: ToolCall): string { - switch (toolCall.type) { - case "write": - case "delete": - case "read": - case "edit": - case "ls": - return toolCall.args.path; - case "generateImage": - return toolCall.args.filePath ?? "generated-image"; - default: - return cursorToolName(toolCall); - } -} - -function cursorToolSearchPattern(toolCall: ToolCall): string | undefined { - switch (toolCall.type) { - case "glob": - return toolCall.args.globPattern; - case "grep": - return toolCall.args.pattern; - case "semSearch": - return toolCall.args.query; - case "ls": - return toolCall.args.path; - case "readLints": - return toolCall.args.paths.join(", "); - default: - return undefined; - } -} - -type CursorLsDirectoryNode = Extract< - Extract["result"], - { readonly status: "success" } ->["value"]["directoryTreeRoot"]; - -function cursorLsSearchResults( - node: CursorLsDirectoryNode, - path: Path.Path, -): ReadonlyArray<{ readonly fileName: string }> { - return [ - ...node.childrenFiles.map((file) => ({ fileName: path.join(node.absPath, file.name) })), - ...node.childrenDirs.flatMap((child) => cursorLsSearchResults(child, path)), - ]; -} - -function cursorToolSearchResults( - toolCall: ToolCall, - path: Path.Path, -): ReadonlyArray<{ - readonly fileName: string; - readonly line?: number; - readonly column?: number; - readonly preview?: string; -}> { - if (toolCall.result?.status !== "success") { - return []; - } - switch (toolCall.type) { - case "glob": - return toolCall.result.value.files.map((fileName) => ({ fileName })); - case "grep": - return Object.values(toolCall.result.value.workspaceResults ?? {}).flatMap((result) => { - if (result.type === "files") { - return result.output.files.map((fileName) => ({ fileName })); - } - if (result.type === "count") { - return result.output.counts.map((entry) => ({ - fileName: entry.file, - preview: `${entry.count} matches`, - })); - } - return result.output.matches.map((entry) => ({ - fileName: entry.file, - ...(entry.lineNumber === undefined ? {} : { line: entry.lineNumber }), - preview: entry.line, - })); - }); - case "ls": - return cursorLsSearchResults(toolCall.result.value.directoryTreeRoot, path); - case "readLints": - return toolCall.result.value.fileDiagnostics.flatMap((file) => - file.diagnostics.map((diagnostic) => { - const line = diagnostic.range?.start?.line; - const character = diagnostic.range?.start?.character; - const hasLine = typeof line === "number" && Number.isInteger(line) && line >= 0; - return { - fileName: file.path, - ...(hasLine ? { line: line + 1 } : {}), - ...(hasLine && - typeof character === "number" && - Number.isInteger(character) && - character >= 0 - ? { column: character + 1 } - : {}), - preview: diagnostic.message, - }; - }), - ); - case "semSearch": - return [ - { - fileName: "semantic-search", - preview: toolCall.result.value.results, - }, - ]; - default: - return []; - } -} - -function cursorTodoSteps( - toolCall: Extract, -): ReadonlyArray { - const todos = - toolCall.result?.status === "success" ? toolCall.result.value.todos : toolCall.args.todos; - return todos - .filter((todo) => todo.status !== "cancelled" && todo.content.trim().length > 0) - .map((todo, index) => ({ - id: `todo-${index + 1}`, - text: todo.content, - status: - todo.status === "inProgress" - ? "running" - : todo.status === "completed" - ? "completed" - : "pending", - })); -} - -function assistantTextsFromConversationSteps(steps: ReadonlyArray): ReadonlyArray { - return steps.flatMap((step) => { - const record = unknownRecord(step); - if (record?.type === "assistantMessage") { - const text = nestedString(record, ["message", "text"]); - return text === undefined || text.length === 0 ? [] : [text]; - } - const text = nestedString(record, ["assistantMessage", "text"]); - return text === undefined || text.length === 0 ? [] : [text]; - }); -} - -function nestedToolResult>( - rawResult: unknown, - mapSuccess: (success: Record) => Value = (success) => success as Value, -): - | { readonly status: "success"; readonly value: Value } - | { readonly status: "error"; readonly error: unknown } - | undefined { - const result = unknownRecord(rawResult); - if (result === undefined) { - return undefined; - } - const success = unknownRecord(result.success); - if (success !== undefined) { - return { - status: "success", - value: mapSuccess(success), - }; - } - return { - status: "error", - error: result.error ?? result.permissionDenied ?? result, - }; -} - -function nestedGrepWorkspaceResults(success: Record): Record { - const workspaces = unknownRecord(success.workspaceResults); - if (workspaces === undefined) { - return {}; - } - return Object.fromEntries( - Object.entries(workspaces).map(([workspace, rawWorkspaceResult]) => { - const workspaceResult = unknownRecord(rawWorkspaceResult); - const content = unknownRecord(workspaceResult?.content); - const rawMatches = Array.isArray(content?.matches) ? content.matches : []; - const matches = rawMatches.flatMap((rawFileMatch) => { - const fileMatch = unknownRecord(rawFileMatch); - const file = typeof fileMatch?.file === "string" ? fileMatch.file : ""; - const lineMatches = Array.isArray(fileMatch?.matches) ? fileMatch.matches : []; - return lineMatches.flatMap((rawLineMatch) => { - const lineMatch = unknownRecord(rawLineMatch); - if (lineMatch === undefined) { - return []; - } - const line = typeof lineMatch.content === "string" ? lineMatch.content : undefined; - if (file.length === 0 || line === undefined) { - return []; - } - return [ - { - file, - line, - ...(typeof lineMatch.lineNumber === "number" - ? { lineNumber: lineMatch.lineNumber } - : {}), - }, - ]; - }); - }); - return [ - workspace, - { - type: "content", - output: { - matches, - totalMatches: - typeof content?.totalMatchedLines === "number" - ? content.totalMatchedLines - : matches.length, - }, - }, - ]; - }), - ); -} - -export function nestedToolCallFromEnvelope( - envelope: Record, -): { readonly callId: string; readonly toolCall: ToolCall } | undefined { - const callId = typeof envelope.toolCallId === "string" ? envelope.toolCallId : undefined; - const wrapperEntry = Object.entries(envelope).find(([key]) => key.endsWith("ToolCall")); - if (wrapperEntry === undefined) { - return undefined; - } - const [wrapperName, rawCall] = wrapperEntry; - const call = unknownRecord(rawCall); - if (call === undefined) { - return undefined; - } - const args = unknownRecord(call.args) ?? {}; - const fallbackCallId = `nested-${wrapperName}`; - const nestedCallId = callId ?? fallbackCallId; - - switch (wrapperName) { - case "readToolCall": { - const success = unknownRecord(unknownRecord(call.result)?.success); - const path = - typeof args.path === "string" - ? args.path - : typeof success?.path === "string" - ? success.path - : ""; - return { - callId: nestedCallId, - toolCall: { - type: "read", - args: { path }, - result: nestedToolResult(call.result, (value) => ({ - content: typeof value.content === "string" ? value.content : "", - totalLines: typeof value.totalLines === "number" ? value.totalLines : 0, - fileSize: typeof value.fileSize === "number" ? value.fileSize : 0, - })), - } as unknown as ToolCall, - }; - } - case "globToolCall": - return { - callId: nestedCallId, - toolCall: { - type: "glob", - args: { - globPattern: typeof args.globPattern === "string" ? args.globPattern : "**/*", - ...(typeof args.targetDirectory === "string" - ? { targetDirectory: args.targetDirectory } - : {}), - }, - result: nestedToolResult(call.result, (value) => ({ - files: Array.isArray(value.files) - ? value.files.filter((file): file is string => typeof file === "string") - : [], - totalFiles: typeof value.totalFiles === "number" ? value.totalFiles : 0, - clientTruncated: false, - ripgrepTruncated: false, - })), - } as unknown as ToolCall, - }; - case "grepToolCall": - return { - callId: nestedCallId, - toolCall: { - type: "grep", - args: { - pattern: typeof args.pattern === "string" ? args.pattern : "", - ...(typeof args.path === "string" ? { path: args.path } : {}), - ...(typeof args.glob === "string" ? { glob: args.glob } : {}), - ...(typeof args.outputMode === "string" ? { outputMode: args.outputMode } : {}), - ...(typeof args.caseInsensitive === "boolean" - ? { caseInsensitive: args.caseInsensitive } - : {}), - ...(typeof args.offset === "number" ? { offset: args.offset } : {}), - ...(typeof args.multiline === "boolean" ? { multiline: args.multiline } : {}), - }, - result: nestedToolResult(call.result, (value) => ({ - workspaceResults: nestedGrepWorkspaceResults(value), - })), - } as unknown as ToolCall, - }; - case "shellToolCall": { - const rawResult = unknownRecord(call.result); - const permissionDenied = unknownRecord(rawResult?.permissionDenied); - const command = - typeof args.command === "string" - ? args.command - : typeof permissionDenied?.command === "string" - ? permissionDenied.command - : ""; - return { - callId: nestedCallId, - toolCall: { - type: "shell", - args: { command }, - result: nestedToolResult(call.result, (value) => ({ - stdout: typeof value.stdout === "string" ? value.stdout : "", - stderr: typeof value.stderr === "string" ? value.stderr : "", - exitCode: typeof value.exitCode === "number" ? value.exitCode : 0, - signal: typeof value.signal === "string" ? value.signal : "", - executionTime: typeof value.executionTime === "number" ? value.executionTime : 0, - })), - } as unknown as ToolCall, - }; - } - default: { - const type = wrapperName.slice(0, -"ToolCall".length); - return { - callId: nestedCallId, - toolCall: { - type, - args, - result: nestedToolResult(call.result), - } as unknown as ToolCall, - }; - } - } -} - -function toolCallsFromConversationSteps( - steps: ReadonlyArray, -): ReadonlyArray<{ readonly callId: string; readonly toolCall: ToolCall }> { - return steps.flatMap((step) => { - const record = unknownRecord(step); - if (record?.type === "toolCall") { - const message = unknownRecord(record.message); - return typeof message?.type === "string" - ? [ - { - callId: typeof record.callId === "string" ? record.callId : `nested-${message.type}`, - toolCall: message as ToolCall, - }, - ] - : []; - } - const envelope = unknownRecord(record?.toolCall); - const nested = envelope === undefined ? undefined : nestedToolCallFromEnvelope(envelope); - return nested === undefined ? [] : [nested]; - }); -} - -function textFromAgentMessage(message: AgentMessage): string { - const value = message.message; - if (typeof value === "string") { - return value; - } - const direct = - nestedString(value, ["text"]) ?? - nestedString(value, ["message", "text"]) ?? - nestedString(value, ["userMessage", "text"]) ?? - nestedString(value, ["assistantMessage", "text"]); - if (direct !== undefined) { - return direct; - } - const content = unknownRecord(value)?.content; - if (!Array.isArray(content)) { - return ""; - } - return content - .flatMap((part) => { - const text = unknownRecord(part)?.text; - return typeof text === "string" ? [text] : []; - }) - .join(""); -} - -interface CursorProjectionTarget { - readonly threadId: ThreadId; - readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; - readonly rootNodeId: OrchestrationV2ExecutionNode["rootNodeId"]; - readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; - readonly providerThreadId: OrchestrationV2ProviderThread["id"] | null; - readonly providerTurnId: OrchestrationV2ProviderTurn["id"] | null; -} - -interface ActiveCursorToolCall { - readonly callId: string; - toolCall: ToolCall; - readonly target: CursorProjectionTarget; - readonly ordinal: number; - readonly startedAt: DateTime.Utc; - streamedOutput: string; -} - -interface ActiveCursorSubagent { - task: OrchestrationV2Subagent; - toolCall: Extract; - readonly callId: string; - readonly childThreadId: ThreadId; - readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; - readonly turnItemId: OrchestrationV2TurnItem["id"]; - readonly turnItemOrdinal: number; - nextChildOrdinal: number; - resultProjected: boolean; -} - -interface ActiveCursorTextSegment { - readonly nativeItemId: string; - readonly startedAt: DateTime.Utc; - text: string; -} - -interface ActiveCursorTextStream { - current: ActiveCursorTextSegment | null; - nextSegment: number; -} - -interface ActiveCursorTurn { - readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; - readonly run: CursorAgentSdk.CursorAgentSdkRun; - readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; - readonly startedAt: DateTime.Utc; - readonly completed: Deferred.Deferred; - // Item ordinals allocated in this turn. No later turn looks items up here. - readonly itemOrdinals: Map; - readonly tools: Map; - readonly subagents: Map; - readonly assistant: ActiveCursorTextStream; - readonly assistantReply: CursorTransportFailure; - readonly reasoning: ActiveCursorTextStream; - interrupted: boolean; - finalized: boolean; -} - -interface CursorLiveAgent { - readonly nativeThreadId: string; - readonly session: CursorAgentSdk.CursorAgentSdkSession; -} - -export interface CursorAdapterV2Options { - readonly instanceId: ProviderInstanceId; - readonly settings: CursorSettings; - readonly environment: NodeJS.ProcessEnv; - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly idAllocator: IdAllocator.IdAllocatorV2Shape; - readonly runner: CursorAgentSdk.CursorAgentSdkRunnerShape; - readonly serverConfig: ServerConfig.ServerConfig["Service"]; -} - -export function makeCursorAdapterV2( - adapterOptions: CursorAdapterV2Options, -): ProviderAdapter.ProviderAdapterV2Shape { - const { fileSystem, path, idAllocator, runner, serverConfig } = adapterOptions; - const apiKey = adapterOptions.environment.CURSOR_API_KEY?.trim() || undefined; - - return ProviderAdapter.ProviderAdapterV2.of({ - instanceId: adapterOptions.instanceId, - driver: CursorAgentSdk.CURSOR_PROVIDER, - getCapabilities: () => Effect.succeed(CursorProviderCapabilitiesV2), - planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), - openSession: Effect.fn("CursorAdapterV2.openSession")( - function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { - const sessionScope = yield* Effect.scope; - const createdAt = yield* DateTime.now; - const session = providerSession({ - providerSessionId: input.providerSessionId, - providerInstanceId: adapterOptions.instanceId, - cwd: input.runtimePolicy.cwd, - model: input.modelSelection.model, - now: createdAt, - }); - const events = yield* Queue.unbounded(); - const liveAgent = yield* Ref.make(null); - const activeTurn = yield* Ref.make(null); - const planIds = yield* Ref.make(new Map()); - - const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => - Queue.offer(events, event).pipe(Effect.asVoid); - - const resolveItemOrdinal = (context: ActiveCursorTurn, nativeItemId: string) => - Effect.sync(() => { - const existing = context.itemOrdinals.get(nativeItemId); - if (existing !== undefined) { - return existing; - } - const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1; - context.itemOrdinals.set(nativeItemId, ordinal); - return ordinal; - }); - - const resolvePlanId = Effect.fnUntraced(function* ( - context: ActiveCursorTurn, - nativeItemId: string, - ) { - const existing = (yield* Ref.get(planIds)).get(nativeItemId); - if (existing !== undefined) { - return existing; - } - const planId = yield* idAllocator.allocate.plan({ - threadId: context.input.threadId, - runId: context.input.runId, - driver: CursorAgentSdk.CURSOR_PROVIDER, - }); - yield* Ref.update(planIds, (current) => { - const updated = new Map(current); - updated.set(nativeItemId, planId); - return updated; - }); - return planId; - }); - - const parentTarget = (context: ActiveCursorTurn): CursorProjectionTarget => ({ - threadId: context.input.threadId, - runId: context.input.runId, - rootNodeId: context.input.rootNodeId, - parentNodeId: context.input.rootNodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - }); - - const emitAssistant = Effect.fnUntraced(function* ( - context: ActiveCursorTurn, - completed: boolean, - ) { - const segment = context.assistant.current; - if (segment === null || segment.text.length === 0) { - return; - } - const now = yield* DateTime.now; - const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: segment.nativeItemId, - }); - const messageId = idAllocator.derive.messageFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: segment.nativeItemId, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: segment.nativeItemId, - }); - const nativeItemRef = { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: segment.nativeItemId, - strength: "weak" as const, - }; - yield* emitProviderEvent({ - type: "node.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - node: { - id: nodeId, - threadId: context.input.threadId, - runId: context.input.runId, - parentNodeId: context.input.rootNodeId, - rootNodeId: context.input.rootNodeId, - kind: "assistant_message", - status: completed ? "completed" : "running", - countsForRun: false, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: segment.startedAt, - completedAt: completed ? now : null, - }, - }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - message: { - createdBy: "agent", - creationSource: "provider", - id: messageId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - role: "assistant", - text: segment.text, - attachments: [], - streaming: !completed, - createdAt: segment.startedAt, - updatedAt: now, - }, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - turnItem: { - id: turnItemId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: completed ? "completed" : "running", - title: null, - startedAt: segment.startedAt, - completedAt: completed ? now : null, - updatedAt: now, - type: "assistant_message", - messageId, - text: segment.text, - streaming: !completed, - }, - }); - }); - - const emitReasoning = Effect.fnUntraced(function* ( - context: ActiveCursorTurn, - completed: boolean, - ) { - const segment = context.reasoning.current; - if (segment === null || segment.text.length === 0) { - return; - } - const now = yield* DateTime.now; - const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: segment.nativeItemId, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: segment.nativeItemId, - }); - const nativeItemRef = { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: segment.nativeItemId, - strength: "weak" as const, - }; - yield* emitProviderEvent({ - type: "node.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - node: { - id: nodeId, - threadId: context.input.threadId, - runId: context.input.runId, - parentNodeId: context.input.rootNodeId, - rootNodeId: context.input.rootNodeId, - kind: "reasoning", - status: completed ? "completed" : "running", - countsForRun: false, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: segment.startedAt, - completedAt: completed ? now : null, - }, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - turnItem: { - id: turnItemId, - threadId: context.input.threadId, - runId: context.input.runId, - nodeId, - providerThreadId: context.input.providerThread.id, - providerTurnId: context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status: completed ? "completed" : "running", - title: null, - startedAt: segment.startedAt, - completedAt: completed ? now : null, - updatedAt: now, - type: "reasoning", - text: segment.text, - streaming: !completed, - }, - }); - }); - - const appendTextSegment = Effect.fnUntraced(function* (input: { - readonly context: ActiveCursorTurn; - readonly stream: ActiveCursorTextStream; - readonly kind: "assistant" | "reasoning"; - readonly text: string; - }) { - if (input.stream.current === null) { - input.stream.nextSegment += 1; - input.stream.current = { - nativeItemId: `${input.kind}:${input.context.run.runId}:${input.stream.nextSegment}`, - startedAt: yield* DateTime.now, - text: "", - }; - } - input.stream.current.text += input.text; - }); - - const completeAssistant = Effect.fnUntraced(function* (context: ActiveCursorTurn) { - if (context.assistant.current === null) { - return; - } - yield* emitAssistant(context, true); - context.assistant.current = null; - }); - - const completeReasoning = Effect.fnUntraced(function* (context: ActiveCursorTurn) { - if (context.reasoning.current === null) { - return; - } - yield* emitReasoning(context, true); - context.reasoning.current = null; - }); - - const emitToolArtifacts = Effect.fnUntraced(function* (input: { - readonly active: ActiveCursorToolCall; - readonly completed: boolean; - /** Terminal status for a tool the turn ended before Cursor completed it. */ - readonly unfinishedStatus?: "interrupted" | "failed" | "cancelled"; - }) { - const { active } = input; - const toolCall = active.toolCall; - const now = yield* DateTime.now; - const failed = input.completed && cursorToolFailed(toolCall); - const status = !input.completed - ? "running" - : (input.unfinishedStatus ?? (failed ? "failed" : "completed")); - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: active.callId, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: active.callId, - }); - const nativeItemRef = { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: active.callId, - strength: "strong" as const, - }; - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId: active.target.threadId, - runId: active.target.runId, - parentNodeId: active.target.parentNodeId, - rootNodeId: active.target.rootNodeId, - kind: "tool_call", - status, - countsForRun: false, - providerThreadId: active.target.providerThreadId, - providerTurnId: active.target.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: active.startedAt, - completedAt: input.completed ? now : null, - }; - const base = { - id: turnItemId, - threadId: active.target.threadId, - runId: active.target.runId, - nodeId, - providerThreadId: active.target.providerThreadId, - providerTurnId: active.target.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal: active.ordinal, - status, - title: null, - startedAt: active.startedAt, - completedAt: input.completed ? now : null, - updatedAt: now, - } satisfies Pick< - OrchestrationV2TurnItem, - | "id" - | "threadId" - | "runId" - | "nodeId" - | "providerThreadId" - | "providerTurnId" - | "nativeItemRef" - | "parentItemId" - | "ordinal" - | "status" - | "title" - | "startedAt" - | "completedAt" - | "updatedAt" - >; - const outputText = cursorToolOutputText(toolCall) || active.streamedOutput; - let turnItem: OrchestrationV2TurnItem; - switch (toolCall.type) { - case "shell": - turnItem = { - ...base, - type: "command_execution", - input: toolCall.args.command, - ...(outputText.length === 0 ? {} : { output: outputText }), - ...(toolCall.result?.status === "success" - ? { exitCode: toolCall.result.value.exitCode } - : {}), - }; - break; - case "write": - case "delete": - case "edit": - case "generateImage": - turnItem = { - ...base, - type: "file_change", - fileName: cursorToolFileName(toolCall), - ...(toolCall.type === "edit" && - toolCall.result?.status === "success" && - toolCall.result.value.linesAdded !== undefined - ? { additions: toolCall.result.value.linesAdded } - : {}), - ...(toolCall.type === "edit" && - toolCall.result?.status === "success" && - toolCall.result.value.linesRemoved !== undefined - ? { deletions: toolCall.result.value.linesRemoved } - : {}), - ...(toolCall.type === "edit" && - toolCall.result?.status === "success" && - toolCall.result.value.diffString !== undefined - ? { diffStr: toolCall.result.value.diffString } - : // A failed change keeps its error where the diff would be. - toolCall.result?.status === "error" && outputText.trim().length > 0 - ? { diffStr: outputText } - : {}), - ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}), - }; - break; - case "read": - turnItem = { - ...base, - title: formatReadToolLabel(toolCall.args.path), - type: "dynamic_tool", - toolName: "Read", - input: toolCall.args, - ...(cursorToolOutput(toolCall) === undefined - ? {} - : { output: cursorToolOutput(toolCall) }), - }; - break; - case "glob": - case "grep": - case "ls": - case "readLints": - case "semSearch": { - const pattern = cursorToolSearchPattern(toolCall); - const searchPath = - toolCall.type === "grep" - ? toolCall.args.path - : toolCall.type === "glob" - ? toolCall.args.targetDirectory - : toolCall.type === "semSearch" - ? toolCall.args.targetDirectories?.join(", ") - : pattern; - // A failed search keeps its error as one row under the searched path. - const results = - toolCall.result?.status === "error" && outputText.trim().length > 0 - ? [{ fileName: searchPath?.trim() || ".", preview: outputText }] - : cursorToolSearchResults(toolCall, path); - turnItem = { - ...base, - title: - formatSearchToolLabel({ - input: toolCall.args, - ...(pattern === undefined ? {} : { pattern }), - }) ?? null, - type: "file_search", - ...(pattern === undefined ? {} : { pattern }), - ...(results.length === 0 ? {} : { results: [...results] }), - }; - break; - } - default: - turnItem = { - ...base, - type: "dynamic_tool", - ...(toolCall.type === "mcp" - ? mcpToolPresentation({ - serverName: toolCall.args.providerIdentifier, - toolName: toolCall.args.toolName, - }) - : {}), - toolName: cursorToolName(toolCall), - input: toolCall.args, - ...(cursorToolOutput(toolCall) === undefined - ? {} - : { output: cursorToolOutput(toolCall) }), - }; - } - yield* emitProviderEvent({ - type: "node.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - node, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - turnItem, - }); - }); - - const ensureToolStarted = Effect.fnUntraced(function* ( - context: ActiveCursorTurn, - callId: string, - toolCall: ToolCall, - ) { - const existing = context.tools.get(callId); - if (existing !== undefined) { - existing.toolCall = toolCall; - return existing; - } - const startedAt = yield* DateTime.now; - const active: ActiveCursorToolCall = { - callId, - toolCall, - target: parentTarget(context), - ordinal: yield* resolveItemOrdinal(context, callId), - startedAt, - streamedOutput: "", - }; - context.tools.set(callId, active); - yield* emitToolArtifacts({ active, completed: false }); - return active; - }); - - const emitPlanArtifacts = Effect.fnUntraced(function* (input: { - readonly context: ActiveCursorTurn; - readonly callId: string; - readonly markdown: string; - readonly completed: boolean; - readonly failed: boolean; - }) { - const nativeItemId = `plan:${input.callId}`; - const now = yield* DateTime.now; - const planId = yield* resolvePlanId(input.context, nativeItemId); - const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId); - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId, - }); - const status = input.failed ? "failed" : input.completed ? "completed" : "running"; - const nativeItemRef = { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: input.callId, - strength: "strong" as const, - }; - const plan: OrchestrationV2PlanArtifact = { - id: planId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, - nodeId, - kind: "proposed_plan", - status: input.completed ? "active" : "draft", - markdown: input.markdown, - }; - yield* emitProviderEvent({ - type: "node.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - node: { - id: nodeId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, - parentNodeId: input.context.input.rootNodeId, - rootNodeId: input.context.input.rootNodeId, - kind: "plan", - status, - countsForRun: false, - providerThreadId: input.context.input.providerThread.id, - providerTurnId: input.context.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: input.context.startedAt, - completedAt: input.completed ? now : null, - }, - }); - yield* emitProviderEvent({ - type: "plan.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - plan, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - turnItem: { - id: turnItemId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, - nodeId, - providerThreadId: input.context.input.providerThread.id, - providerTurnId: input.context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status, - title: null, - startedAt: input.context.startedAt, - completedAt: input.completed ? now : null, - updatedAt: now, - type: "proposed_plan", - planId, - markdown: input.markdown, - streaming: !input.completed, - }, - }); - }); - - const emitTodoArtifacts = Effect.fnUntraced(function* (input: { - readonly context: ActiveCursorTurn; - readonly callId: string; - readonly toolCall: Extract; - readonly completed: boolean; - readonly failed: boolean; - }) { - const nativeItemId = `todos:${input.callId}`; - const now = yield* DateTime.now; - const planId = yield* resolvePlanId(input.context, nativeItemId); - const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId); - const nodeId = idAllocator.derive.nodeFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId, - }); - const turnItemId = idAllocator.derive.turnItemFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId, - }); - const status = input.failed ? "failed" : input.completed ? "completed" : "running"; - const steps = cursorTodoSteps(input.toolCall); - const nativeItemRef = { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: input.callId, - strength: "strong" as const, - }; - yield* emitProviderEvent({ - type: "node.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - node: { - id: nodeId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, - parentNodeId: input.context.input.rootNodeId, - rootNodeId: input.context.input.rootNodeId, - kind: "todo_list", - status, - countsForRun: false, - providerThreadId: input.context.input.providerThread.id, - providerTurnId: input.context.providerTurnId, - nativeItemRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: input.context.startedAt, - completedAt: input.completed ? now : null, - }, - }); - yield* emitProviderEvent({ - type: "plan.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - plan: { - id: planId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, - nodeId, - kind: "todo_list", - status: input.completed ? "active" : "draft", - steps: [...steps], - }, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - turnItem: { - id: turnItemId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, - nodeId, - providerThreadId: input.context.input.providerThread.id, - providerTurnId: input.context.providerTurnId, - nativeItemRef, - parentItemId: null, - ordinal, - status, - title: null, - startedAt: input.context.startedAt, - completedAt: input.completed ? now : null, - updatedAt: now, - type: "todo_list", - planId, - steps: [...steps], - }, - }); - }); - - const emitSubagent = Effect.fnUntraced(function* (input: { - readonly context: ActiveCursorTurn; - readonly callId: string; - readonly toolCall: Extract; - readonly completed: boolean; - readonly status?: OrchestrationV2Subagent["status"]; - }) { - const args = input.toolCall.args; - const result = - input.toolCall.result?.status === "success" ? input.toolCall.result.value : undefined; - const existing = input.context.subagents.get(input.callId); - if ( - existing !== undefined && - !isOrchestrationV2WorkActive(existing.task.status) && - !input.completed - ) - return; - const now = yield* DateTime.now; - const status: OrchestrationV2Subagent["status"] = - input.status ?? - (input.completed - ? cursorToolFailed(input.toolCall) - ? "failed" - : "completed" - : "running"); - const resultText = [ - ...assistantTextsFromConversationSteps(result?.conversationSteps ?? []), - ...(result?.resultSuffix === undefined ? [] : [result.resultSuffix]), - ] - .filter((part) => part.trim().length > 0) - .join("\n"); - const nativeItemId = input.callId; - const nodeId = - existing?.task.id ?? - idAllocator.derive.nodeFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId, - }); - const childRootNodeId = - existing?.childRootNodeId ?? - idAllocator.derive.nodeFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: `${nativeItemId}:child-root`, - }); - const childThreadId = - existing?.childThreadId ?? - idAllocator.derive.threadFromProviderThread({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeThreadId: `${input.context.run.runId}:task:${input.callId}`, - }); - const task: OrchestrationV2Subagent = { - ...(existing?.task ?? { - id: nodeId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, - parentNodeId: input.context.input.rootNodeId, - origin: "provider_native" as const, - createdBy: "agent" as const, - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerInstanceId: input.context.input.modelSelection.instanceId, - providerThreadId: null, - childThreadId, - nativeTaskRef: { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: input.callId, - strength: "strong" as const, - }, - prompt: args.prompt, - title: args.description, - result: null, - startedAt: now, - }), - model: args.model?.trim() || existing?.task.model || null, - nativeTaskRef: { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: input.callId, - strength: "strong" as const, - }, - status, - result: resultText.length === 0 ? (existing?.task.result ?? null) : resultText, - completedAt: input.completed ? (existing?.task.completedAt ?? now) : null, - updatedAt: now, - }; - const subagent: ActiveCursorSubagent = { - task, - toolCall: input.toolCall, - callId: input.callId, - childThreadId, - childRootNodeId, - turnItemId: - existing?.turnItemId ?? - idAllocator.derive.turnItemFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId, - }), - turnItemOrdinal: - existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(input.context, nativeItemId)), - nextChildOrdinal: existing?.nextChildOrdinal ?? 100, - resultProjected: existing?.resultProjected ?? false, - }; - input.context.subagents.set(input.callId, subagent); - - if (existing === undefined) { - yield* emitProviderEvent({ - type: "app_thread.created", - driver: CursorAgentSdk.CURSOR_PROVIDER, - appThread: makeSubagentChildThread({ - parentThread: input.context.input.appThread, - childThreadId, - parentNodeId: nodeId, - activeProviderThreadId: null, - providerInstanceId: input.context.input.modelSelection.instanceId, - modelSelection: input.context.input.modelSelection, - title: subagentThreadTitle({ - parentTitle: input.context.input.appThread.title, - title: args.description, - prompt: args.prompt, - ordinal: input.context.subagents.size, - }), - now, - createdBy: "agent", - creationSource: "provider", - }), - }); - const promptNativeId = `${nativeItemId}:prompt`; - const promptArtifacts = makeSubagentConversationArtifacts({ - senderThreadId: input.context.input.threadId, - messageId: idAllocator.derive.messageFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: promptNativeId, - }), - turnItemId: idAllocator.derive.turnItemFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: promptNativeId, - }), - threadId: childThreadId, - rootNodeId: childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: promptNativeId, - strength: "weak", - }, - role: "user", - text: args.prompt, - ordinal: 100, - now, - }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - message: promptArtifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - turnItem: promptArtifacts.turnItem, - }); - } - - yield* emitProviderEvent({ - type: "node.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - node: { - id: nodeId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, - parentNodeId: input.context.input.rootNodeId, - rootNodeId: input.context.input.rootNodeId, - kind: "subagent", - status, - countsForRun: false, - providerThreadId: input.context.input.providerThread.id, - providerTurnId: input.context.providerTurnId, - nativeItemRef: task.nativeTaskRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: task.startedAt, - completedAt: task.completedAt, - }, - }); - yield* emitProviderEvent({ - type: "node.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - node: { - id: childRootNodeId, - threadId: childThreadId, - runId: null, - parentNodeId: null, - rootNodeId: childRootNodeId, - kind: "root_turn", - status, - countsForRun: false, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: task.nativeTaskRef, - runtimeRequestId: null, - checkpointScopeId: null, - startedAt: task.startedAt, - completedAt: task.completedAt, - }, - }); - yield* emitProviderEvent({ - type: "subagent.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - subagent: task, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - turnItem: { - id: subagent.turnItemId, - threadId: input.context.input.threadId, - runId: input.context.input.runId, - nodeId, - providerThreadId: input.context.input.providerThread.id, - providerTurnId: input.context.providerTurnId, - nativeItemRef: task.nativeTaskRef, - parentItemId: null, - ordinal: subagent.turnItemOrdinal, - status, - title: task.title, - startedAt: task.startedAt, - completedAt: task.completedAt, - updatedAt: now, - type: "subagent", - subagentId: task.id, - origin: task.origin, - driver: task.driver, - providerInstanceId: task.providerInstanceId, - childThreadId, - prompt: task.prompt, - result: task.result, - }, - }); - - if (input.completed && !subagent.resultProjected) { - for (const [index, nestedTool] of toolCallsFromConversationSteps( - result?.conversationSteps ?? [], - ).entries()) { - const callId = `${nativeItemId}:child-tool:${nestedTool.callId || index + 1}`; - const startedAt = task.startedAt ?? now; - const active: ActiveCursorToolCall = { - callId, - toolCall: nestedTool.toolCall, - target: { - threadId: childThreadId, - runId: null, - rootNodeId: childRootNodeId, - parentNodeId: childRootNodeId, - providerThreadId: null, - providerTurnId: null, - }, - ordinal: ++subagent.nextChildOrdinal, - startedAt, - streamedOutput: "", - }; - yield* emitToolArtifacts({ active, completed: true }); - } - if (task.result !== null && task.result.length > 0) { - const resultNativeId = `${nativeItemId}:result`; - const resultArtifacts = makeSubagentConversationArtifacts({ - messageId: idAllocator.derive.messageFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: resultNativeId, - }), - turnItemId: idAllocator.derive.turnItemFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: resultNativeId, - }), - threadId: childThreadId, - rootNodeId: childRootNodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: resultNativeId, - strength: "weak", - }, - role: "assistant", - text: task.result, - ordinal: ++subagent.nextChildOrdinal, - now, - }); - yield* emitProviderEvent({ - type: "message.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - message: resultArtifacts.message, - }); - yield* emitProviderEvent({ - type: "turn_item.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - turnItem: resultArtifacts.turnItem, - }); - } - subagent.resultProjected = true; - } - }); - - const handleToolUpdate = Effect.fnUntraced(function* ( - context: ActiveCursorTurn, - update: Extract< - InteractionUpdate, - { - readonly type: "tool-call-started" | "partial-tool-call" | "tool-call-completed"; - } - >, - ) { - const completed = update.type === "tool-call-completed"; - const toolCall = update.toolCall; - switch (toolCall.type) { - case "createPlan": - yield* emitPlanArtifacts({ - context, - callId: update.callId, - markdown: toolCall.args.plan, - completed, - failed: completed && cursorToolFailed(toolCall), - }); - return; - case "updateTodos": - yield* emitTodoArtifacts({ - context, - callId: update.callId, - toolCall, - completed, - failed: completed && cursorToolFailed(toolCall), - }); - return; - case "task": - if (update.type === "partial-tool-call") { - return; - } - yield* emitSubagent({ - context, - callId: update.callId, - toolCall, - completed, - }); - return; - default: { - const active = yield* ensureToolStarted(context, update.callId, toolCall); - active.toolCall = toolCall; - if (update.type !== "tool-call-started") { - yield* emitToolArtifacts({ active, completed }); - } - if (completed) { - context.tools.delete(update.callId); - } - } - } - }); - - const shellOutputText = (event: Record): string => { - const candidates = [ - event.text, - event.output, - event.data, - event.stdout, - event.stderr, - event.chunk, - ]; - return candidates.filter((value): value is string => typeof value === "string").join(""); - }; - - const handleInteractionUpdate = Effect.fnUntraced(function* ( - context: ActiveCursorTurn, - update: InteractionUpdate, - ) { - if (context.finalized) { - return; - } - switch (update.type) { - case "text-delta": - context.assistantReply.push(update.text); - yield* completeReasoning(context); - yield* appendTextSegment({ - context, - stream: context.assistant, - kind: "assistant", - text: update.text, - }); - yield* emitAssistant(context, false); - return; - case "thinking-delta": - yield* completeAssistant(context); - yield* appendTextSegment({ - context, - stream: context.reasoning, - kind: "reasoning", - text: update.text, - }); - yield* emitReasoning(context, false); - return; - case "thinking-completed": - yield* completeReasoning(context); - return; - case "tool-call-started": - case "partial-tool-call": - case "tool-call-completed": - yield* completeAssistant(context); - yield* completeReasoning(context); - yield* handleToolUpdate(context, update); - return; - case "step-completed": - case "turn-ended": - yield* completeAssistant(context); - yield* completeReasoning(context); - return; - case "shell-output-delta": { - const shell = Array.from(context.tools.values()) - .toReversed() - .find((candidate) => candidate.toolCall.type === "shell"); - if (shell === undefined) { - return; - } - shell.streamedOutput += shellOutputText(update.event); - yield* emitToolArtifacts({ active: shell, completed: false }); - return; - } - default: - return; - } - }); - - const providerTurnPayload = (input: { - readonly context: ActiveCursorTurn; - readonly status: OrchestrationV2ProviderTurn["status"]; - readonly completedAt: DateTime.Utc | null; - }): OrchestrationV2ProviderTurn => ({ - id: input.context.providerTurnId, - providerThreadId: input.context.input.providerThread.id, - nodeId: input.context.input.rootNodeId, - runAttemptId: input.context.input.attemptId, - nativeTurnRef: { - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeId: input.context.run.runId, - strength: "strong", - }, - ordinal: input.context.input.providerTurnOrdinal, - status: input.status, - startedAt: input.context.startedAt, - completedAt: input.completedAt, - }); - - const finalizeTurn = Effect.fnUntraced(function* (input: { - readonly context: ActiveCursorTurn; - readonly status: Extract< - OrchestrationV2ProviderTurn["status"], - "completed" | "interrupted" | "failed" | "cancelled" - >; - readonly failure?: OrchestrationV2ProviderFailure; - readonly threadDisposition?: "reusable" | "broken"; - }) { - if (input.context.finalized) { - return; - } - input.context.finalized = true; - const completedAt = yield* DateTime.now; - // Tools still here never got a tool-call-completed. A stopped or - // failed turn cut them short, so they end with the turn's status. - for (const tool of input.context.tools.values()) { - yield* emitToolArtifacts({ - active: tool, - completed: true, - ...(input.status === "completed" ? {} : { unfinishedStatus: input.status }), - }); - } - input.context.tools.clear(); - // This interaction stops delivering updates at finalization. Tasks - // without a completion have an unknown outcome. Background launch - // acknowledgements have already settled their rows. - for (const subagent of input.context.subagents.values()) { - if (!isOrchestrationV2WorkActive(subagent.task.status)) continue; - yield* emitSubagent({ - context: input.context, - callId: subagent.callId, - toolCall: subagent.toolCall, - completed: true, - status: input.status === "completed" ? "idle" : input.status, - }); - } - yield* completeReasoning(input.context); - yield* completeAssistant(input.context); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerTurn: providerTurnPayload({ - context: input.context, - status: input.status, - completedAt, - }), - }); - yield* emitProviderEvent({ - type: "provider_thread.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerThread: { - ...input.context.input.providerThread, - providerSessionId: session.id, - status: "active", - firstRunOrdinal: - input.context.input.providerThread.firstRunOrdinal ?? - input.context.input.runOrdinal, - lastRunOrdinal: input.context.input.runOrdinal, - updatedAt: completedAt, - }, - }); - const threadDisposition = input.threadDisposition ?? "reusable"; - yield* emitProviderEvent( - input.status === "failed" - ? { - type: "turn.terminal", - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerThreadId: input.context.input.providerThread.id, - providerTurnId: input.context.providerTurnId, - runOrdinal: input.context.input.runOrdinal, - failureItemOrdinal: yield* resolveItemOrdinal( - input.context, - `terminal-failure:${input.context.providerTurnId}`, - ), - status: input.status, - failure: input.failure ?? makeProviderFailure({ class: "provider_error" }), - threadDisposition, - } - : { - type: "turn.terminal", - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerThreadId: input.context.input.providerThread.id, - providerTurnId: input.context.providerTurnId, - runOrdinal: input.context.input.runOrdinal, - status: input.status, - failure: null, - threadDisposition, - }, - ); - yield* Ref.update(activeTurn, (current) => - current?.providerTurnId === input.context.providerTurnId ? null : current, - ); - yield* Deferred.succeed(input.context.completed, undefined); - }); - - const terminalStatus = ( - context: ActiveCursorTurn, - result: RunResult, - ): Extract< - OrchestrationV2ProviderTurn["status"], - "completed" | "interrupted" | "failed" | "cancelled" - > => { - if (context.interrupted) { - return "interrupted"; - } - switch (result.status) { - case "finished": - return "completed"; - case "cancelled": - return "cancelled"; - case "error": - return "failed"; - } - }; - - const openAgent = Effect.fnUntraced(function* (openInput: { - readonly operation: "create" | "resume"; - readonly threadId: ThreadId; - readonly modelSelection: ModelSelection; - readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; - readonly agentId?: string; - }) { - const existing = yield* Ref.get(liveAgent); - if ( - existing !== null && - openInput.operation === "resume" && - existing.nativeThreadId === openInput.agentId - ) { - return existing; - } - if (existing !== null) { - yield* existing.session.close.pipe(Effect.ignore); - yield* Ref.set(liveAgent, null); - } - const sdkSession = yield* runner.open({ - operation: openInput.operation, - ...(openInput.agentId === undefined ? {} : { agentId: openInput.agentId }), - options: makeCursorAgentOptions({ - ...(apiKey === undefined ? {} : { apiKey }), - modelSelection: openInput.modelSelection, - runtimePolicy: openInput.runtimePolicy, - threadId: openInput.threadId, - }), - threadId: openInput.threadId, - providerSessionId: input.providerSessionId, - }); - const next = { - nativeThreadId: sdkSession.agentId, - session: sdkSession, - } satisfies CursorLiveAgent; - yield* Ref.set(liveAgent, next); - return next; - }); - - let cursorSkillNames: ReadonlySet | undefined; - const resolveUserMessage = Effect.fnUntraced(function* ( - turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, - ) { - const rawText = turnInput.message.text; - if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) { - return "/compress"; - } - if (hasCursorSkillMention(rawText) && cursorSkillNames === undefined) { - const skills = yield* discoverCursorSkills( - turnInput.runtimePolicy.cwd ?? undefined, - adapterOptions.environment, - ).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - ); - cursorSkillNames = new Set( - skills - .filter((skill) => skill.enabled && skill.userInvocable !== false) - .map((skill) => skill.name), - ); - } - const userText = t3OrchestrationPromptForFirstRun({ - prompt: providerMessageTextWithAttachmentPaths({ - text: - cursorSkillNames === undefined - ? rawText - : rewriteCursorSkillMentions(rawText, cursorSkillNames), - attachments: turnInput.message.attachments, - resolveAttachmentPath: (attachment) => - resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), - }), - runOrdinal: turnInput.runOrdinal, - hasT3Mcp: cursorMcpServers(turnInput.threadId) !== undefined, - }); - const images = yield* Effect.forEach( - turnInput.message.attachments.filter(isProviderNativeImageAttachment), - (attachment: ChatAttachment) => - Effect.gen(function* () { - const path = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment, - }); - if (path === null) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - detail: `Invalid attachment id '${attachment.id}'.`, - }); - } - const bytes = yield* fileSystem.readFile(path).pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - detail: `Failed to read attachment '${attachment.id}'.`, - payload: cause, - }), - ), - ); - return { - data: Buffer.from(bytes).toString("base64"), - mimeType: attachment.mimeType, - }; - }), - { concurrency: 1 }, - ); - if (userText.length === 0 && images.length === 0) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - detail: "Cursor turn requires non-empty text or attachments.", - }); - } - const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`; - return images.length === 0 - ? text - : ({ - text, - images, - } satisfies SDKUserMessage); - }); - - const startTurn = Effect.fn("CursorAdapterV2.startTurn")( - function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { - const current = yield* Ref.get(activeTurn); - if (current !== null) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - detail: `Cursor provider turn ${current.providerTurnId} is still active.`, - }); - } - const agentId = nativeThreadId(turnInput.providerThread); - const agent = yield* openAgent({ - operation: "resume", - agentId, - threadId: turnInput.threadId, - modelSelection: turnInput.modelSelection, - runtimePolicy: turnInput.runtimePolicy, - }); - const message = yield* resolveUserMessage(turnInput); - const mcpServers = cursorMcpServers(turnInput.threadId); - const pendingUpdates: Array = []; - let context: ActiveCursorTurn | null = null; - const sdkRun = yield* agent.session.send({ - message, - options: { - model: cursorSdkModelSelection(turnInput.modelSelection), - mode: turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", - ...(mcpServers === undefined ? {} : { mcpServers }), - }, - onDelta: (update) => { - if (context === null) { - return Effect.sync(() => { - pendingUpdates.push(update); - }); - } - return handleInteractionUpdate(context, update); - }, - }); - const startedAt = yield* DateTime.now; - const completed = yield* Deferred.make(); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeTurnId: sdkRun.runId, - }); - context = { - input: turnInput, - run: sdkRun, - providerTurnId, - startedAt, - completed, - itemOrdinals: new Map(), - tools: new Map(), - subagents: new Map(), - assistant: { - current: null, - nextSegment: 0, - }, - assistantReply: new CursorTransportFailure(), - reasoning: { - current: null, - nextSegment: 0, - }, - interrupted: false, - finalized: false, - }; - yield* Ref.set(activeTurn, context); - yield* emitProviderEvent({ - type: "provider_turn.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerTurn: providerTurnPayload({ - context, - status: "running", - completedAt: null, - }), - }); - yield* emitProviderEvent({ - type: "provider_thread.updated", - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerThread: { - ...turnInput.providerThread, - providerSessionId: session.id, - status: "active", - updatedAt: startedAt, - }, - }); - for (const update of pendingUpdates) { - yield* handleInteractionUpdate(context, update); - } - - yield* sdkRun.wait.pipe( - Effect.flatMap((result) => - Effect.gen(function* () { - if ( - context !== null && - context.assistant.nextSegment === 0 && - result.result !== undefined && - result.result.length > 0 - ) { - context.assistantReply.push(result.result); - yield* appendTextSegment({ - context, - stream: context.assistant, - kind: "assistant", - text: result.result, - }); - } - if (context !== null) { - const transportFailure = context.assistantReply.failure; - const status = - transportFailure === undefined ? terminalStatus(context, result) : "failed"; - yield* finalizeTurn({ - context, - status, - ...(status === "failed" - ? { - failure: makeProviderFailure({ - cause: - transportFailure ?? (result as { readonly error?: unknown }).error, - class: - transportFailure === undefined - ? "provider_error" - : "transport_error", - }), - } - : {}), - }); - } - }), - ), - Effect.catch((cause) => - Effect.gen(function* () { - if (context !== null) { - yield* finalizeTurn({ - context, - status: context.interrupted ? "interrupted" : "failed", - ...(context.interrupted - ? {} - : { - failure: makeProviderFailure({ cause, class: "transport_error" }), - }), - }); - } - yield* Effect.logWarning("orchestration-v2.cursor-run-failed", { - providerSessionId: input.providerSessionId, - providerThreadId: turnInput.providerThread.id, - providerTurnId, - cause, - }); - }), - ), - Effect.forkIn(sessionScope), - ); - }, - (effect, turnInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterTurnStartError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - threadId: turnInput.threadId, - providerThreadId: turnInput.providerThread.id, - runId: turnInput.runId, - cause, - }), - ), - ), - ); - - const closeSession = Effect.fnUntraced(function* () { - const existing = yield* Ref.get(liveAgent); - if (existing !== null) { - yield* existing.session.close.pipe(Effect.ignore); - yield* Ref.set(liveAgent, null); - } - yield* runner.assertComplete.pipe( - Effect.catchCause((cause) => - Effect.logWarning("orchestration-v2.cursor-runner-incomplete", { - providerSessionId: input.providerSessionId, - cause, - }), - ), - ); - }); - yield* Effect.addFinalizer(() => closeSession()); - - const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { - instanceId: adapterOptions.instanceId, - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerSessionId: input.providerSessionId, - providerSession: session, - events: Stream.fromEffectRepeat(Queue.take(events)), - ensureThread: Effect.fn("CursorAdapterV2.ensureThread")( - function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) { - const opened = yield* openAgent({ - operation: "create", - threadId: threadInput.threadId, - modelSelection: threadInput.modelSelection, - runtimePolicy: threadInput.runtimePolicy, - }); - const now = yield* DateTime.now; - return makeProviderThread({ - idAllocator, - providerInstanceId: adapterOptions.instanceId, - appThreadId: threadInput.threadId, - providerSessionId: input.providerSessionId, - nativeThreadId: opened.nativeThreadId, - now, - }); - }, - (effect, threadInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterEnsureThreadError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - threadId: threadInput.threadId, - cause, - }), - ), - ), - ), - resumeThread: Effect.fn("CursorAdapterV2.resumeThread")( - function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) { - const agentId = nativeThreadId(threadInput.providerThread); - yield* openAgent({ - operation: "resume", - agentId, - threadId: threadInput.providerThread.appThreadId ?? input.threadId, - modelSelection: input.modelSelection, - runtimePolicy: input.runtimePolicy, - }); - const now = yield* DateTime.now; - return { - ...threadInput.providerThread, - providerSessionId: input.providerSessionId, - status: "idle" as const, - updatedAt: now, - }; - }, - (effect, threadInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterResumeThreadError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerSessionId: input.providerSessionId, - providerThreadId: threadInput.providerThread.id, - cause, - }), - ), - ), - ), - startTurn, - compactThread: (turnInput) => - startTurn({ - ...turnInput, - message: { ...turnInput.message, text: "/compress" }, - }), - steerTurn: (turnInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerThreadId: turnInput.providerThread.id, - }), - ), - interruptTurn: Effect.fn("CursorAdapterV2.interruptTurn")( - function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { - const context = yield* Ref.get(activeTurn); - // Stop on a settled turn: finalization already ended its tools - // and subagents, so nothing of it is left running to stop. - if (context === null && turnInput.requestRuntimeRestart === true) return; - if (context?.providerTurnId !== turnInput.providerTurnId) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - detail: `Cursor provider turn ${turnInput.providerTurnId} is not active.`, - }); - } - context.interrupted = true; - yield* context.run.cancel; - const stopped = yield* Deferred.await(context.completed).pipe( - Effect.timeoutOption("10 seconds"), - ); - if (Option.isSome(stopped)) { - return; - } - yield* Effect.logWarning("orchestration-v2.cursor-interrupt-timeout", { - providerSessionId: input.providerSessionId, - providerThreadId: turnInput.providerThread.id, - providerTurnId: turnInput.providerTurnId, - }); - yield* finalizeTurn({ context, status: "interrupted" }); - }, - (effect, turnInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterInterruptError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerThreadId: turnInput.providerThread.id, - providerTurnId: turnInput.providerTurnId, - cause, - }), - ), - ), - ), - respondToRuntimeRequest: (requestInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - requestId: requestInput.requestId, - cause: new ProviderAdapter.ProviderAdapterProtocolError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - detail: "Cursor Agent SDK does not expose interactive approval requests.", - }), - }), - ), - readThreadSnapshot: Effect.fn("CursorAdapterV2.readThreadSnapshot")( - function* (snapshotInput) { - const requestedAgentId = nativeThreadId(snapshotInput.providerThread); - const agent = yield* openAgent({ - operation: "resume", - agentId: requestedAgentId, - threadId: snapshotInput.providerThread.appThreadId ?? input.threadId, - modelSelection: input.modelSelection, - runtimePolicy: input.runtimePolicy, - }); - const messages = yield* agent.session.listMessages; - const now = yield* DateTime.now; - const threadId = snapshotInput.providerThread.appThreadId ?? input.threadId; - const projectedMessages: Array = messages.flatMap( - (message) => { - const text = textFromAgentMessage(message); - if (text.length === 0) { - return []; - } - return [ - { - createdBy: message.type === "user" ? "user" : "agent", - creationSource: "provider", - id: idAllocator.derive.messageFromProviderItem({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - nativeItemId: message.uuid, - }), - threadId, - runId: null, - nodeId: null, - role: message.type, - text, - attachments: [], - streaming: false, - createdAt: now, - updatedAt: now, - }, - ]; - }, - ); - return { - providerThread: { - ...snapshotInput.providerThread, - providerSessionId: input.providerSessionId, - status: "idle" as const, - updatedAt: now, - }, - providerTurns: [], - messages: projectedMessages, - runtimeRequests: [], - providerPayload: { - agentId: requestedAgentId, - messages, - }, - }; - }, - (effect, snapshotInput) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerThreadId: snapshotInput.providerThread.id, - cause, - }), - ), - ), - ), - rollbackThread: (rollbackInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterRollbackThreadError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerThreadId: rollbackInput.providerThread.id, - checkpointId: rollbackInput.target.checkpointId, - cause: "Cursor Agent SDK does not expose conversation rollback.", - }), - ), - forkThread: (forkInput) => - Effect.fail( - new ProviderAdapter.ProviderAdapterForkThreadError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerThreadId: forkInput.sourceProviderThread.id, - cause: "Cursor Agent SDK does not expose native agent forks.", - }), - ), - }; - return runtime; - }, - (effect, input) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapter.ProviderAdapterOpenSessionError({ - driver: CursorAgentSdk.CURSOR_PROVIDER, - providerSessionId: input.providerSessionId, - cause, - }), - ), - ), - ), - }); -} - -export type CursorAdapterV2DriverEnv = - | CursorAgentSdk.CursorAgentSdkRunner - | FileSystem.FileSystem - | Path.Path - | IdAllocator.IdAllocatorV2 - | ServerConfig.ServerConfig; - -export const CursorAdapterV2Driver: ProviderAdapterDriver< - CursorSettings, - CursorAdapterV2DriverEnv -> = { - driverKind: CURSOR_DRIVER_KIND, - configSchema: CursorSettings, - defaultConfig: (): CursorSettings => DEFAULT_CURSOR_SETTINGS, - create: Effect.fn("CursorAdapterV2Driver.create")( - function* (input: ProviderAdapterDriverCreateInput) { - const hostEnvironment = yield* HostProcessEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const serverConfig = yield* ServerConfig.ServerConfig; - return makeCursorAdapterV2({ - instanceId: input.instanceId, - settings: { - ...input.config, - enabled: input.enabled, - }, - environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), - fileSystem, - path, - idAllocator, - runner, - serverConfig, - }); - }, - (effect, input) => - effect.pipe( - Effect.mapError( - (cause) => - new ProviderAdapterDriverCreateError({ - driver: CURSOR_DRIVER_KIND, - instanceId: input.instanceId, - detail: "Failed to create Cursor Agent SDK adapter.", - cause, - }), - ), - ), - ), -}; - -const layer: Layer.Layer< - ProviderAdapter.ProviderAdapterV2, - never, - | CursorAgentSdk.CursorAgentSdkRunner - | FileSystem.FileSystem - | Path.Path - | IdAllocator.IdAllocatorV2 - | ServerConfig.ServerConfig -> = Layer.effect( - ProviderAdapter.ProviderAdapterV2, - Effect.gen(function* () { - const hostEnvironment = yield* HostProcessEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const serverConfig = yield* ServerConfig.ServerConfig; - return makeCursorAdapterV2({ - instanceId: CURSOR_DEFAULT_INSTANCE_ID, - settings: DEFAULT_CURSOR_SETTINGS, - environment: hostEnvironment, - fileSystem, - path, - idAllocator, - runner, - serverConfig, - }); - }), -); diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts b/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts deleted file mode 100644 index 5754ec28706ce09a95e978333b7b4aca8197233b..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts +++ /dev/null @@ -1,304 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, describe, it } from "@effect/vitest"; -import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { vi } from "vite-plus/test"; - -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; -import * as CursorAgentSdk from "./CursorAgentSdk.ts"; - -const cursorSdkMock = vi.hoisted(() => { - const closeExecutionOrder: Array = []; - const runOperations: Array = []; - let agentCloseFailure: unknown; - let staleRunActive = false; - const runWait = vi.fn(async () => ({ - id: "run-cursor-agent-sdk-test", - requestId: "request-cursor-agent-sdk-test", - status: "finished", - result: "ok", - model: { id: "default" }, - durationMs: 1, - })); - const runCancel = vi.fn(async () => {}); - const agentClose = vi.fn(() => { - closeExecutionOrder.push("sdk:agent.close"); - if (agentCloseFailure !== undefined) { - throw agentCloseFailure; - } - }); - const send = vi.fn( - async ( - _message: unknown, - options: { readonly onDelta?: (input: { readonly update: unknown }) => Promise }, - ) => { - runOperations.push("run.start"); - if (staleRunActive) { - throw new Error("Agent agent-cursor-agent-sdk-test already has active run"); - } - await options.onDelta?.({ - update: { - type: "assistant-message-chunk", - text: "hello", - }, - }); - return { - id: "run-cursor-agent-sdk-test", - agentId: "agent-cursor-agent-sdk-test", - wait: runWait, - cancel: runCancel, - }; - }, - ); - const create = vi.fn(async () => ({ - agentId: "agent-cursor-agent-sdk-test", - send, - close: agentClose, - })); - const resume = vi.fn(async () => ({ - agentId: "agent-cursor-agent-sdk-test", - send, - close: agentClose, - })); - const listRuns = vi.fn(async () => { - runOperations.push("agent.listRuns"); - return { - items: staleRunActive ? [{ id: "run-stale", status: "running" }] : [], - }; - }); - const cancelRun = vi.fn(async () => { - runOperations.push("agent.cancelRun"); - staleRunActive = false; - }); - - return { - agentClose, - cancelRun, - closeExecutionOrder, - create, - listRuns, - resume, - runOperations, - runCancel, - runWait, - send, - setAgentCloseFailure: (failure: unknown) => { - agentCloseFailure = failure; - }, - setStaleRunActive: (active: boolean) => { - staleRunActive = active; - }, - }; -}); - -vi.mock("../../provider/cursorSdk.ts", () => ({ - Agent: { - create: cursorSdkMock.create, - resume: cursorSdkMock.resume, - listRuns: cursorSdkMock.listRuns, - cancelRun: cursorSdkMock.cancelRun, - messages: { - list: vi.fn(async () => []), - }, - }, -})); - -const layerTest = CursorAgentSdk.layer.pipe( - Layer.provide( - Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, { - native: { - filePath: "cursor-agent-sdk-test.log", - write: (event: unknown) => - Effect.sync(() => { - if ( - typeof event === "object" && - event !== null && - Reflect.get(Reflect.get(event, "event"), "payload")?.type === "agent.close" - ) { - cursorSdkMock.closeExecutionOrder.push("protocol:agent.close"); - } - }), - close: () => Effect.void, - }, - canonical: undefined, - }), - ), - Layer.provide(NodeServices.layer), -); - -describe("CursorAgentSdkRunner", () => { - it.effect("leaves a resumed agent alone when its next send succeeds", () => - Effect.gen(function* () { - cursorSdkMock.runOperations.length = 0; - cursorSdkMock.setStaleRunActive(false); - - const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const session = yield* runner.open({ - operation: "resume", - agentId: "agent-cursor-agent-sdk-test", - options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } }, - threadId: ThreadId.make("thread-cursor-agent-sdk-normal-resume-test"), - providerSessionId: ProviderSessionId.make( - "provider-session-cursor-agent-sdk-normal-resume-test", - ), - }); - const run = yield* session.send({ message: "continue" }); - yield* run.wait; - - assert.deepStrictEqual(cursorSdkMock.runOperations, ["run.start"]); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("retries a resumed send after cancelling its abandoned local run", () => - Effect.gen(function* () { - cursorSdkMock.runOperations.length = 0; - cursorSdkMock.listRuns.mockClear(); - cursorSdkMock.cancelRun.mockClear(); - cursorSdkMock.setStaleRunActive(true); - - const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const session = yield* runner.open({ - operation: "resume", - agentId: "agent-cursor-agent-sdk-test", - options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } }, - threadId: ThreadId.make("thread-cursor-agent-sdk-stale-run-test"), - providerSessionId: ProviderSessionId.make( - "provider-session-cursor-agent-sdk-stale-run-test", - ), - }); - assert.deepStrictEqual(cursorSdkMock.runOperations, []); - const run = yield* session.send({ message: "continue" }); - yield* run.wait; - - assert.deepStrictEqual(cursorSdkMock.runOperations, [ - "run.start", - "agent.listRuns", - "agent.cancelRun", - "run.start", - ]); - assert.deepStrictEqual(cursorSdkMock.listRuns.mock.calls[0], [ - "agent-cursor-agent-sdk-test", - { runtime: "local", cwd: process.cwd(), limit: 1 }, - ]); - assert.deepStrictEqual(cursorSdkMock.cancelRun.mock.calls[0], [ - "run-stale", - { - runtime: "local", - cwd: process.cwd(), - }, - ]); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("logs agent.close before invoking the Cursor SDK", () => - Effect.gen(function* () { - cursorSdkMock.agentClose.mockClear(); - cursorSdkMock.closeExecutionOrder.length = 0; - cursorSdkMock.setAgentCloseFailure(undefined); - - const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const session = yield* runner.open({ - operation: "create", - options: { - model: { id: "default" }, - mode: "agent", - local: { - cwd: process.cwd(), - }, - }, - threadId: ThreadId.make("thread-cursor-agent-sdk-close-order-test"), - providerSessionId: ProviderSessionId.make( - "provider-session-cursor-agent-sdk-close-order-test", - ), - }); - - cursorSdkMock.closeExecutionOrder.length = 0; - yield* session.close; - - assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [ - "protocol:agent.close", - "sdk:agent.close", - ]); - assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("logs agent.close even when the Cursor SDK close fails", () => - Effect.gen(function* () { - cursorSdkMock.agentClose.mockClear(); - cursorSdkMock.closeExecutionOrder.length = 0; - const closeFailure = new Error("agent close failed"); - cursorSdkMock.setAgentCloseFailure(closeFailure); - - const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const session = yield* runner.open({ - operation: "create", - options: { - model: { id: "default" }, - mode: "agent", - local: { - cwd: process.cwd(), - }, - }, - threadId: ThreadId.make("thread-cursor-agent-sdk-close-failure-test"), - providerSessionId: ProviderSessionId.make( - "provider-session-cursor-agent-sdk-close-failure-test", - ), - }); - - cursorSdkMock.closeExecutionOrder.length = 0; - const error = yield* Effect.flip(session.close); - - assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError); - assert.equal(error.method, "agent.close"); - assert.strictEqual(error.cause, closeFailure); - assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [ - "protocol:agent.close", - "sdk:agent.close", - ]); - assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1); - cursorSdkMock.setAgentCloseFailure(undefined); - }).pipe(Effect.provide(layerTest)), - ); - - it.effect("surfaces interaction callback failures through run.wait", () => - Effect.gen(function* () { - cursorSdkMock.create.mockClear(); - cursorSdkMock.runWait.mockClear(); - cursorSdkMock.send.mockClear(); - - const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const session = yield* runner.open({ - operation: "create", - options: { - model: { id: "default" }, - mode: "agent", - local: { - cwd: process.cwd(), - autoReview: false, - sandboxOptions: { enabled: false }, - enableAgentRetries: true, - }, - }, - threadId: ThreadId.make("thread-cursor-agent-sdk-test"), - providerSessionId: ProviderSessionId.make("provider-session-cursor-agent-sdk-test"), - }); - - const callbackFailure = new Error("delta callback failed"); - const run = yield* session.send({ - message: "hello", - onDelta: () => Effect.fail(callbackFailure), - }); - - const error = yield* Effect.flip(run.wait); - - assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError); - assert.equal(error.method, "run.wait"); - assert.strictEqual(error.cause, callbackFailure); - assert.equal(cursorSdkMock.create.mock.calls.length, 1); - assert.equal(cursorSdkMock.send.mock.calls.length, 1); - assert.equal(cursorSdkMock.runWait.mock.calls.length, 1); - }).pipe(Effect.provide(layerTest)), - ); -}); diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts b/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts deleted file mode 100644 index 036a93a0e557f963666f2d375cd400ddc257937c..0000000000000000000000000000000000000000 --- a/apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts +++ /dev/null @@ -1,612 +0,0 @@ -import type { - AgentMessage, - AgentOptions, - InteractionUpdate, - RunResult, - SDKUserMessage, - SendOptions, -} from "@cursor/sdk"; -import { - type OrchestrationV2ProviderSession, - ProviderDriverKind, - type ThreadId, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; - -import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts"; -import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; -import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; - -export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; -export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor"); - -export class CursorAgentSdkRunnerError extends Schema.TaggedError()( - "CursorAgentSdkRunnerError", - { - method: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Cursor Agent SDK ${this.method} failed.`; - } -} - -const isCursorAgentSdkRunnerError = Schema.is(CursorAgentSdkRunnerError); - -export interface CursorAgentSdkOpenInput { - readonly operation: "create" | "resume"; - readonly agentId?: string; - readonly options: AgentOptions; - readonly threadId: ThreadId; - readonly providerSessionId: OrchestrationV2ProviderSession["id"]; -} - -export interface CursorAgentSdkSendInput { - readonly message: string | SDKUserMessage; - readonly options?: Omit; - readonly onDelta?: (update: InteractionUpdate) => Effect.Effect; -} - -export interface CursorAgentSdkRun { - readonly runId: string; - readonly agentId: string; - readonly wait: Effect.Effect; - readonly cancel: Effect.Effect; -} - -export interface CursorAgentSdkSession { - readonly agentId: string; - readonly send: ( - input: CursorAgentSdkSendInput, - ) => Effect.Effect; - readonly listMessages: Effect.Effect, CursorAgentSdkRunnerError>; - readonly close: Effect.Effect; -} - -export interface CursorAgentSdkRunnerShape { - readonly open: ( - input: CursorAgentSdkOpenInput, - ) => Effect.Effect; - readonly assertComplete: Effect.Effect; -} - -export class CursorAgentSdkRunner extends Context.Service< - CursorAgentSdkRunner, - CursorAgentSdkRunnerShape ->()("t3/orchestration-v2/Adapters/CursorAgentSdk/CursorAgentSdkRunner") {} - -export interface CursorAgentSdkLoggedAgentOptions { - readonly model?: AgentOptions["model"]; - readonly hasName?: boolean; - readonly mode?: AgentOptions["mode"]; - readonly local?: { - readonly hasCwd?: boolean; - readonly autoReview?: boolean; - readonly settingSources?: AgentOptions["local"] extends infer Local - ? Local extends { readonly settingSources?: infer Sources } - ? Sources - : never - : never; - readonly sandboxEnabled?: boolean; - readonly enableAgentRetries?: boolean; - readonly hasCustomTools?: boolean; - }; - readonly agents?: ReadonlyArray; -} - -export interface CursorAgentSdkLoggedSendOptions { - readonly model?: SendOptions["model"]; - readonly mode?: SendOptions["mode"]; - readonly local?: { - readonly force?: boolean; - readonly hasCustomTools?: boolean; - }; - readonly idempotencyKey?: string; -} - -export type CursorAgentSdkProtocolLogEvent = - | { - readonly direction: "outgoing"; - readonly stage: "decoded"; - readonly payload: { - readonly type: "agent.open"; - readonly operation: CursorAgentSdkOpenInput["operation"]; - readonly agentId?: string; - readonly options: CursorAgentSdkLoggedAgentOptions; - }; - } - | { - readonly direction: "incoming"; - readonly stage: "decoded"; - readonly payload: { - readonly type: "agent.opened"; - readonly agentId: string; - }; - } - | { - readonly direction: "outgoing"; - readonly stage: "decoded"; - readonly payload: { - readonly type: "run.start"; - readonly message: string | SDKUserMessage; - readonly options: CursorAgentSdkLoggedSendOptions; - }; - } - | { - readonly direction: "incoming"; - readonly stage: "decoded"; - readonly payload: { - readonly type: "run.started"; - readonly runId: string; - readonly agentId: string; - }; - } - | { - readonly direction: "incoming"; - readonly stage: "decoded"; - readonly payload: { - readonly type: "interaction.update"; - readonly runId: string; - readonly update: InteractionUpdate; - }; - } - | { - readonly direction: "incoming"; - readonly stage: "decoded"; - readonly payload: { - readonly type: "run.completed"; - readonly result: RunResult; - }; - } - | { - readonly direction: "outgoing"; - readonly stage: "decoded"; - readonly payload: { - readonly type: "run.cancel"; - readonly runId: string; - }; - } - | { - readonly direction: "outgoing"; - readonly stage: "decoded"; - readonly payload: { - readonly type: "agent.messages.list"; - readonly agentId: string; - }; - } - | { - readonly direction: "incoming"; - readonly stage: "decoded"; - readonly payload: { - readonly type: "agent.messages"; - readonly agentId: string; - readonly messages: ReadonlyArray; - }; - } - | { - readonly direction: "outgoing"; - readonly stage: "decoded"; - readonly payload: { - readonly type: "agent.close"; - readonly agentId: string; - }; - }; - -export type CursorAgentSdkProtocolLogger = ( - event: CursorAgentSdkProtocolLogEvent, -) => Effect.Effect; - -function runnerError(cause: unknown, method: string): CursorAgentSdkRunnerError { - return isCursorAgentSdkRunnerError(cause) - ? cause - : new CursorAgentSdkRunnerError({ method, cause }); -} - -function isActiveRunConflict(cause: unknown): boolean { - return cause instanceof Error && /already has active run/i.test(cause.message); -} - -export function isCursorCancellationError(cause: unknown): boolean { - let current = cause; - const seen = new Set(); - - while (typeof current === "object" && current !== null && !seen.has(current)) { - if (Reflect.get(current, "name") === "AbortError") { - return true; - } - seen.add(current); - current = Reflect.get(current, "cause"); - } - - return false; -} - -export function loggedCursorAgentOptions(options: AgentOptions): CursorAgentSdkLoggedAgentOptions { - return { - ...(options.model === undefined ? {} : { model: options.model }), - ...(options.name === undefined ? {} : { hasName: true }), - ...(options.mode === undefined ? {} : { mode: options.mode }), - ...(options.local === undefined - ? {} - : { - local: { - ...(options.local.cwd === undefined ? {} : { hasCwd: true }), - ...(options.local.autoReview === undefined - ? {} - : { autoReview: options.local.autoReview }), - ...(options.local.settingSources === undefined - ? {} - : { settingSources: options.local.settingSources }), - ...(options.local.sandboxOptions === undefined - ? {} - : { sandboxEnabled: options.local.sandboxOptions.enabled }), - ...(options.local.enableAgentRetries === undefined - ? {} - : { enableAgentRetries: options.local.enableAgentRetries }), - ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }), - }, - }), - ...(options.agents === undefined ? {} : { agents: Object.keys(options.agents).toSorted() }), - }; -} - -export function loggedCursorSendOptions( - options: Omit | undefined, -): CursorAgentSdkLoggedSendOptions { - if (options === undefined) { - return {}; - } - return { - ...(options.model === undefined ? {} : { model: options.model }), - ...(options.mode === undefined ? {} : { mode: options.mode }), - ...(options.local === undefined - ? {} - : { - local: { - ...(options.local.force === undefined ? {} : { force: options.local.force }), - ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }), - }, - }), - ...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }), - }; -} - -function makeCursorAgentSdkProtocolLogger(input: { - readonly nativeEventLogger: EventNdjsonLogger | undefined; - readonly threadId: ThreadId; - readonly providerSessionId: OrchestrationV2ProviderSession["id"]; -}): CursorAgentSdkProtocolLogger | undefined { - if (input.nativeEventLogger === undefined) { - return undefined; - } - const nativeEventLogger = input.nativeEventLogger; - return (event) => - nativeEventLogger - .write( - { - provider: CURSOR_PROVIDER, - protocol: CURSOR_AGENT_SDK_PROTOCOL, - kind: "protocol", - providerSessionId: input.providerSessionId, - event, - }, - input.threadId, - ) - .pipe(Effect.ignore); -} - -/** - * The Cursor SDK decides once per process whether local sandboxing works, and - * caches the answer the first time any run starts. Only sandboxed runs point - * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access) - * run it caches "unsupported" and rejects every later sandboxed run until the - * server restarts. Warming a bare sandboxed executor before the first - * unsandboxed agent opens lets the SDK find the helper and cache the real - * answer. Warming is best effort: on a machine without sandbox support it - * fails, the SDK caches "unsupported", and sandboxed runs report that as - * before. - */ -let cursorSandboxSupportPrime: Promise | undefined; - -function primeCursorSandboxSupport(options: AgentOptions): Promise { - cursorSandboxSupportPrime ??= (async () => { - const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined; - const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd }); - const release = await platform.prewarmLocalWorkspace({ - ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), - local: { - ...(cwd === undefined ? {} : { cwd }), - settingSources: [], - sandboxOptions: { enabled: true }, - }, - }); - await release(); - })().catch(() => undefined); - return cursorSandboxSupportPrime; -} - -/** - * Runs agents through the Cursor SDK, logging every frame to the protocol - * logger chosen for each opened agent. The live layer writes the native - * provider event log; the replay recorder turns the same frames into a - * transcript. - */ -export function makeCursorAgentSdkRunner( - protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined, -): CursorAgentSdkRunnerShape { - return CursorAgentSdkRunner.of({ - open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) { - if (input.options.local?.sandboxOptions?.enabled === false) { - yield* Effect.promise(() => primeCursorSandboxSupport(input.options)); - } - const protocolLogger = protocolLoggerFor(input); - const log = (event: CursorAgentSdkProtocolLogEvent) => - protocolLogger === undefined ? Effect.void : protocolLogger(event); - - yield* log({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "agent.open", - operation: input.operation, - ...(input.agentId === undefined ? {} : { agentId: input.agentId }), - options: loggedCursorAgentOptions(input.options), - }, - }); - - const agent = yield* Effect.tryPromise({ - try: () => - input.operation === "create" - ? Agent.create(input.options) - : Agent.resume(input.agentId!, input.options), - catch: (cause) => runnerError(cause, `agent.${input.operation}`), - }); - - yield* log({ - direction: "incoming", - stage: "decoded", - payload: { - type: "agent.opened", - agentId: agent.agentId, - }, - }); - - const cwd = - typeof input.options.local?.cwd === "string" - ? input.options.local.cwd - : input.options.local?.cwd?.[0]; - const runOptions = { - runtime: "local" as const, - ...(cwd === undefined ? {} : { cwd }), - ...(input.options.local?.store === undefined ? {} : { store: input.options.local.store }), - }; - let abandonedRunRecoveryAvailable = input.operation === "resume"; - - return { - agentId: agent.agentId, - send: Effect.fn("CursorAgentSdkSession.send")(function* (sendInput) { - const context = yield* Effect.context(); - yield* log({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "run.start", - message: sendInput.message, - options: loggedCursorSendOptions(sendInput.options), - }, - }); - - let callbacksReady = false; - const pendingUpdates: Array = []; - let callbackFailure: { readonly cause: unknown } | undefined; - let callbackChain = Promise.resolve(); - let runId = ""; - const dispatchUpdate = (update: InteractionUpdate): Promise => { - callbackChain = callbackChain - .then(() => { - if (callbackFailure !== undefined) { - return; - } - return Effect.runPromiseWith(context)( - log({ - direction: "incoming", - stage: "decoded", - payload: { - type: "interaction.update", - runId, - update, - }, - }).pipe(Effect.andThen(sendInput.onDelta?.(update) ?? Effect.void)), - ); - }) - .catch((cause) => { - callbackFailure ??= { cause }; - }); - return callbackChain; - }; - - const startRun = () => - Effect.tryPromise({ - try: () => - agent.send(sendInput.message, { - ...sendInput.options, - onDelta: async ({ update }) => { - if (!callbacksReady) { - pendingUpdates.push(update); - return; - } - await dispatchUpdate(update); - }, - }), - catch: (cause) => runnerError(cause, "run.start"), - }); - const run = yield* startRun().pipe( - Effect.catchIf( - (error) => abandonedRunRecoveryAvailable && isActiveRunConflict(error.cause), - (error) => - Effect.gen(function* () { - abandonedRunRecoveryAvailable = false; - const latestRun = yield* Effect.tryPromise({ - try: () => Agent.listRuns(agent.agentId, { ...runOptions, limit: 1 }), - catch: (cause) => runnerError(cause, "agent.listRuns"), - }); - const activeRun = latestRun.items.find( - (candidate) => candidate.status === "running", - ); - if (activeRun === undefined) { - return yield* error; - } - yield* log({ - direction: "outgoing", - stage: "decoded", - payload: { type: "run.cancel", runId: activeRun.id }, - }); - yield* Effect.tryPromise({ - try: () => Agent.cancelRun(activeRun.id, runOptions), - catch: (cause) => runnerError(cause, "agent.cancelRun"), - }); - return yield* startRun(); - }), - ), - ); - abandonedRunRecoveryAvailable = false; - runId = run.id; - yield* log({ - direction: "incoming", - stage: "decoded", - payload: { - type: "run.started", - runId: run.id, - agentId: run.agentId, - }, - }); - callbacksReady = true; - for (const update of pendingUpdates) { - yield* Effect.tryPromise({ - try: () => dispatchUpdate(update), - catch: (cause) => runnerError(cause, "run.onDelta"), - }); - } - - return { - runId: run.id, - agentId: run.agentId, - wait: Effect.tryPromise({ - try: async () => { - const result = await run.wait(); - await callbackChain; - if (callbackFailure !== undefined) { - throw callbackFailure.cause; - } - return result; - }, - catch: (cause) => runnerError(cause, "run.wait"), - }).pipe( - Effect.tap((result) => - log({ - direction: "incoming", - stage: "decoded", - payload: { - type: "run.completed", - result, - }, - }), - ), - ), - cancel: log({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "run.cancel", - runId: run.id, - }, - }).pipe( - Effect.andThen( - Effect.tryPromise({ - try: async () => { - try { - await run.cancel(); - } catch (cause) { - if (!isCursorCancellationError(cause)) { - throw cause; - } - } - }, - catch: (cause) => runnerError(cause, "run.cancel"), - }), - ), - ), - } satisfies CursorAgentSdkRun; - }), - listMessages: log({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "agent.messages.list", - agentId: agent.agentId, - }, - }).pipe( - Effect.andThen( - Effect.tryPromise({ - try: () => - Agent.messages.list(agent.agentId, { - runtime: "local", - ...(cwd === undefined ? {} : { cwd }), - }), - catch: (cause) => runnerError(cause, "agent.messages.list"), - }), - ), - Effect.tap((messages) => - log({ - direction: "incoming", - stage: "decoded", - payload: { - type: "agent.messages", - agentId: agent.agentId, - messages, - }, - }), - ), - ), - close: log({ - direction: "outgoing", - stage: "decoded", - payload: { - type: "agent.close", - agentId: agent.agentId, - }, - }).pipe( - Effect.andThen( - Effect.try({ - try: () => agent.close(), - catch: (cause) => runnerError(cause, "agent.close"), - }), - ), - ), - } satisfies CursorAgentSdkSession; - }), - assertComplete: Effect.void, - }); -} - -export const layer: Layer.Layer< - CursorAgentSdkRunner, - never, - ProviderEventLoggers.ProviderEventLoggers -> = Layer.effect( - CursorAgentSdkRunner, - Effect.gen(function* () { - const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; - return makeCursorAgentSdkRunner((input) => - makeCursorAgentSdkProtocolLogger({ - nativeEventLogger, - threadId: input.threadId, - providerSessionId: input.providerSessionId, - }), - ); - }), -); diff --git a/apps/server/src/orchestration-v2/CommandPolicy.test.ts b/apps/server/src/orchestration-v2/CommandPolicy.test.ts index 256ecd45e75ab504b11c4533994040e4055b43a4..517229c222ea9773172d20ff9c662b6107dd5e4c 100644 --- a/apps/server/src/orchestration-v2/CommandPolicy.test.ts +++ b/apps/server/src/orchestration-v2/CommandPolicy.test.ts @@ -12,7 +12,7 @@ import { import * as Effect from "effect/Effect"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; -import { CursorProviderCapabilitiesV2 } from "./Adapters/CursorAdapterV2.ts"; +import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts"; import * as CommandPolicy from "./CommandPolicy.ts"; diff --git a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts index 3e33d5b2de417ec349ba7410d45098f4fe00da98..e48ceb60177c95fbfcf5162b0a59b2f1720291fd 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -12,7 +12,7 @@ import { CodexAdapterV2Driver, type CodexAdapterV2DriverEnv } from "./Adapters/C import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv, -} from "./Adapters/CursorAdapterV2.ts"; +} from "@t3tools/provider-cursor/server"; import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts"; import { OpenCodeAdapterV2Driver, diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index a0de3392631c73a81136167a65d2a5f5045880c7..b71043aabc4e62a0bcb56f02b6c06384a5623c38 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -43,7 +43,7 @@ import { canReuseCodexContextUsage, } from "../Adapters/CodexAdapterV2.ts"; import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts"; -import { CursorProviderCapabilitiesV2 } from "../Adapters/CursorAdapterV2.ts"; +import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; import * as EventSink from "../EventSink.ts"; import * as EventStore from "../EventStore.ts"; import * as LegacyV1ThreadImporter from "../legacy/LegacyV1ThreadImporter.ts"; diff --git a/apps/server/src/provider/AntigravityProvider.test.ts b/apps/server/src/provider/AntigravityProvider.test.ts index 77a436aaf7be2094feec3bcf1883c98186ced1db..69502047f73dcf5e6428290132800fcfec7fb1f0 100644 --- a/apps/server/src/provider/AntigravityProvider.test.ts +++ b/apps/server/src/provider/AntigravityProvider.test.ts @@ -22,6 +22,7 @@ import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as ProviderHostLive from "./ProviderHostLive.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; import { buildAntigravityModelsFromSession, @@ -116,9 +117,10 @@ const layerTest = ProviderHostLive.layer.pipe( }), ), Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(ServerSecretStore.layer), Layer.provide( ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-provider-test-" }).pipe( - Layer.provide(NodeServices.layer), + Layer.provideMerge(NodeServices.layer), ), ), ); diff --git a/apps/server/src/provider/CursorAuth.test.ts b/apps/server/src/provider/CursorAuth.test.ts deleted file mode 100644 index 6fa7133093c75737b66112c683239d9250e367d6..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/CursorAuth.test.ts +++ /dev/null @@ -1,356 +0,0 @@ -import type { SdkLoginOptions, StoredSdkCredentials } from "@cursor/sdk"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Option from "effect/Option"; -import * as Scope from "effect/Scope"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; - -import { makeCursorAuth, type CursorAuthOptions } from "./CursorAuth.ts"; -import { InMemoryCredentialStore } from "./cursorSdk.ts"; - -const instanceId = ProviderInstanceId.make("cursor-personal"); -const owner = "owner-client"; -const otherOwner = "other-client"; -const authorizationUrl = "https://cursor.com/loginDeepControl?challenge=test-challenge"; - -const makeHarness = Effect.fn("makeCursorAuthHarness")(function* ( - overrides: Partial = {}, -) { - const store = new InMemoryCredentialStore(); - const complete = Promise.withResolvers(); - const returned = Promise.withResolvers(); - const now = yield* Clock.currentTimeMillis; - const credentials: StoredSdkCredentials = { - version: 1, - backendUrl: "https://api2.cursor.sh", - apiKey: "browser-key", - createdAtMs: now, - apiKeyExpiresAtMs: now + 86_400_000, - email: "cursor@example.com", - }; - let loginOptions: SdkLoginOptions | undefined; - const changes: boolean[] = []; - const auth = yield* makeCursorAuth({ - instanceId, - displayName: "Personal Cursor", - enabled: true, - store, - onChanged: (signedIn) => - Effect.sync(() => { - changes.push(signedIn); - }), - login: async (options) => { - loginOptions = options; - options.onLoginUrl?.(authorizationUrl); - await complete.promise; - await options.store?.save(credentials); - returned.resolve(); - return { - apiKey: credentials.apiKey, - apiKeyExpiresAtMs: credentials.apiKeyExpiresAtMs!, - email: credentials.email!, - }; - }, - ...overrides, - }); - return { - auth, - store, - complete, - returned, - credentials, - changes, - loginOptions: () => loginOptions, - }; -}); - -type Auth = Effect.Success>; -const phase = (auth: Auth, phase: ProviderAuthState["phase"], session = owner) => - auth.controller.subscribe(session).pipe( - Stream.filter((state) => state.phase === phase), - Stream.runHead, - Effect.map(Option.getOrThrow), - ); - -it.layer(NodeServices.layer)("CursorAuth", (it) => { - it.effect("closes SDK sessions and interrupts in-flight credential work before signing out", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* Effect.promise(() => harness.store.save(harness.credentials)); - const acquired = yield* Deferred.make(); - let closedSession = false; - let closedRequest = false; - yield* harness.auth.withAccess( - Effect.acquireRelease(Effect.succeed("session"), () => - Effect.sync(() => { - closedSession = true; - }), - ), - ); - const request = yield* harness.auth - .withAccess( - Effect.gen(function* () { - yield* Effect.addFinalizer(() => - Effect.sync(() => { - closedRequest = true; - }), - ); - yield* Deferred.succeed(acquired, undefined); - return yield* Effect.never; - }), - ) - .pipe(Effect.forkScoped); - yield* Deferred.await(acquired); - yield* harness.auth.controller.logout(Effect.void); - expect(closedSession).toBe(true); - expect(closedRequest).toBe(true); - expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true); - expect(yield* harness.auth.readApiKey).toBeUndefined(); - }).pipe(Effect.scoped), - ); - - it.effect("completes a remote browser login, saves its key and refreshes provider status", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - let stopped = false; - expect(yield* harness.auth.readApiKey).toBeUndefined(); - const starting = yield* harness.auth.controller.start( - owner, - Effect.sync(() => { - stopped = true; - }), - ); - const waiting = yield* phase(harness.auth, "waiting"); - expect(stopped).toBe(true); - expect(waiting.authorizationUrl).toBe(authorizationUrl); - expect(waiting.flowId).toBe(starting.flowId); - expect(harness.loginOptions()?.openBrowser).toBe(false); - expect((yield* harness.auth.controller.start(owner)).flowId).toBe(starting.flowId); - expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); - yield* Effect.sync(() => harness.complete.resolve()); - const succeeded = yield* phase(harness.auth, "succeeded"); - expect(succeeded.authorizationUrl).toBeNull(); - expect(yield* harness.auth.requireApiKey).toBe("browser-key"); - expect(harness.changes).toEqual([true]); - expect(Object.values(succeeded)).not.toContain("browser-key"); - expect(yield* Effect.promise(() => harness.store.load())).toEqual(harness.credentials); - }).pipe(Effect.scoped), - ); - - it.effect("keeps the login link private to its owner and rejects other clients' mutations", () => - Effect.gen(function* () { - const { auth } = yield* makeHarness(); - yield* auth.controller.start(owner); - const waiting = yield* phase(auth, "waiting"); - const foreign = yield* phase(auth, "waiting", otherOwner); - expect(foreign).toMatchObject({ flowId: null, authorizationUrl: null, expiresAt: null }); - expect(Exit.isFailure(yield* Effect.exit(auth.controller.start(otherOwner)))).toBe(true); - expect( - Exit.isFailure(yield* Effect.exit(auth.controller.cancel(otherOwner, waiting.flowId!))), - ).toBe(true); - expect( - Exit.isFailure( - yield* Effect.exit( - auth.controller.complete(owner, { - flowId: waiting.flowId!, - callbackUrl: "http://localhost/callback", - }), - ), - ), - ).toBe(true); - expect((yield* phase(auth, "waiting")).authorizationUrl).toBe(authorizationUrl); - }).pipe(Effect.scoped), - ); - - it.effect("aborts cancellation and ignores a late successful SDK login", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.auth.controller.start(owner); - const waiting = yield* phase(harness.auth, "waiting"); - const cancelled = yield* harness.auth.controller.cancel(owner, waiting.flowId!); - expect(cancelled.phase).toBe("cancelled"); - expect(cancelled.authorizationUrl).toBeNull(); - expect(harness.loginOptions()?.signal?.aborted).toBe(true); - yield* Effect.sync(() => harness.complete.resolve()); - yield* Effect.promise(() => harness.returned.promise); - expect(yield* harness.auth.readApiKey).toBeUndefined(); - expect(harness.changes).toEqual([]); - }).pipe(Effect.scoped), - ); - - it.effect("times out pending logins and permits a retry", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.auth.controller.start(owner); - const waiting = yield* phase(harness.auth, "waiting"); - yield* TestClock.adjust(300_000); - expect((yield* phase(harness.auth, "failed")).authorizationUrl).toBeNull(); - expect(harness.loginOptions()?.signal?.aborted).toBe(true); - const retry = yield* harness.auth.controller.start(owner); - expect(retry.flowId).not.toBe(waiting.flowId); - }).pipe(Effect.scoped), - ); - - it.effect("finishes saving a completed login atomically across the timeout", () => - Effect.gen(function* () { - const store = new InMemoryCredentialStore(); - const saving = Promise.withResolvers(); - const finishSave = Promise.withResolvers(); - const scope = yield* Scope.make(); - yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); - const harness = yield* makeHarness({ - store: { - load: () => store.load(), - clear: () => store.clear(), - save: async (credentials) => { - saving.resolve(); - await finishSave.promise; - await store.save(credentials); - }, - }, - }).pipe(Effect.provideService(Scope.Scope, scope)); - yield* Effect.addFinalizer(() => Effect.sync(() => finishSave.resolve())); - yield* harness.auth.controller.start(owner); - yield* phase(harness.auth, "waiting"); - yield* Effect.sync(() => harness.complete.resolve()); - yield* Effect.promise(() => saving.promise); - yield* TestClock.adjust(300_000); - yield* Effect.sync(() => finishSave.resolve()); - yield* phase(harness.auth, "succeeded"); - expect(yield* harness.auth.requireApiKey).toBe("browser-key"); - yield* Scope.close(scope, Exit.void); - const state = yield* harness.auth.controller.subscribe(owner).pipe(Stream.runHead); - expect(Option.getOrThrow(state).phase).toBe("succeeded"); - expect(harness.changes).toEqual([true]); - }).pipe(Effect.scoped), - ); - - it.effect("discards a credential rejected by verification and permits a fresh login", () => - Effect.gen(function* () { - let rejected = true; - const harness = yield* makeHarness({ - onChanged: (signedIn) => - signedIn && rejected - ? Effect.fail( - new ProviderSetupError({ - instanceId, - operation: "start", - detail: "Could not verify the Cursor sign-in.", - }), - ) - : Effect.void, - }); - yield* harness.auth.controller.start(owner); - yield* phase(harness.auth, "waiting"); - yield* Effect.sync(() => harness.complete.resolve()); - yield* phase(harness.auth, "failed"); - expect(yield* Effect.promise(() => harness.store.load())).toBeUndefined(); - expect(yield* harness.auth.readApiKey).toBeUndefined(); - expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); - rejected = false; - yield* harness.auth.controller.start(owner); - yield* phase(harness.auth, "succeeded"); - expect(yield* harness.auth.requireApiKey).toBe("browser-key"); - }).pipe(Effect.scoped), - ); - - it.effect("clears only this instance's credential after its sessions stop", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - const other = yield* makeHarness({ instanceId: ProviderInstanceId.make("cursor-work") }); - yield* Effect.promise(() => harness.store.save(harness.credentials)); - yield* Effect.promise(() => other.store.save({ ...other.credentials, apiKey: "other-key" })); - const state = yield* harness.auth.controller.logout( - Effect.gen(function* () { - expect(yield* harness.auth.readApiKey).toBe("browser-key"); - expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); - }), - ); - expect(state.message).toBe("Signed out of Cursor."); - expect(yield* harness.auth.readApiKey).toBeUndefined(); - expect(yield* other.auth.requireApiKey).toBe("other-key"); - expect(harness.changes).toEqual([false]); - }).pipe(Effect.scoped), - ); - - it.effect("cancels a pending sign-in on logout without saving its eventual key", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.auth.controller.start(owner); - yield* phase(harness.auth, "waiting"); - yield* harness.auth.controller.logout(Effect.void); - expect(harness.loginOptions()?.signal?.aborted).toBe(true); - yield* Effect.sync(() => harness.complete.resolve()); - yield* Effect.promise(() => harness.returned.promise); - expect(yield* harness.auth.readApiKey).toBeUndefined(); - }).pipe(Effect.scoped), - ); - - it.effect("retains credentials if sessions cannot be stopped for logout", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* Effect.promise(() => harness.store.save(harness.credentials)); - const result = yield* Effect.exit( - harness.auth.controller.logout( - Effect.fail( - new ProviderSetupError({ - instanceId, - operation: "stop", - detail: "Unable to stop sessions", - }), - ), - ), - ); - expect(Exit.isFailure(result)).toBe(true); - expect(yield* harness.auth.requireApiKey).toBe("browser-key"); - }).pipe(Effect.scoped), - ); - - it.effect( - "prefers explicit API keys and prevents browser login from silently overriding them", - () => - Effect.gen(function* () { - const harness = yield* makeHarness({ apiKey: " explicit-key " }); - yield* Effect.promise(() => harness.store.save(harness.credentials)); - expect(yield* harness.auth.requireApiKey).toBe("explicit-key"); - expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true); - expect( - Exit.isFailure(yield* Effect.exit(harness.auth.controller.logout(Effect.void))), - ).toBe(true); - expect(harness.loginOptions()).toBeUndefined(); - expect((yield* Effect.promise(() => harness.store.load()))?.apiKey).toBe("browser-key"); - }).pipe(Effect.scoped), - ); - - it.effect("ignores expired browser credentials and rejects disabled sign-in", () => - Effect.gen(function* () { - const harness = yield* makeHarness({ enabled: false }); - yield* Effect.promise(() => - harness.store.save({ ...harness.credentials, apiKeyExpiresAtMs: 0 }), - ); - expect(yield* harness.auth.readApiKey).toBeUndefined(); - expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true); - }).pipe(Effect.scoped), - ); - - it.effect("aborts pending login when the provider instance is disposed", () => - Effect.gen(function* () { - const scope = yield* Scope.make(); - const harness = yield* makeHarness().pipe(Effect.provideService(Scope.Scope, scope)); - yield* harness.auth.controller.start(owner); - yield* phase(harness.auth, "waiting"); - yield* Scope.close(scope, Exit.void); - expect(harness.loginOptions()?.signal?.aborted).toBe(true); - yield* Effect.sync(() => harness.complete.resolve()); - yield* Effect.promise(() => harness.returned.promise); - expect(yield* harness.auth.readApiKey).toBeUndefined(); - }), - ); -}); diff --git a/apps/server/src/provider/CursorAuth.ts b/apps/server/src/provider/CursorAuth.ts deleted file mode 100644 index 14988170b1eba759ea4fbf48b89cde3eba7d7776..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/CursorAuth.ts +++ /dev/null @@ -1,495 +0,0 @@ -import type { SdkCredentialStore, SdkLoginOptions, SdkLoginResult } from "@cursor/sdk"; -import { - ProviderSetupError, - type ProviderAuthState, - type ProviderInstanceId, -} from "@t3tools/contracts"; -import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; -import * as Queue from "effect/Queue"; -import * as Scope from "effect/Scope"; -import * as Semaphore from "effect/Semaphore"; -import * as Stream from "effect/Stream"; -import * as SubscriptionRef from "effect/SubscriptionRef"; - -import { Cursor, InMemoryCredentialStore } from "./cursorSdk.ts"; -import type { ProviderAuthController } from "./ProviderAuthService.ts"; - -const AUTH_TIMEOUT_MS = 300_000; - -interface AuthFlow { - readonly id: string; - readonly owner: string; - fiber?: Fiber.Fiber; -} - -export interface CursorAuth { - readonly controller: ProviderAuthController; - readonly readApiKey: Effect.Effect; - readonly requireApiKey: Effect.Effect; - readonly usesApiKey: boolean; - readonly withAccess: ( - task: Effect.Effect, - ) => Effect.Effect; -} - -export interface CursorAuthOptions { - readonly instanceId: ProviderInstanceId; - readonly displayName: string; - readonly enabled: boolean; - readonly apiKey?: string; - readonly store: SdkCredentialStore; - readonly credentialBinding?: NonNullable; - readonly onChanged: (signedIn: boolean) => Effect.Effect; - readonly login?: (options: SdkLoginOptions) => Promise; -} - -/** Each instance owns its browser credential; explicit API keys always take precedence. */ -export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: CursorAuthOptions) { - const scope = yield* Scope.Scope; - const crypto = yield* Crypto.Crypto; - const lock = yield* Semaphore.make(1); - const configuredKey = options.apiKey?.trim() || undefined; - const emptyState: ProviderAuthState = { - instanceId: options.instanceId, - phase: "idle", - flowId: null, - authorizationUrl: null, - expiresAt: null, - message: null, - }; - const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: emptyState }); - let active: AuthFlow | undefined; - let operation: "idle" | "login" | "stopping" | "closed" = "idle"; - const sessions = new Set(); - - const publish = (flow: AuthFlow, patch: Partial) => - SubscriptionRef.update(snapshot, (current) => ({ - owner: flow.owner, - state: { ...current.state, ...patch }, - })); - - const readApiKey = Effect.gen(function* () { - if (configuredKey) return configuredKey; - const credentials = yield* Effect.tryPromise({ - try: () => options.store.load(), - catch: (cause) => - new ProviderSetupError({ - instanceId: options.instanceId, - operation: "credentials", - detail: "Could not read the Cursor sign-in. Try signing in again.", - cause, - }), - }); - const now = yield* Clock.currentTimeMillis; - return credentials && - (credentials.apiKeyExpiresAtMs === undefined || credentials.apiKeyExpiresAtMs > now) - ? credentials.apiKey - : undefined; - }); - - const requireApiKey = Effect.gen(function* () { - if (operation !== "idle") { - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: "credentials", - detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.", - }); - } - const apiKey = yield* readApiKey; - if (!apiKey) { - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: "credentials", - detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", - }); - } - return apiKey; - }); - - // Keep SDK sessions and one-shot text generation inside instance-owned scopes. - // Closing admission before draining these scopes prevents cached credentials surviving sign-out. - const withAccess: CursorAuth["withAccess"] = (task) => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const parent = yield* Scope.Scope; - const child = yield* lock.withPermits(1)( - Effect.gen(function* () { - if (operation !== "idle") - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: "session", - detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.", - }); - const child = yield* Scope.make(); - sessions.add(child); - yield* Scope.addFinalizer( - child, - Effect.sync(() => { - sessions.delete(child); - }), - ); - yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); - return child; - }), - ); - const fiber = yield* restore(task).pipe( - Effect.provideService(Scope.Scope, child), - Effect.forkIn(child), - ); - return yield* restore(Fiber.await(fiber)).pipe( - Effect.flatMap((result) => result), - Effect.onExit((result) => - Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, - ), - ); - }), - ); - const stopSessionsWithCredentials = Effect.suspend(() => - Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { - discard: true, - concurrency: "unbounded", - }), - ); - - const runLogin = (flow: AuthFlow, stopSessions: Effect.Effect) => - Effect.gen(function* () { - yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials)); - // The SDK first writes into memory. Cancellation must not save a late login result. - const pendingStore = new InMemoryCredentialStore(); - const urls = yield* Queue.unbounded(); - yield* Queue.take(urls).pipe( - Effect.flatMap((authorizationUrl) => - lock.withPermits(1)( - Effect.gen(function* () { - if (active !== flow) return; - yield* publish(flow, { - phase: "waiting", - authorizationUrl, - message: - "Open the Cursor sign-in page and finish signing in. This page updates automatically.", - }); - }), - ), - ), - Effect.forever, - Effect.forkScoped, - ); - yield* Effect.tryPromise({ - try: (signal) => - (options.login ?? Cursor.auth.login)({ - openBrowser: false, - store: pendingStore, - signal, - apiKeyName: `T3 Code - ${options.displayName}`, - onLoginUrl: (authorizationUrl) => { - if (active === flow) Queue.offerUnsafe(urls, authorizationUrl); - }, - }), - catch: (cause) => - new ProviderSetupError({ - instanceId: options.instanceId, - operation: "start", - detail: "Cursor sign-in failed. Start sign-in again.", - cause, - }), - }); - // Commit and publish together; interrupting a store Promise cannot stop a late write. - yield* lock.withPermits(1)( - Effect.gen(function* () { - if (active !== flow) return; - yield* publish(flow, { - phase: "verifying", - authorizationUrl: null, - message: "Checking Cursor sign-in.", - }); - yield* Effect.tryPromise({ - try: async () => { - const credentials = await pendingStore.load(); - if (!credentials) throw new Error("Cursor login did not return credentials"); - await options.store.save(credentials); - }, - catch: (cause) => - new ProviderSetupError({ - instanceId: options.instanceId, - operation: "start", - detail: "Could not save the Cursor sign-in. Try again.", - cause, - }), - }); - const verified = yield* options.onChanged(true).pipe(Effect.exit); - if (Exit.isFailure(verified)) { - yield* Effect.tryPromise({ - try: () => options.store.clear(), - catch: (cause) => - new ProviderSetupError({ - instanceId: options.instanceId, - operation: "start", - detail: "Could not clear the rejected Cursor sign-in. Try signing out.", - cause, - }), - }); - return yield* Effect.failCause(verified.cause); - } - active = undefined; - operation = "idle"; - yield* publish(flow, { - phase: "succeeded", - authorizationUrl: null, - expiresAt: null, - message: "Signed in with Cursor.", - }); - }).pipe(Effect.uninterruptible), - ); - }).pipe( - Effect.scoped, - Effect.timeoutOrElse({ - duration: AUTH_TIMEOUT_MS, - orElse: () => - Effect.fail( - new ProviderSetupError({ - instanceId: options.instanceId, - operation: "start", - detail: "Cursor sign-in expired. Start sign-in again.", - }), - ), - }), - Effect.exit, - Effect.flatMap((result) => - lock.withPermits(1)( - Effect.gen(function* () { - if (active !== flow) return; - active = undefined; - operation = "idle"; - yield* publish(flow, { - phase: Exit.isSuccess(result) ? "succeeded" : "failed", - authorizationUrl: null, - expiresAt: null, - message: Exit.isSuccess(result) - ? "Signed in with Cursor." - : "Cursor sign-in failed or expired. Start sign-in again.", - }); - }), - ), - ), - ); - - const requireFlow = (owner: string, id: string, name: string) => - Effect.gen(function* () { - if (!active || active.owner !== owner || active.id !== id) { - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: name, - detail: "This sign-in is no longer active in this client.", - }); - } - return active; - }); - - const controller: ProviderAuthController = { - credentialBinding: options.credentialBinding ?? { - owner: "t3", - key: `cursor:${options.instanceId}`, - }, - isChangingCredentials: Effect.sync(() => operation !== "idle"), - start: (owner, stopSessions = Effect.void) => - lock.withPermits(1)( - Effect.uninterruptible( - Effect.gen(function* () { - if (!options.enabled) - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: "start", - detail: "Enable Cursor before signing in.", - }); - if (configuredKey) - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: "start", - detail: - "Remove CURSOR_API_KEY from this provider's environment before using browser sign-in.", - }); - if (active?.owner === owner && operation === "login") return snapshot.value.state; - if (operation !== "idle") - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: "start", - detail: "Cursor setup is already in progress.", - }); - const id = yield* crypto.randomUUIDv4.pipe( - Effect.mapError( - (cause) => - new ProviderSetupError({ - instanceId: options.instanceId, - operation: "start", - detail: "Could not start Cursor sign-in. Try again.", - cause, - }), - ), - ); - const expiresAt = DateTime.formatIso( - DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS), - ); - const flow: AuthFlow = { id, owner }; - active = flow; - operation = "login"; - const state: ProviderAuthState = { - ...emptyState, - phase: "starting", - flowId: id, - expiresAt, - message: "Starting Cursor sign-in.", - }; - yield* SubscriptionRef.set(snapshot, { owner, state }); - flow.fiber = yield* runLogin(flow, stopSessions).pipe( - Effect.interruptible, - Effect.forkIn(scope), - ); - return state; - }), - ), - ), - complete: () => - Effect.fail( - new ProviderSetupError({ - instanceId: options.instanceId, - operation: "complete", - detail: "Finish signing in on the Cursor website. No redirect URL is needed.", - }), - ), - cancel: (owner, id) => - Effect.uninterruptible( - Effect.gen(function* () { - const flow = yield* lock.withPermits(1)( - Effect.gen(function* () { - const flow = yield* requireFlow(owner, id, "cancel"); - active = undefined; - operation = "stopping"; - yield* publish(flow, { - phase: "cancelled", - authorizationUrl: null, - expiresAt: null, - message: "Cursor sign-in was cancelled.", - }); - return flow; - }), - ); - if (flow.fiber) yield* Fiber.interrupt(flow.fiber); - operation = "idle"; - return snapshot.value.state; - }), - ), - logout: (stopSessions) => - Effect.uninterruptible( - Effect.gen(function* () { - const flow = yield* lock.withPermits(1)( - Effect.gen(function* () { - if (configuredKey) - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: "logout", - detail: - "Remove CURSOR_API_KEY from this provider's environment to disconnect it.", - }); - if (operation !== "idle" && operation !== "login") - return yield* new ProviderSetupError({ - instanceId: options.instanceId, - operation: "logout", - detail: "Cursor setup is already stopping.", - }); - operation = "stopping"; - const flow = active; - active = undefined; - return flow; - }), - ); - const result = yield* Effect.gen(function* () { - if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); - yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials)); - yield* Effect.tryPromise({ - try: () => options.store.clear(), - catch: (cause) => - new ProviderSetupError({ - instanceId: options.instanceId, - operation: "logout", - detail: "Could not clear the Cursor sign-in. Try again.", - cause, - }), - }); - yield* options.onChanged(false); - }).pipe(Effect.exit); - operation = "idle"; - const state: ProviderAuthState = { - ...emptyState, - phase: Exit.isSuccess(result) ? "idle" : "failed", - message: Exit.isSuccess(result) - ? "Signed out of Cursor." - : "Cursor sign-out failed. Try again.", - }; - yield* SubscriptionRef.set(snapshot, { owner: null, state }); - if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); - return state; - }), - ), - subscribe: (owner) => - SubscriptionRef.changes(snapshot).pipe( - Stream.map((current) => { - const state = { - ...current.state, - credentialOwner: "t3" as const, - methods: [ - { - id: "browser", - name: "Sign in with Cursor", - description: null, - type: "agent" as const, - }, - ], - interaction: - current.state.phase === "waiting" && - current.state.authorizationUrl && - current.state.flowId - ? { - type: "browser" as const, - id: current.state.flowId, - url: current.state.authorizationUrl, - requiresConsent: false, - } - : null, - }; - if (current.owner === null || current.owner === owner) return state; - return { - ...state, - interaction: null, - flowId: null, - authorizationUrl: null, - expiresAt: null, - message: active ? "Sign-in is in progress in another client." : current.state.message, - }; - }), - ), - isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", - }; - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - operation = "closed"; - const flow = active; - active = undefined; - if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); - yield* stopSessionsWithCredentials; - }), - ); - - return { - controller, - readApiKey, - requireApiKey, - withAccess, - usesApiKey: configuredKey !== undefined, - } satisfies CursorAuth; -}); diff --git a/apps/server/src/provider/CursorCredentialStore.test.ts b/apps/server/src/provider/CursorCredentialStore.test.ts deleted file mode 100644 index e3c0995ba0722f5e6ac6a2789553bf9851bf9e1e..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/CursorCredentialStore.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; -import { ProviderInstanceId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Option from "effect/Option"; - -import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; -import { makeCursorCredentialStore } from "./CursorCredentialStore.ts"; - -const makeSecrets = () => { - const data = new Map(); - return { - data, - secrets: ServerSecretStore.ServerSecretStore.of({ - get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), - set: (key, bytes) => - Effect.sync(() => { - data.set(key, bytes); - }), - remove: (key) => - Effect.sync(() => { - data.delete(key); - }), - create: () => Effect.die("unused"), - getOrCreateRandom: () => Effect.die("unused"), - }), - }; -}; - -const legacyCredentials = { - version: 1 as const, - backendUrl: "https://api.cursor.com", - apiKey: "test-only-legacy-key", - createdAtMs: 100, -}; -// The SDK's FileCredentialStore writes pretty-printed JSON. -const legacyFileText = `{ - "version": 1, - "backendUrl": "https://api.cursor.com", - "apiKey": "test-only-legacy-key", - "createdAtMs": 100 -}`; - -it.effect.each([ - { - name: "imports a legacy sign-in", - legacy: legacyFileText, - stored: undefined, - expected: "test-only-legacy-key", - }, - { - name: "keeps a newer stored sign-in", - legacy: legacyFileText, - stored: legacyFileText.replace("test-only-legacy-key", "test-only-current-key"), - expected: "test-only-current-key", - }, - { - name: "replaces a damaged stored sign-in", - legacy: legacyFileText, - stored: "damaged", - expected: "test-only-legacy-key", - }, - { - name: "discards a damaged legacy file", - legacy: "damaged", - stored: undefined, - expected: undefined, - }, -])("$name and deletes the legacy file", ({ legacy, stored, expected }) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const legacyFile = path.join(yield* fileSystem.makeTempDirectoryScoped(), "cursor.json"); - yield* fileSystem.writeFileString(legacyFile, legacy); - const { secrets } = makeSecrets(); - const instanceId = ProviderInstanceId.make("personal"); - if (stored !== undefined) { - const current = yield* makeCursorCredentialStore(instanceId).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - ); - yield* secrets.set(current.binding.key, new TextEncoder().encode(stored)); - } - const migrated = yield* makeCursorCredentialStore(instanceId, legacyFile).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - ); - assert.strictEqual((yield* Effect.tryPromise(() => migrated.store.load()))?.apiKey, expected); - assert.isFalse(yield* fileSystem.exists(legacyFile)); - }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), -); - -it.effect( - "restores SDK credentials in a new controller and keeps another account when signing out", - () => - Effect.gen(function* () { - const data = new Map(); - const secrets = ServerSecretStore.ServerSecretStore.of({ - get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), - set: (key, bytes) => - Effect.sync(() => { - data.set(key, bytes); - }), - remove: (key) => - Effect.sync(() => { - data.delete(key); - }), - create: () => Effect.die("unused"), - getOrCreateRandom: () => Effect.die("unused"), - }); - const makeStore = (id: string) => - makeCursorCredentialStore(ProviderInstanceId.make(id)).pipe( - Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), - ); - const personal = yield* makeStore("personal"); - const work = yield* makeStore("work"); - const credentials = { - version: 1 as const, - backendUrl: "https://api.cursor.com", - apiKey: "test-only-key", - createdAtMs: 100, - apiKeyExpiresAtMs: 1000, - email: "test@example.com", - }; - yield* Effect.tryPromise(() => personal.store.save(credentials)); - yield* Effect.tryPromise(() => - work.store.save({ ...credentials, apiKey: "test-only-work-key" }), - ); - const restored = yield* makeStore("personal"); - assert.deepEqual(yield* Effect.tryPromise(() => restored.store.load()), credentials); - data.set(personal.binding.key, new TextEncoder().encode("damaged credential")); - assert.isUndefined(yield* Effect.tryPromise(() => restored.store.load())); - yield* Effect.tryPromise(() => restored.store.save(credentials)); - yield* Effect.tryPromise(() => restored.store.clear()); - assert.isUndefined(yield* Effect.tryPromise(() => personal.store.load())); - assert.strictEqual( - (yield* Effect.tryPromise(() => work.store.load()))?.apiKey, - "test-only-work-key", - ); - }).pipe(Effect.provide(NodeServices.layer)), -); diff --git a/apps/server/src/provider/CursorCredentialStore.ts b/apps/server/src/provider/CursorCredentialStore.ts deleted file mode 100644 index 56c3014deedb93edb023d2b455a858352c0dd1cd..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/CursorCredentialStore.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { SdkCredentialStore } from "@cursor/sdk"; -import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; - -const Credentials = Schema.fromJsonString( - Schema.Struct({ - version: Schema.Literal(1), - backendUrl: Schema.String, - apiKey: Schema.String, - createdAtMs: Schema.Finite, - apiKeyExpiresAtMs: Schema.optionalKey(Schema.Finite), - email: Schema.optionalKey(Schema.String), - }), -); - -const decodeCredentials = Schema.decodeUnknownEffect(Credentials); -const encodeCredentials = Schema.encodeEffect(Credentials); - -/** - * The SDK owns the credential format; persistence uses the environment's secret store. - * `legacyFile` is the SDK file store earlier versions used; its sign-in moves into - * the secret store once and the file is deleted. - */ -export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")(function* ( - instanceId: ProviderInstanceId, - legacyFile?: string, -) { - const credentials = yield* ProviderCredentialStore.make("cursor", instanceId); - if (legacyFile !== undefined) { - const fileSystem = yield* FileSystem.FileSystem; - yield* Effect.gen(function* () { - const legacy = yield* fileSystem.readFileString(legacyFile).pipe(Effect.option); - if (Option.isNone(legacy)) return; - // A stored sign-in wins unless it is damaged; the SDK would ignore it anyway. - const stored = yield* credentials.get; - const storedIsValid = - Option.isSome(stored) && - Option.isSome( - yield* decodeCredentials(new TextDecoder().decode(stored.value)).pipe(Effect.option), - ); - if ( - !storedIsValid && - Option.isSome(yield* decodeCredentials(legacy.value).pipe(Effect.option)) - ) { - yield* credentials.set(new TextEncoder().encode(legacy.value)); - } - yield* fileSystem.remove(legacyFile); - }).pipe( - Effect.catch((error) => - Effect.logWarning("Could not migrate legacy Cursor credentials", error), - ), - ); - } - const store: SdkCredentialStore = { - load: () => - Effect.runPromise( - credentials.get.pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed(undefined), - onSome: (bytes) => - decodeCredentials(new TextDecoder().decode(bytes)).pipe( - Effect.orElseSucceed(() => undefined), - ), - }), - ), - ), - ), - save: (value) => - Effect.runPromise( - encodeCredentials(value).pipe( - Effect.mapError( - () => - new ProviderSetupError({ - instanceId, - operation: "credentials", - detail: "Cursor returned an unsupported credential format.", - }), - ), - Effect.flatMap((encoded) => credentials.set(new TextEncoder().encode(encoded))), - ), - ), - clear: () => Effect.runPromise(credentials.remove), - }; - return { store, binding: credentials.binding }; -}); diff --git a/apps/server/src/provider/CursorProvider.test.ts b/apps/server/src/provider/CursorProvider.test.ts deleted file mode 100644 index 7fe35db9ea324bf85c965361aa260bd8d88012b7..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/CursorProvider.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import type { SDKModel } from "@cursor/sdk"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; -import type { CursorSettings } from "@t3tools/contracts"; -import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; - -import { - buildCursorCapabilitiesFromSdkModel, - buildCursorDiscoveredModelsFromSdk, - buildCursorProviderSnapshot, - buildInitialCursorProviderSnapshot, - checkCursorProviderStatus, -} from "./CursorProvider.ts"; -import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; - -const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema); - -function selectDescriptor( - id: string, - label: string, - options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, -) { - return { - id, - label, - type: "select" as const, - options: [...options], - ...(options.find((option) => option.isDefault)?.id - ? { currentValue: options.find((option) => option.isDefault)?.id } - : {}), - }; -} - -function booleanDescriptor(id: string, label: string, currentValue?: boolean) { - return { - id, - label, - type: "boolean" as const, - ...(typeof currentValue === "boolean" ? { currentValue } : {}), - }; -} - -const baseCursorSettings: CursorSettings = decodeCursorSettings({ - enabled: true, - customModels: [], -}); - -const sdkParameterizedModel = { - id: "claude-opus-4-8", - displayName: "Opus 4.8", - parameters: [ - { - id: "thinking", - displayName: "Thinking", - values: [{ value: "false" }, { value: "true" }], - }, - { - id: "context", - displayName: "Context", - values: [ - { value: "300k", displayName: "300K" }, - { value: "1m", displayName: "1M" }, - ], - }, - { - id: "effort", - displayName: "Effort", - values: [ - { value: "low", displayName: "Low" }, - { value: "high", displayName: "High" }, - ], - }, - { - id: "fast", - displayName: "Fast", - values: [{ value: "false" }, { value: "true", displayName: "Fast" }], - }, - ], - variants: [ - { - displayName: "Opus 4.8", - isDefault: true, - params: [ - { id: "thinking", value: "true" }, - { id: "context", value: "1m" }, - { id: "effort", value: "high" }, - { id: "fast", value: "false" }, - ], - }, - ], -} satisfies SDKModel; - -describe("buildInitialCursorProviderSnapshot", () => { - it.effect("uses SDK-specific pending status copy", () => - Effect.gen(function* () { - const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings); - - expect(provider).toMatchObject({ - status: "warning", - message: "Checking Cursor SDK availability...", - }); - }), - ); -}); - -describe("buildCursorProviderSnapshot", () => { - it("downgrades ready status to warning when SDK model discovery returns no models", () => { - expect( - buildCursorProviderSnapshot({ - checkedAt: "2026-01-01T00:00:00.000Z", - cursorSettings: baseCursorSettings, - parsed: { - version: null, - status: "ready", - auth: { status: "authenticated", type: "api-key", label: "Cursor API key" }, - }, - discoveryWarning: "Cursor SDK model discovery returned no built-in models.", - }), - ).toMatchObject({ - status: "warning", - message: "Cursor SDK model discovery returned no built-in models.", - models: [], - supportsConversationRollback: false, - }); - }); -}); - -describe("Cursor SDK model discovery", () => { - it("maps native SDK parameter ids and default variant values to model capabilities", () => { - expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual( - createModelCapabilities({ - optionDescriptors: [ - selectDescriptor("effort", "Effort", [ - { id: "low", label: "Low" }, - { id: "high", label: "High", isDefault: true }, - ]), - selectDescriptor("contextWindow", "Context", [ - { id: "300k", label: "300K" }, - { id: "1m", label: "1M", isDefault: true }, - ]), - booleanDescriptor("fastMode", "Fast", false), - booleanDescriptor("thinking", "Thinking", true), - ], - }), - ); - }); - - it("filters invalid and duplicate SDK model entries", () => { - expect( - buildCursorDiscoveredModelsFromSdk([ - sdkParameterizedModel, - { ...sdkParameterizedModel, displayName: "Duplicate" }, - { id: "", displayName: "Invalid" }, - ]), - ).toEqual([ - { - slug: "claude-opus-4-8", - name: "Opus 4.8", - isCustom: false, - capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel), - }, - ]); - }); -}); - -describe("checkCursorProviderStatus", () => { - it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () => - Effect.gen(function* () { - const provider = yield* checkCursorProviderStatus( - { - ...baseCursorSettings, - customModels: ["internal/cursor-model"], - }, - { CURSOR_API_KEY: "test-cursor-key" }, - ).pipe( - Effect.provide( - CursorSdkCatalog.layerTest((apiKey) => { - expect(apiKey).toBe("test-cursor-key"); - return Effect.succeed({ - user: { - apiKeyName: "test-key", - userEmail: "cursor@example.com", - createdAt: "2026-01-01T00:00:00.000Z", - }, - models: [sdkParameterizedModel], - }); - }), - ), - ); - - expect(provider).toMatchObject({ - status: "ready", - auth: { - status: "authenticated", - type: "api-key", - label: "Cursor API key (test-key)", - email: "cursor@example.com", - }, - models: [ - { slug: "claude-opus-4-8", isCustom: false }, - { slug: "internal/cursor-model", isCustom: true }, - ], - }); - }), - ); - - it.effect("surfaces SDK authentication failures", () => - Effect.gen(function* () { - const provider = yield* checkCursorProviderStatus(baseCursorSettings, { - CURSOR_API_KEY: "invalid-test-key", - }).pipe( - Effect.provide( - CursorSdkCatalog.layerTest(() => - Effect.fail( - new CursorSdkCatalog.CursorSdkCatalogError({ - authenticationFailure: true, - cause: new Error("unauthorized"), - }), - ), - ), - ), - ); - - expect(provider).toMatchObject({ - status: "error", - auth: { status: "unauthenticated" }, - message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.", - }); - }), - ); - - it.effect("requires a Cursor API key without probing any external Cursor binary", () => - Effect.gen(function* () { - const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe( - Effect.provide( - CursorSdkCatalog.layerTest(() => - Effect.die("SDK catalog must not be used without CURSOR_API_KEY"), - ), - ), - ); - - expect(provider).toMatchObject({ - installed: true, - status: "error", - auth: { status: "unauthenticated" }, - message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", - }); - }), - ); -}); diff --git a/apps/server/src/provider/CursorProvider.ts b/apps/server/src/provider/CursorProvider.ts deleted file mode 100644 index 473a7bb8c5aae0ea255d111bb196b7e65fb3053c..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/CursorProvider.ts +++ /dev/null @@ -1,350 +0,0 @@ -import type { SDKModel, SDKUser } from "@cursor/sdk"; -import type { - CursorSettings, - ModelCapabilities, - ProviderOptionDescriptor, - ServerProviderAuth, - ServerProviderModel, - ServerProviderState, -} from "@t3tools/contracts"; -import { createModelCapabilities } from "@t3tools/shared/model"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; - -import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./cursorSdkModel.ts"; -import { - buildBooleanOptionDescriptor, - buildSelectOptionDescriptor, - buildServerProvider, - providerModelsFromSettings, - type ServerProviderDraft, -} from "@t3tools/provider-core/server/snapshotProbe"; -import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; - -const CURSOR_PRESENTATION = { - displayName: "Cursor", - supportsConversationRollback: false, - showInteractionModeToggle: true, -} as const; -const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ - optionDescriptors: [], -}); - -const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000; - -export function buildInitialCursorProviderSnapshot( - cursorSettings: CursorSettings, -): Effect.Effect { - return Effect.gen(function* () { - const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); - const models = getCursorFallbackModels(cursorSettings); - - if (!cursorSettings.enabled) { - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Cursor is disabled in T3 Code settings.", - }, - }); - } - - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: true, - checkedAt, - models, - probe: { - installed: true, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Checking Cursor SDK availability...", - }, - }); - }); -} - -function getCursorFallbackModels( - cursorSettings: Pick, -): ReadonlyArray { - return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); -} - -function toTitleCaseWords(value: string): string { - const parts: Array = []; - for (const part of value.split(/[\s_-]+/g)) { - if (part.length > 0) { - parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()); - } - } - return parts.join(" "); -} - -function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined { - return model.variants - ?.find((variant) => variant.isDefault) - ?.params.find((parameter) => parameter.id === parameterId)?.value; -} - -export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities { - const seen = new Set(); - const optionDescriptors: Array = []; - const parameters = (model.parameters ?? []) - .map((parameter, index) => ({ parameter, index })) - .toSorted( - (left, right) => - cursorSdkParameterPriority(left.parameter.id) - - cursorSdkParameterPriority(right.parameter.id) || left.index - right.index, - ); - for (const { parameter } of parameters) { - const nativeId = parameter.id.trim(); - const id = cursorSdkProviderOptionId(nativeId); - if (!nativeId || !id || seen.has(id)) { - continue; - } - seen.add(id); - - const values = parameter.values.flatMap((entry) => { - const value = entry.value.trim(); - if (!value) { - return []; - } - return [ - { - value, - label: entry.displayName?.trim() || value, - }, - ]; - }); - if (values.length === 0) { - continue; - } - - const label = parameter.displayName?.trim() || toTitleCaseWords(id); - const defaultValue = cursorSdkDefaultParameterValue(model, nativeId); - const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase())); - if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) { - if (defaultValue === "true" || defaultValue === "false") { - optionDescriptors.push( - buildBooleanOptionDescriptor({ - id, - label, - currentValue: defaultValue === "true", - }), - ); - } else { - optionDescriptors.push(buildBooleanOptionDescriptor({ id, label })); - } - continue; - } - - optionDescriptors.push( - buildSelectOptionDescriptor({ - id, - label, - options: values.map((entry) => ({ - ...entry, - ...(entry.value === defaultValue ? { isDefault: true } : {}), - })), - }), - ); - } - - return createModelCapabilities({ optionDescriptors }); -} - -export function buildCursorDiscoveredModelsFromSdk( - models: ReadonlyArray, -): ReadonlyArray { - const seen = new Set(); - return models.flatMap((model) => { - const slug = model.id.trim(); - const name = model.displayName.trim(); - if (!slug || !name || seen.has(slug)) { - return []; - } - seen.add(slug); - return [ - { - slug, - name, - isCustom: false, - capabilities: buildCursorCapabilitiesFromSdkModel(model), - } satisfies ServerProviderModel, - ]; - }); -} - -function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth { - const email = user.userEmail?.trim(); - const apiKeyName = user.apiKeyName.trim(); - return { - status: "authenticated", - type, - label: - type === "browser" - ? "Cursor account" - : apiKeyName - ? `Cursor API key (${apiKeyName})` - : "Cursor API key", - ...(email ? { email } : {}), - }; -} - -interface CursorProviderProbeResult { - readonly version: string | null; - readonly status: Exclude; - readonly auth: ServerProviderAuth; - readonly message?: string; -} - -function joinProviderMessages(...messages: ReadonlyArray): string | undefined { - const parts: Array = []; - for (const message of messages) { - const trimmed = message?.trim(); - if (trimmed) { - parts.push(trimmed); - } - } - return parts.length > 0 ? parts.join(" ") : undefined; -} - -export function buildCursorProviderSnapshot(input: { - readonly checkedAt: string; - readonly cursorSettings: CursorSettings; - readonly parsed: CursorProviderProbeResult; - readonly discoveredModels?: ReadonlyArray; - readonly discoveryWarning?: string; -}): ServerProviderDraft { - const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: input.cursorSettings.enabled, - checkedAt: input.checkedAt, - models: providerModelsFromSettings( - input.discoveredModels ?? [], - input.cursorSettings.customModels, - EMPTY_CAPABILITIES, - ), - probe: { - installed: true, - version: input.parsed.version, - status: - input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, - auth: input.parsed.auth, - ...(message ? { message } : {}), - }, - }); -} - -export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( - cursorSettings: CursorSettings, - environment?: NodeJS.ProcessEnv, - authenticationType: "api-key" | "browser" = "api-key", -): Effect.fn.Return { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - const fallbackModels = getCursorFallbackModels(cursorSettings); - - if (!cursorSettings.enabled) { - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: false, - checkedAt, - models: fallbackModels, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Cursor is disabled in T3 Code settings.", - }, - }); - } - - const sdkApiKey = environment?.CURSOR_API_KEY?.trim(); - if (!sdkApiKey) { - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unauthenticated" }, - message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", - }, - }); - } - - const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; - const catalogResult = yield* sdkCatalog - .read(sdkApiKey) - .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); - - if (Result.isFailure(catalogResult)) { - yield* Effect.logWarning("Cursor SDK catalog probe failed", { - cause: catalogResult.failure.cause, - }); - const authenticationFailure = catalogResult.failure.authenticationFailure; - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: authenticationFailure ? "unauthenticated" : "unknown" }, - message: authenticationFailure - ? authenticationType === "browser" - ? "Cursor sign-in expired or was rejected. Sign in again in provider settings." - : "Cursor SDK authentication failed. Check CURSOR_API_KEY." - : "Cursor SDK catalog request failed. Check server logs for details.", - }, - }); - } - - if (Option.isNone(catalogResult.success)) { - return buildServerProvider({ - presentation: CURSOR_PRESENTATION, - enabled: cursorSettings.enabled, - checkedAt, - models: fallbackModels, - probe: { - installed: true, - version: null, - status: "error", - auth: { status: "unknown" }, - message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`, - }, - }); - } - - const snapshot = catalogResult.success.value; - const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models); - return buildCursorProviderSnapshot({ - checkedAt, - cursorSettings, - parsed: { - version: null, - status: "ready", - auth: cursorSdkAuth(snapshot.user, authenticationType), - }, - discoveredModels, - ...(discoveredModels.length === 0 - ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." } - : {}), - }); -}); diff --git a/apps/server/src/provider/CursorSdkCatalog.test.ts b/apps/server/src/provider/CursorSdkCatalog.test.ts deleted file mode 100644 index 6cbd24f8b6be705b93f1cd4a48799b695587ded5..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/CursorSdkCatalog.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { SDKModel, SDKUser } from "@cursor/sdk"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; - -import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; - -const user = { - apiKeyName: "test-key", - userEmail: "cursor@example.com", - createdAt: "2026-01-01T00:00:00.000Z", -} satisfies SDKUser; - -const model = { - id: "claude-opus-4-8", - displayName: "Opus 4.8", - parameters: [], -} satisfies SDKModel; - -const catalogError = () => - new CursorSdkCatalog.CursorSdkCatalogError({ - authenticationFailure: false, - cause: new Error("catalog unavailable"), - }); - -describe("CursorSdkCatalog", () => { - it.effect("keeps the user probe fresh while caching models by API key", () => - Effect.gen(function* () { - let userCalls = 0; - let modelCalls = 0; - const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ - readUser: () => Effect.sync(() => ((userCalls += 1), user)), - readModels: () => Effect.sync(() => ((modelCalls += 1), [model])), - }); - - yield* catalog.read("first-key"); - yield* catalog.read("first-key"); - yield* catalog.read("second-key"); - - expect(userCalls).toBe(3); - expect(modelCalls).toBe(2); - }), - ); - - it.effect("does not cache failed or empty model discovery", () => - Effect.gen(function* () { - let modelCalls = 0; - const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ - readUser: () => Effect.succeed(user), - readModels: () => - Effect.suspend(() => { - modelCalls += 1; - if (modelCalls === 1) return Effect.fail(catalogError()); - if (modelCalls === 2) return Effect.succeed([]); - return Effect.succeed([model]); - }), - }); - - expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true); - expect((yield* catalog.read("test-key")).models).toEqual([]); - expect((yield* catalog.read("test-key")).models).toEqual([model]); - expect((yield* catalog.read("test-key")).models).toEqual([model]); - expect(modelCalls).toBe(3); - }), - ); -}); diff --git a/apps/server/src/provider/CursorSdkCatalog.ts b/apps/server/src/provider/CursorSdkCatalog.ts deleted file mode 100644 index e866955dd55f10bb60311435d9869f422ff04fce..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/CursorSdkCatalog.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { SDKModel, SDKUser } from "@cursor/sdk"; -import * as Cache from "effect/Cache"; -import * as Context from "effect/Context"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; -import * as Layer from "effect/Layer"; -import * as Schema from "effect/Schema"; - -import { AuthenticationError, Cursor, CursorSdkError } from "./cursorSdk.ts"; - -export interface CursorSdkCatalogSnapshot { - readonly user: SDKUser; - readonly models: ReadonlyArray; -} - -export class CursorSdkCatalogError extends Schema.TaggedError()( - "CursorSdkCatalogError", - { - authenticationFailure: Schema.Boolean, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return this.authenticationFailure - ? "Cursor SDK authentication failed." - : "Cursor SDK catalog request failed."; - } -} - -export interface CursorSdkCatalogShape { - readonly read: (apiKey: string) => Effect.Effect; -} - -export class CursorSdkCatalog extends Context.Service()( - "t3/provider/CursorSdkCatalog", -) {} - -function isAuthenticationFailure(cause: unknown): boolean { - return ( - cause instanceof AuthenticationError || - (cause instanceof CursorSdkError && cause.status === 401) - ); -} - -export interface CursorSdkCatalogProbes { - readonly readUser: (apiKey: string) => Effect.Effect; - readonly readModels: ( - apiKey: string, - ) => Effect.Effect, CursorSdkCatalogError>; -} - -const sdkRequest = (tryRequest: () => Promise) => - Effect.tryPromise({ - try: tryRequest, - catch: (cause) => - new CursorSdkCatalogError({ - authenticationFailure: isAuthenticationFailure(cause), - cause, - }), - }); - -const liveProbes: CursorSdkCatalogProbes = { - readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })), - readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })), -}; - -export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* ( - probes: CursorSdkCatalogProbes = liveProbes, -) { - const modelCache = yield* Cache.makeWith(probes.readModels, { - capacity: 32, - timeToLive: (exit) => - Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, - }); - return CursorSdkCatalog.of({ - read: (apiKey) => - Effect.all( - { - user: probes.readUser(apiKey), - models: Cache.get(modelCache, apiKey), - }, - { concurrency: "unbounded" }, - ), - }); -}); - -export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); - -export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer { - return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read })); -} diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index d08cacb60dcde9c5dd60d2ea532e0543f07c6dc3..33aa39f5598b081c3b5ae3017cfc0195e3f6453a 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -34,6 +34,7 @@ import * as ModelManifest from "../ModelManifest.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { AntigravityDriver } from "./AntigravityDriver.ts"; import * as ProviderHostLive from "../ProviderHostLive.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; const hostPlatform = HostProcessPlatform.defaultValue(); const windowsHost = hostPlatform === "win32"; @@ -264,7 +265,10 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { Layer.provideMerge(ModelManifest.layerTest), Layer.provideMerge(IdAllocator.layer), ); -const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); +const layerTest = ProviderHostLive.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(layerDeps), +); it.layer(layerTest)("AntigravityDriver", (it) => { it.effect.skipIf(windowsHost)( diff --git a/apps/server/src/provider/Drivers/CursorDriver.test.ts b/apps/server/src/provider/Drivers/CursorDriver.test.ts deleted file mode 100644 index 8aae15d1c575a51aaf05808df6f18b4abfc9f4bb..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Drivers/CursorDriver.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off -import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Stream from "effect/Stream"; -import { vi } from "vite-plus/test"; -import { HttpClient } from "effect/http"; -import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; - -import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; -import * as ServerConfig from "../../config.ts"; -import * as ServerSettings from "../../serverSettings.ts"; -import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import { CursorDriver } from "./CursorDriver.ts"; -import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; -import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; -import { Cursor } from "../cursorSdk.ts"; -import * as ProviderHostLive from "../ProviderHostLive.ts"; - -const layerDeps = ServerSecretStore.layer.pipe( - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3-cursor-driver-copy-command-", - }), - ), - Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(IdAllocator.layer), - Layer.provideMerge( - Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({ - open: () => Effect.die("Maintenance resolution must not open a Cursor session"), - }), - ), - Layer.provideMerge(ServerSettings.layerTest()), - Layer.provideMerge( - Layer.mock(BackgroundPolicy.BackgroundPolicy)({ - shouldRunScopeWork: () => Effect.succeed(false), - }), - ), - Layer.provideMerge( - Layer.succeed( - ProviderEventLoggers.ProviderEventLoggers, - ProviderEventLoggers.NoOpProviderEventLoggers, - ), - ), - Layer.provideMerge( - Layer.succeed( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("Disabled Cursor must not make an HTTP request")), - ), - ), -); -const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); - -it.layer(layerTest)("CursorDriver", (it) => { - it.effect( - "persists browser credentials, uses them for chat, and closes the SDK session on logout", - () => - Effect.gen(function* () { - const login = vi.spyOn(Cursor.auth, "login").mockImplementation(async (options) => { - options?.onLoginUrl?.("https://cursor.com/loginDeepControl?challenge=test-only"); - await options?.store?.save({ - version: 1, - backendUrl: "https://api2.cursor.sh", - apiKey: "instance-browser-key", - createdAtMs: 0, - apiKeyExpiresAtMs: 4_000_000_000_000, - email: "cursor@example.com", - }); - return { apiKey: "instance-browser-key", apiKeyExpiresAtMs: 4_000_000_000_000 }; - }); - const me = vi.spyOn(Cursor, "me").mockResolvedValue({ - apiKeyName: "T3 Code", - createdAt: "2026-01-01T00:00:00.000Z", - userEmail: "cursor@example.com", - }); - const models = vi - .spyOn(Cursor.models, "list") - .mockResolvedValue([{ id: "auto", displayName: "Auto" }]); - yield* Effect.addFinalizer(() => - Effect.sync(() => { - login.mockRestore(); - me.mockRestore(); - models.mockRestore(); - }), - ); - const input = { - instanceId: ProviderInstanceId.make("cursor-browser-persisted"), - displayName: "Personal Cursor", - enabled: true, - environment: [{ name: "CURSOR_API_KEY", value: "", sensitive: true }], - config: CursorDriver.defaultConfig(), - }; - const openedKeys: Array = []; - let closed = 0; - const instance = yield* CursorDriver.create(input).pipe( - Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { - assertComplete: Effect.void, - open: (request) => - Effect.sync(() => { - openedKeys.push(request.options.apiKey); - return { - agentId: "browser-auth-agent", - listMessages: Effect.succeed([]), - send: () => Effect.die("This test only opens a session"), - close: Effect.sync(() => { - closed += 1; - }), - }; - }), - }), - ); - expect((yield* instance.snapshot.refresh).auth.status).toBe("unauthenticated"); - yield* instance.auth!.start("client"); - yield* instance.auth!.subscribe("client").pipe( - Stream.filter((state) => state.phase === "succeeded"), - Stream.runHead, - Effect.map(Option.getOrThrow), - ); - expect(me).toHaveBeenCalledWith({ apiKey: "instance-browser-key" }); - expect(yield* instance.snapshot.getSnapshot).toMatchObject({ - status: "ready", - auth: { - status: "authenticated", - type: "browser", - canLogout: true, - email: "cursor@example.com", - }, - setup: { canAuthenticate: true, canInstall: false }, - }); - const threadId = ThreadId.make("cursor-browser-thread"); - const modelSelection = { instanceId: input.instanceId, model: "auto" }; - const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const runtime = yield* instance.orchestrationAdapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("cursor-browser-session"), - modelSelection, - runtimePolicy, - }); - yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); - expect(openedKeys).toEqual(["instance-browser-key"]); - expect(closed).toBe(0); - const recreated = yield* CursorDriver.create(input); - expect((yield* recreated.snapshot.refresh).auth.status).toBe("authenticated"); - yield* instance.auth!.logout(Effect.void); - expect(closed).toBe(1); - expect((yield* instance.snapshot.getSnapshot).auth.status).toBe("unauthenticated"); - expect((yield* recreated.snapshot.refresh).auth.status).toBe("unauthenticated"); - expect( - (yield* Effect.exit(runtime.ensureThread({ threadId, modelSelection, runtimePolicy }))) - ._tag, - ).toBe("Failure"); - expect(openedKeys).toEqual(["instance-browser-key"]); - expect(closed).toBe(1); - }).pipe(Effect.scoped), - ); - - it.effect("keeps the bundled SDK manual-only without probing or updating cursor-agent", () => - Effect.gen(function* () { - const instance = yield* CursorDriver.create({ - instanceId: ProviderInstanceId.make("cursor-sdk"), - displayName: "Cursor test", - enabled: false, - environment: [], - config: CursorDriver.defaultConfig(), - }); - expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); - expect((yield* instance.snapshot.refresh).status).toBe("disabled"); - }).pipe( - Effect.provideService( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("SDK maintenance must not spawn a process")), - ), - Effect.scoped, - ), - ); -}); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts deleted file mode 100644 index cf4d82bcfdb1b73992e824fb8c20a789e13da30f..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * CursorDriver — `ProviderDriver` for the Cursor Agent SDK runtime. - * - * Provider status, model discovery, orchestration, and text generation use the - * official Cursor SDK with an instance browser login or CURSOR_API_KEY. - * - * @module provider/Drivers/CursorDriver - */ -import { CursorSettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Crypto from "effect/Crypto"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { HttpClient } from "effect/http"; -import { readCursorUsageLimits } from "../cursorUsageLimits.ts"; - -import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; -import * as ServerConfig from "../../config.ts"; -import * as ServerSettings from "../../serverSettings.ts"; -import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; -import { - CursorAdapterV2Driver, - type CursorAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/CursorAdapterV2.ts"; -import { ProviderDriverError } from "../Errors.ts"; -import { - buildInitialCursorProviderSnapshot, - checkCursorProviderStatus, -} from "../CursorProvider.ts"; -import * as CursorSdkCatalog from "../CursorSdkCatalog.ts"; -import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; -import { - defaultProviderContinuationIdentity, - type ProviderDriver, - type ProviderInstance, -} from "@t3tools/provider-core/server/driver"; -import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; -import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; -import { - haveProviderSnapshotSettingsChanged, - makeProviderSnapshotSettingsSource, - type ProviderSnapshotSettings, -} from "@t3tools/provider-core/server/snapshotSettings"; -import { probeCursorSkills } from "./CursorSkills.ts"; -import { makeCursorAuth } from "../CursorAuth.ts"; -import * as CursorCredentialStore from "../CursorCredentialStore.ts"; -import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; -import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; -const decodeCursorSettings = Schema.decodeSync(CursorSettings); -const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError); - -const DRIVER_KIND = ProviderDriverKind.make("cursor"); -const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ - provider: DRIVER_KIND, - packageName: null, -}); - -export type CursorDriverEnv = - | CursorAdapterV2DriverEnv - | Crypto.Crypto - | FileSystem.FileSystem - | Path.Path - | HttpClient.HttpClient - | ProviderHost - | ServerConfig.ServerConfig - | ServerSecretStore.ServerSecretStore - | ServerSettings.ServerSettingsService; - -export const CursorDriver: ProviderDriver = { - driverKind: DRIVER_KIND, - metadata: { - displayName: "Cursor", - supportsMultipleInstances: true, - }, - configSchema: CursorSettings, - defaultConfig: (): CursorSettings => decodeCursorSettings({}), - create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => - Effect.gen(function* () { - const serverSettings = yield* ServerSettings.ServerSettingsService; - const host = yield* ProviderHost; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const httpClient = yield* HttpClient.HttpClient; - const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; - const processEnv = mergeProviderInstanceEnvironment(environment); - const continuationIdentity = defaultProviderContinuationIdentity({ - driverKind: DRIVER_KIND, - instanceId, - }); - const stampIdentity = withInstanceIdentity({ - instanceId, - driverKind: DRIVER_KIND, - displayName: displayName ?? "Cursor", - accentColor, - continuationGroupKey: continuationIdentity.continuationKey, - }); - const effectiveConfig = { ...config, enabled } satisfies CursorSettings; - const credentials = yield* CursorCredentialStore.makeCursorCredentialStore( - instanceId, - path.join( - (yield* ServerConfig.ServerConfig).stateDir, - "provider-auth", - encodeURIComponent(instanceId), - "cursor.json", - ), - ).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Could not open the Cursor credential store.", - cause, - }), - ), - ); - const auth = yield* makeCursorAuth({ - instanceId, - displayName: displayName ?? "Cursor", - enabled, - ...(processEnv.CURSOR_API_KEY ? { apiKey: processEnv.CURSOR_API_KEY } : {}), - store: credentials.store, - credentialBinding: credentials.binding, - onChanged: (signedIn): Effect.Effect => - snapshot.refresh.pipe( - Effect.flatMap((provider) => - !signedIn || provider.auth.status === "authenticated" - ? Effect.void - : Effect.fail( - new ProviderSetupError({ - instanceId, - operation: "start", - detail: provider.message ?? "Could not verify the Cursor sign-in. Try again.", - }), - ), - ), - ), - }); - const stampSnapshot: typeof stampIdentity = (draft) => - stampIdentity({ - ...draft, - setup: { canAuthenticate: !auth.usesApiKey, canInstall: false }, - auth: { - ...draft.auth, - canLogout: !auth.usesApiKey, - }, - }); - - const orchestrationAdapter = yield* CursorAdapterV2Driver.create({ - instanceId, - displayName, - accentColor, - environment, - enabled, - config, - }).pipe( - Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { - ...sdkRunner, - open: (input) => - auth.requireApiKey.pipe( - Effect.flatMap((apiKey) => - Effect.acquireRelease( - sdkRunner - .open({ ...input, options: { ...input.options, apiKey } }) - .pipe( - Effect.flatMap((session) => - Effect.cached(session.close).pipe( - Effect.map((close) => ({ ...session, close })), - ), - ), - ), - (session) => session.close.pipe(Effect.ignore), - ), - ), - auth.withAccess, - Effect.mapError((cause) => - isSdkRunnerError(cause) - ? cause - : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }), - ), - ), - }), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: "Failed to build Cursor orchestration adapter.", - cause, - }), - ), - ); - const textGeneration = yield* makeCursorTextGeneration( - effectiveConfig, - processEnv, - auth.requireApiKey, - auth.withAccess, - ); - - const checkProvider = auth.readApiKey.pipe( - Effect.orElseSucceed(() => undefined), - Effect.flatMap((apiKey) => - checkCursorProviderStatus( - effectiveConfig, - { - ...processEnv, - CURSOR_API_KEY: apiKey, - }, - auth.usesApiKey ? "api-key" : "browser", - ).pipe( - Effect.flatMap((snapshot) => - effectiveConfig.enabled && - snapshot.installed && - snapshot.auth.status === "authenticated" - ? serverSettings.getSettings.pipe( - Effect.flatMap((settings) => - readCursorUsageLimits( - effectiveConfig, - { ...processEnv, CURSOR_API_KEY: apiKey }, - settings.cursorKeychainUsageEnabled, - ), - ), - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ) - : Effect.succeed(snapshot), - ), - ), - ), - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.map(stampSnapshot), - Effect.provide(CursorSdkCatalog.layer), - ); - - const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); - const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), - getSettings: snapshotSettings.getSettings, - streamSettings: snapshotSettings.streamSettings, - haveSettingsChanged: haveProviderSnapshotSettingsChanged, - initialSnapshot: (settings) => - buildInitialCursorProviderSnapshot(settings.provider).pipe(Effect.map(stampSnapshot)), - checkProvider, - }).pipe( - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`, - cause, - }), - ), - ); - - return { - instanceId, - driverKind: DRIVER_KIND, - continuationIdentity, - displayName, - accentColor, - enabled, - auth: auth.controller, - snapshot, - snapshotForCwd: (cwd) => - !effectiveConfig.enabled - ? snapshot.getSnapshot - : Effect.all([ - snapshot.getSnapshot, - probeCursorSkills(cwd, processEnv).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new ProviderDriverError({ - driver: DRIVER_KIND, - instanceId, - detail: `Failed to discover Cursor skills for '${cwd}'`, - cause, - }), - ), - ), - ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), - orchestrationAdapter, - textGeneration, - } satisfies ProviderInstance; - }), -}; diff --git a/apps/server/src/provider/Drivers/CursorSkills.test.ts b/apps/server/src/provider/Drivers/CursorSkills.test.ts deleted file mode 100644 index 382f9c6dc54e89d68545ae48db0e3a5f139cfcf3..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Drivers/CursorSkills.test.ts +++ /dev/null @@ -1,186 +0,0 @@ -import * as NodeOS from "node:os"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { describe, expect, it } from "vite-plus/test"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import type * as Scope from "effect/Scope"; -import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; - -import { - discoverCursorSkills, - hasCursorSkillMention, - probeCursorSkills, - rewriteCursorSkillMentions, -} from "./CursorSkills.ts"; - -const runNode = ( - effect: Effect.Effect, -) => effect.pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise); - -describe("Cursor skills", () => { - it("discovers recursive project skills with project precedence", async () => - await runNode( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const userHome = yield* fileSystem - .makeTempDirectoryScoped({ - directory: NodeOS.tmpdir(), - prefix: "cursor-skills-home-", - }) - .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); - const workspace = yield* fileSystem - .makeTempDirectoryScoped({ - directory: NodeOS.tmpdir(), - prefix: "cursor-skills-workspace-", - }) - .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); - const writeSkill = Effect.fn("writeCursorSkill")(function* ( - root: string, - name: string, - contents: string, - ) { - const skillDirectory = path.join(root, name); - yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); - yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents); - }); - - yield* writeSkill( - path.join(userHome, ".cursor", "skills"), - "review", - "---\ndescription: user review\n---\n", - ); - yield* writeSkill( - path.join(workspace, ".agents", "skills", "nested"), - "review", - "---\nname: Review changes\ndescription: project review\n---\n", - ); - yield* writeSkill( - path.join(workspace, ".cursor", "skills"), - "internal", - "---\nuser-invocable: false\n---\n", - ); - yield* writeSkill( - path.join(workspace, ".cursor", "skills"), - "oversized", - "x".repeat(1_000_001), - ); - yield* fileSystem.makeDirectory(path.join(userHome, ".codex"), { recursive: true }); - yield* fileSystem.writeFileString( - path.join(userHome, ".codex", "skills"), - "not a directory", - ); - - const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); - expect(skills).toEqual([ - { - name: "internal", - path: path.join(workspace, ".cursor", "skills", "internal", "SKILL.md"), - scope: "project", - enabled: true, - userInvocable: false, - }, - { - name: "oversized", - path: path.join(workspace, ".cursor", "skills", "oversized", "SKILL.md"), - scope: "project", - enabled: true, - }, - { - name: "review", - displayName: "Review changes", - description: "project review", - path: path.join(workspace, ".agents", "skills", "nested", "review", "SKILL.md"), - scope: "project", - enabled: true, - }, - ]); - expect( - (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, - ).toBe("Failure"); - }), - )); - - it.skipIf(!symlinksSupported)( - "treats a symlinked skill outside the root as a package boundary", - async () => - await runNode( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const userHome = yield* fileSystem.makeTempDirectoryScoped({ - directory: NodeOS.tmpdir(), - prefix: "cursor-skills-home-", - }); - const workspace = yield* fileSystem - .makeTempDirectoryScoped({ - directory: NodeOS.tmpdir(), - prefix: "cursor-skills-workspace-", - }) - .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); - const library = yield* fileSystem.makeTempDirectoryScoped({ - directory: NodeOS.tmpdir(), - prefix: "cursor-skills-library-", - }); - const writeSkill = Effect.fn("writeCursorSkill")(function* ( - directory: string, - contents: string, - ) { - yield* fileSystem.makeDirectory(directory, { recursive: true }); - yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents); - }); - - // A skill package managed in a config repo and installed by symlink. - // Its own SKILL.md must be discovered under the link name, but nothing - // below the target may be walked. - yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n"); - yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n"); - const root = path.join(workspace, ".cursor", "skills"); - yield* fileSystem.makeDirectory(root, { recursive: true }); - yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review")); - - const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); - expect(skills).toEqual([ - { - name: "review", - description: "shared", - path: path.join(root, "review", "SKILL.md"), - scope: "project", - enabled: true, - }, - ]); - expect( - (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, - ).toBe("Success"); - }), - ), - ); - - it("rewrites only discovered skill mentions into Cursor slash invocations", () => { - expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true); - expect(hasCursorSkillMention("please $review this")).toBe(true); - expect( - rewriteCursorSkillMentions("use $review, keep $HOME and 5$review", new Set(["review"])), - ).toBe("use $review, keep $HOME and 5$review"); - expect(rewriteCursorSkillMentions("please $review this", new Set(["review"]))).toBe( - "please /review this", - ); - }); - it("detects and invokes digit-leading Cursor skills without rewriting money", () => { - const names = new Set(["2spec", "20k", "100M", "1e6"]); - expect(hasCursorSkillMention("use $2spec here")).toBe(true); - expect(hasCursorSkillMention("use $2spec here")).toBe(true); - expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here"); - expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here"); - for (const text of [ - "pay $20 tomorrow", - "budget $20k here", - "cost $100M total", - "limit $1e6 here", - ]) { - expect(hasCursorSkillMention(text)).toBe(false); - expect(rewriteCursorSkillMentions(text, names)).toBe(text); - } - }); -}); diff --git a/apps/server/src/provider/Drivers/CursorSkills.ts b/apps/server/src/provider/Drivers/CursorSkills.ts deleted file mode 100644 index c8cd561dfea0970285bd1457961a378c8728f122..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/Drivers/CursorSkills.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * CursorSkills — workspace-aware discovery and native invocation for Cursor. - * - * Cursor discovers Agent Skills recursively from user and project roots but - * its ACP command catalog only appears after opening a real session. Scanning - * the same roots avoids starting an agent and its MCP servers just to populate - * a composer menu. - * - * @module provider/Drivers/CursorSkills - */ -import * as NodeOS from "node:os"; - -import type { ServerProviderSkill } from "@t3tools/contracts"; -import * as ByteSize from "effect/ByteSize"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as PlatformError from "effect/PlatformError"; -import * as Schema from "effect/Schema"; -import { parse as parseYamlDocument } from "yaml"; - -const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; -const SKILL_MENTION_PATTERN = - /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; -const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u"); -const MAX_SKILL_DEPTH = 10; -const MAX_SKILL_BYTES = ByteSize.bytes(1_000_000); -const MAX_SKILL_SCAN_ENTRIES = 10_000; -const MAX_SKILL_SCAN_BYTES = ByteSize.bytes(8_000_000); - -interface CursorSkillFrontmatter { - readonly description?: string; - readonly displayName?: string; - readonly userInvocationOnly?: boolean; - readonly userInvocable?: boolean; - readonly cliVisible: boolean; -} - -interface CursorSkillScanBudget { - remainingEntries: number; - remainingBytes: bigint; - exhausted: boolean; - incomplete: boolean; -} - -class CursorSkillsProbeError extends Schema.TaggedError()( - "CursorSkillsProbeError", - { - reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]), - cwd: Schema.optional(Schema.String), - }, -) { - override get message(): string { - const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; - return `Cursor skill discovery${location} was incomplete (${this.reason}).`; - } -} - -const orUndefined = ( - effect: Effect.Effect, - budget?: CursorSkillScanBudget, -): Effect.Effect => - effect.pipe( - Effect.map((value): A | undefined => value), - Effect.catchTags({ - PlatformError: (error) => { - if (error.reason._tag !== "NotFound" && budget) budget.incomplete = true; - return Effect.void.pipe(Effect.as(undefined)); - }, - }), - ); - -function parseFrontmatterBoolean(value: unknown): boolean | undefined { - if (typeof value === "boolean") return value; - if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined; - if (typeof value !== "string") return undefined; - switch (value.trim().toLowerCase()) { - case "true": - case "yes": - case "on": - return true; - case "false": - case "no": - case "off": - return false; - default: - return undefined; - } -} - -function parseSkillFrontmatter(contents: string): CursorSkillFrontmatter | undefined { - const match = FRONTMATTER_PATTERN.exec(contents); - if (!match) return { cliVisible: true }; - - let parsed: unknown; - try { - parsed = parseYamlDocument(match[1] ?? ""); - } catch { - return undefined; - } - if (typeof parsed !== "object" || parsed === null) return undefined; - - const record = parsed as Record; - const metadata = - typeof record.metadata === "object" && record.metadata !== null - ? (record.metadata as Record) - : undefined; - const rawSurfaces = metadata?.surfaces; - const surfaces = Array.isArray(rawSurfaces) - ? rawSurfaces.filter((surface): surface is string => typeof surface === "string") - : typeof rawSurfaces === "string" - ? rawSurfaces.split(",") - : []; - const description = typeof record.description === "string" ? record.description.trim() : ""; - const displayName = typeof record.name === "string" ? record.name.trim() : ""; - return { - cliVisible: - surfaces.length === 0 || surfaces.some((surface) => surface.trim().toLowerCase() === "cli"), - ...(description ? { description } : {}), - ...(displayName ? { displayName } : {}), - ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true - ? { userInvocationOnly: true } - : {}), - ...(parseFrontmatterBoolean(record["user-invocable"]) === false - ? { userInvocable: false } - : {}), - }; -} - -const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (input: { - readonly directory: string; - readonly scope: "user" | "project"; - readonly budget: CursorSkillScanBudget; -}): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const skills: ServerProviderSkill[] = []; - if (input.budget.exhausted) return skills; - const rootDirectory = yield* orUndefined(fileSystem.realPath(input.directory), input.budget); - if (!rootDirectory) return skills; - const visitedDirectories = new Set(); - - const visit = Effect.fn("visitCursorSkillDirectory")(function* ( - directory: string, - depth: number, - ): Effect.fn.Return { - if (input.budget.exhausted) return; - const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget); - if (!resolvedDirectory) { - return; - } - if (visitedDirectories.has(resolvedDirectory)) { - return; - } - visitedDirectories.add(resolvedDirectory); - // A symlink whose target lives outside the root is a skill package - // boundary: read its own SKILL.md so linked skill libraries show up, but - // never walk the target tree. - const insideRoot = - resolvedDirectory === rootDirectory || - resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`); - - const skillPath = path.join(directory, "SKILL.md"); - const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); - if (skillInfo?.type === "File") { - let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true }; - if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) { - const contents = yield* orUndefined(fileSystem.readFileString(skillPath)); - if (contents !== undefined) { - input.budget.remainingBytes -= skillInfo.size; - frontmatter = parseSkillFrontmatter(contents); - } - } - const name = path.basename(directory).trim(); - if (frontmatter?.cliVisible && name) { - skills.push({ - name, - path: skillPath, - scope: input.scope, - enabled: true, - ...(frontmatter.displayName && frontmatter.displayName !== name - ? { displayName: frontmatter.displayName } - : {}), - ...(frontmatter.description ? { description: frontmatter.description } : {}), - ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}), - ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}), - }); - } - } - - if (!insideRoot) { - return; - } - const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget); - if (!entries) { - return; - } - for (const entry of [...entries].sort()) { - if (input.budget.remainingEntries === 0) { - input.budget.exhausted = true; - return; - } - input.budget.remainingEntries -= 1; - const child = path.join(directory, entry); - const info = yield* orUndefined(fileSystem.stat(child), input.budget); - if (info?.type !== "Directory") continue; - if (depth >= MAX_SKILL_DEPTH) { - input.budget.exhausted = true; - return; - } - yield* visit(child, depth + 1); - } - }); - - yield* visit(rootDirectory, 0); - return skills; -}); - -const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( - cwd?: string, - environment: NodeJS.ProcessEnv = process.env, -) { - const path = yield* Path.Path; - const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); - const rootsBelow = (base: string, scope: "user" | "project") => [ - { directory: path.join(base, ".cursor", "skills"), scope }, - { directory: path.join(base, ".agents", "skills"), scope }, - { directory: path.join(base, ".codex", "skills"), scope }, - { directory: path.join(base, ".claude", "skills"), scope }, - ]; - const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; - - const skillsByName = new Map(); - const budget: CursorSkillScanBudget = { - remainingEntries: MAX_SKILL_SCAN_ENTRIES, - remainingBytes: MAX_SKILL_SCAN_BYTES, - exhausted: false, - incomplete: false, - }; - for (const root of roots) { - if (budget.exhausted) break; - const skills = yield* discoverSkillsInRoot({ ...root, budget }); - for (const skill of skills) { - if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill); - } - } - return { - skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)), - failureReason: budget.exhausted - ? ("scan-budget-exhausted" as const) - : budget.incomplete - ? ("filesystem-error" as const) - : undefined, - }; -}); - -export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* ( - cwd?: string, - environment: NodeJS.ProcessEnv = process.env, -) { - return (yield* inspectCursorSkills(cwd, environment)).skills; -}); - -export const probeCursorSkills = Effect.fn("probeCursorSkills")(function* ( - cwd?: string, - environment: NodeJS.ProcessEnv = process.env, -) { - const inspection = yield* inspectCursorSkills(cwd, environment); - if (inspection.failureReason) { - return yield* new CursorSkillsProbeError({ - reason: inspection.failureReason, - ...(cwd ? { cwd } : {}), - }); - } - return inspection.skills; -}); - -/** Cursor invokes Agent Skills with `/name`; T3 composers insert `$name`. */ -export function hasCursorSkillMention(prompt: string): boolean { - return HAS_SKILL_MENTION_PATTERN.test(prompt); -} - -export function rewriteCursorSkillMentions( - prompt: string, - skillNames: ReadonlySet, -): string { - return prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) => - skillNames.has(name) ? `${prefix}/${name}` : match, - ); -} diff --git a/apps/server/src/provider/Drivers/GrokDriver.test.ts b/apps/server/src/provider/Drivers/GrokDriver.test.ts index f1737f71f39121909d16fec0cf5daa387821b3bf..e0da3203902c1eea50c1de3f69b9661f31ba883f 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.test.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.test.ts @@ -17,6 +17,7 @@ import { GrokDriver } from "./GrokDriver.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderHostLive from "../ProviderHostLive.ts"; +import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; const layerDeps = ServerConfig.layerTest(process.cwd(), { prefix: "t3-grok-driver-update-", @@ -42,7 +43,10 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { ), ), ); -const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); +const layerTest = ProviderHostLive.layer.pipe( + Layer.provideMerge(ServerSecretStore.layer), + Layer.provideMerge(layerDeps), +); const noSpawner = ChildProcessSpawner.make(() => Effect.die("Disabled Grok must not spawn a process"), diff --git a/apps/server/src/provider/ProviderHostLive.ts b/apps/server/src/provider/ProviderHostLive.ts index 965cb47e365f51849fbaa0dc4dfc0138a2e4b18c..372ac75765c6063057474da42f56abb9e360919e 100644 --- a/apps/server/src/provider/ProviderHostLive.ts +++ b/apps/server/src/provider/ProviderHostLive.ts @@ -5,13 +5,17 @@ * @module provider/ProviderHostLive */ import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import { ProviderCredentialError } from "@t3tools/provider-core/server/errors"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { resolveAttachmentPath } from "../attachmentStore.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; +import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; export const layer = Layer.effect( ProviderHost, @@ -19,6 +23,8 @@ export const layer = Layer.effect( const config = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; + const secrets = yield* ServerSecretStore.ServerSecretStore; + const crypto = yield* Crypto.Crypto; return ProviderHost.of({ paths: { cwd: config.cwd, @@ -34,6 +40,30 @@ export const layer = Layer.effect( shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), + credentials: (namespace, bindingId) => + ProviderCredentialStore.make(namespace, bindingId).pipe( + Effect.map((store) => ({ + binding: store.binding, + get: store.get.pipe( + Effect.mapError((cause) => new ProviderCredentialError({ operation: "get", cause })), + ), + set: (credentials: Uint8Array) => + store + .set(credentials) + .pipe( + Effect.mapError( + (cause) => new ProviderCredentialError({ operation: "set", cause }), + ), + ), + remove: store.remove.pipe( + Effect.mapError( + (cause) => new ProviderCredentialError({ operation: "remove", cause }), + ), + ), + })), + Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), + Effect.provideService(Crypto.Crypto, crypto), + ), }); }), ); diff --git a/apps/server/src/provider/ProviderInstanceRegistry.test.ts b/apps/server/src/provider/ProviderInstanceRegistry.test.ts index 5c1bbb1658223dd4bcbb6e1d46ad136a2ecddeec..353c29fb90daaee7a7692b2478d6b6caec71ef7f 100644 --- a/apps/server/src/provider/ProviderInstanceRegistry.test.ts +++ b/apps/server/src/provider/ProviderInstanceRegistry.test.ts @@ -31,12 +31,12 @@ import { EnvironmentId, type ClaudeSettings, type CodexSettings, - type CursorSettings, type GrokSettings, ProviderDriverKind, type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; +import type { CursorSettings } from "@t3tools/provider-cursor/settings"; import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -53,7 +53,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as ServerSettings from "../serverSettings.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; -import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; diff --git a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts index 6640288c7ff4898dbe5d354c9b1a903efd219390..2cbb70fbbdeccd37f7e99f1b757ccf7e1034d03f 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -2,7 +2,7 @@ import * as Layer from "effect/Layer"; import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; -import * as CursorAgentSdk from "../orchestration-v2/Adapters/CursorAgentSdk.ts"; +import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; diff --git a/apps/server/src/provider/acp/CursorTransportFailure.test.ts b/apps/server/src/provider/acp/CursorTransportFailure.test.ts deleted file mode 100644 index fdde19b934d201fa79de54668f5df284cdd62924..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/CursorTransportFailure.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { CursorTransportFailure } from "./CursorTransportFailure.ts"; - -const diagnostic = "Error: RetriableError: WritableIterable is closed"; - -function failureFor(chunks: string[]) { - const reply = new CursorTransportFailure(); - for (const chunk of chunks) reply.push(chunk); - return reply.failure; -} - -describe("CursorTransportFailure", () => { - it.each([ - diagnostic, - "Error: ConnectError: [unavailable] transport closed", - "Error: ConnectError: [aborted] aborted", - "Error: ConnectError: [deadline_exceeded] timed out", - "Something went wrong communicating with the server. Please try again.", - ])("recognizes a terminal diagnostic: %s", (message) => { - expect(failureFor([message])).toBe(message); - expect(failureFor([...message])).toBe(message); - }); - - it("recognizes a standalone failure with a stack trace", () => { - expect(failureFor(["\n", diagnostic, "\n at send (cli.js:1:2)\n\n"])).toBe(diagnostic); - }); - - it.each([ - "The error was " + diagnostic, - "This is an example of a transport error:\n" + diagnostic, - "I inspected the files.\n" + diagnostic, - "> " + diagnostic, - " " + diagnostic, - "```text\n" + diagnostic + "\n```", - "~~~\n" + diagnostic + "\n~~~", - diagnostic + "\nThis is an example of a transport error.", - "Error: ConnectError: [unauthenticated] sign in", - "Error: ConnectError: [permission_denied] subscription required", - "Error: HTTP 500 from the application being debugged", - "Error: RetriableError: [internal] Failed to run step, exceeded max retries", - "Error: RetriableError: [internal] Failed to run step, exceeded max retries\n at step (cli.js:1:2)", - ])("preserves prose, code and non-transport errors: %s", (message) => { - expect(failureFor([...message])).toBeUndefined(); - }); - - it("tracks fences across long lines without retaining the answer", () => { - expect(failureFor(["```\n", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); - expect(failureFor(["```", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); - expect(failureFor(["x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); - }); - - it("does not retain a previous failure after new output", () => { - const reply = new CursorTransportFailure(); - reply.push(diagnostic); - expect(reply.failure).toBe(diagnostic); - reply.push("\nRecovered successfully."); - expect(reply.failure).toBeUndefined(); - }); -}); diff --git a/apps/server/src/provider/acp/CursorTransportFailure.ts b/apps/server/src/provider/acp/CursorTransportFailure.ts deleted file mode 100644 index e6cadb23dc0d1d1c9f9635c2d86c9a6081f9db2d..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/acp/CursorTransportFailure.ts +++ /dev/null @@ -1,52 +0,0 @@ -const maxLineLength = 4096; -// Cursor also uses RetriableError for agent-loop failures; preserve those diagnostics. -const transportError = - /^Error: (?:RetriableError: (?!\[internal\]).+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; -const serverError = "Something went wrong communicating with the server. Please try again."; - -interface ReplyState { - disqualified: boolean; - failure: string | undefined; -} - -function consumeLine(state: ReplyState, line: string) { - if (state.disqualified) return; - const text = line.trimEnd(); - if (transportError.test(text) || text === serverError) { - state.failure = text; - } else if (text.trim() !== "" && !(state.failure && /^\s+at\s/.test(text))) { - // An explanation or code sample can quote the same diagnostic. Only - // classify an assistant item consisting entirely of a transport dump. - state.disqualified = true; - state.failure = undefined; - } -} - -/** Tracks a standalone Cursor diagnostic without retaining an entire streamed answer. */ -export class CursorTransportFailure { - private state: ReplyState = { disqualified: false, failure: undefined }; - private line = ""; - - push(text: string) { - for (const [index, part] of text.split("\n").entries()) { - if (this.state.disqualified) return; - if (index > 0) { - consumeLine(this.state, this.line); - this.line = ""; - } - if (this.line.length + part.length > maxLineLength) { - this.state.disqualified = true; - this.state.failure = undefined; - this.line = ""; - return; - } - this.line += part; - } - } - - get failure() { - const state = { ...this.state }; - consumeLine(state, this.line); - return state.failure; - } -} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 8cfcc007e9c16fbca591dadb9953830f610d5ad3..516e52d0e49ba291cb731f851eb42ebd97606948 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,7 +24,7 @@ import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegis import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; -import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; diff --git a/apps/server/src/provider/cursorKeychainToken.test.ts b/apps/server/src/provider/cursorKeychainToken.test.ts deleted file mode 100644 index 6c2e68d7b427b2c356ea0fa3d2ae55be91fb39d0..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/cursorKeychainToken.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { assert, describe, it } from "@effect/vitest"; - -import { - CursorKeychainTimeoutError, - makeCachedCursorAccessTokenReader, -} from "./cursorKeychainToken.ts"; - -describe("Cursor Keychain reader", () => { - it("shares concurrent reads and rechecks after the cache expires", async () => { - let reads = 0; - let time = 0; - const read = makeCachedCursorAccessTokenReader( - async () => { - reads++; - return `token-${reads}`; - }, - () => time, - ); - assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]); - assert.strictEqual(await read(), "token-1"); - assert.strictEqual(reads, 1); - time = 5 * 60_000; - assert.strictEqual(await read(), "token-2"); - }); - - it("gives up on an unanswered prompt and reuses it on the next read", async () => { - let reads = 0; - let allow: (token: string) => void = () => {}; - const read = makeCachedCursorAccessTokenReader( - () => { - reads++; - return new Promise((resolve) => { - allow = resolve; - }); - }, - () => 0, - 1, - ); - const error = await read().catch((cause: unknown) => cause); - assert.instanceOf(error, CursorKeychainTimeoutError); - const retry = read(); - allow("token"); - assert.strictEqual(await retry, "token"); - assert.strictEqual(reads, 1); - }); -}); diff --git a/apps/server/src/provider/cursorKeychainToken.ts b/apps/server/src/provider/cursorKeychainToken.ts deleted file mode 100644 index 2857683e4821c4c608632dbbb53591e1d1966e87..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/cursorKeychainToken.ts +++ /dev/null @@ -1,55 +0,0 @@ -import * as NodeModule from "node:module"; - -const CACHE_MS = 5 * 60_000; - -const requireForKeyring = NodeModule.createRequire(import.meta.url); - -/** Rejected when nobody answers the macOS Keychain prompt in time. */ -export class CursorKeychainTimeoutError extends Error { - constructor() { - super("Timed out waiting for Keychain access."); - } -} - -/** - * Share one Keychain request across usage history and limits in this server process. - * - * macOS shows the access prompt on the server's own screen, which a remote - * client cannot answer, so callers give up after `timeoutMs`. The read stays in - * flight: the next call reuses it instead of stacking a second prompt, and picks - * up the token once someone allows access. - */ -export function makeCachedCursorAccessTokenReader( - read: () => Promise, - now: () => number = Date.now, - timeoutMs = 30_000, -): () => Promise { - let cached: { token: string; until: number } | null = null; - let pending: Promise | null = null; - return () => { - if (cached && cached.until > now()) return Promise.resolve(cached.token); - pending ??= read() - .then((token) => { - cached = token ? { token, until: now() + CACHE_MS } : null; - return token; - }) - .finally(() => { - pending = null; - }); - const deadline = AbortSignal.timeout(timeoutMs); - return Promise.race([ - pending, - new Promise((_, reject) => { - deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), { - once: true, - }); - }), - ]); - }; -} - -/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ -export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { - const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); - return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; -}); diff --git a/apps/server/src/provider/cursorSdk.test.ts b/apps/server/src/provider/cursorSdk.test.ts deleted file mode 100644 index 6a34ee9fc40e9696fb4b598902b2ec6197cf459b..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/cursorSdk.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- The probe is a plain Node child; this deadline kills it if it hangs. -import * as NodeChildProcess from "node:child_process"; -import { describe, expect, it } from "vite-plus/test"; - -const cursorSdkUrl = new URL("./cursorSdk.ts", import.meta.url).href; - -// Loads cursorSdk.ts in a fresh process. @cursor/sdk is stubbed so the guard -// can be tested without the real package. The vitest worker already has its -// own unhandledRejection listener, which would hide the process-exit behavior. -const probeProgram = ` -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { registerHooks } from "node:module"; -import { pathToFileURL } from "node:url"; -import { spawn } from "node:child_process"; - -const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-")); -const stub = join(dir, "stub.cjs"); -writeFileSync( - stub, - "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };", -); -registerHooks({ - resolve(specifier, context, next) { - if (specifier === "@cursor/sdk") { - return { url: pathToFileURL(stub).href, shortCircuit: true }; - } - return next(specifier, context); - }, -}); - -const { isCursorShellSpawnFailure } = await import(${JSON.stringify(cursorSdkUrl)}); -const mode = process.argv[1]; -const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid); - -if (mode === "predicate") { - const cursorShell = Object.assign(new Error("spawn /bin/zsh ENOENT"), { - code: "ENOENT", - syscall: "spawn /bin/zsh", - path: "/bin/zsh", - spawnargs: ["-c", "dump_zsh_state >&4", "--", "true"], - }); - const bashShell = Object.assign(new Error("spawn bash ENOENT"), { - code: "ENOENT", - syscall: "spawn bash", - spawnargs: ["-c", "dump_bash_state >&4"], - }); - const sandboxRestore = Object.assign(new Error("spawn /bin/zsh ENOENT"), { - code: "ENOENT", - syscall: "spawn /bin/zsh", - spawnargs: ["-c", "builtin eval \\"\${__CURSOR_SANDBOX_ENV_RESTORE:-}\\""], - }); - const gitSpawn = Object.assign(new Error("spawn git ENOENT"), { - code: "ENOENT", - syscall: "spawn git", - path: "git", - spawnargs: ["status"], - }); - const plainZsh = Object.assign(new Error("spawn /bin/zsh ENOENT"), { - code: "ENOENT", - syscall: "spawn /bin/zsh", - path: "/bin/zsh", - spawnargs: ["-lc", "true"], - }); - console.log( - JSON.stringify({ - cursorShell: isCursorShellSpawnFailure(cursorShell), - bashShell: isCursorShellSpawnFailure(bashShell), - sandboxRestore: isCursorShellSpawnFailure(sandboxRestore), - gitSpawn: isCursorShellSpawnFailure(gitSpawn), - plainZsh: isCursorShellSpawnFailure(plainZsh), - open: isCursorShellSpawnFailure( - Object.assign(new Error("open failed"), { code: "ENOENT", syscall: "open" }), - ), - plain: isCursorShellSpawnFailure(new Error("boom")), - string: isCursorShellSpawnFailure("spawn ENOENT"), - }), - ); - process.exit(0); -} - -if (mode === "cursor-shell") { - const child = spawn("/bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"], { - cwd: missingCwd, - }); - child.on("error", (error) => { - Promise.reject(error); - }); - setTimeout(() => process.exit(0), 500); -} else if (mode === "other-spawn") { - Promise.reject( - Object.assign(new Error("spawn git ENOENT"), { - code: "ENOENT", - syscall: "spawn git", - path: "git", - spawnargs: ["status"], - }), - ); - setTimeout(() => process.exit(0), 500); -} else if (mode === "other-rejection") { - Promise.reject(new Error("boom")); - setTimeout(() => process.exit(0), 500); -} else if (mode === "spawn-without-listener") { - spawn(process.execPath, ["-e", "process.exit(0)"], { cwd: missingCwd }); - setTimeout(() => process.exit(0), 500); -} else { - console.error("Unknown cursor shell spawn guard probe: " + mode); - process.exit(2); -} -`; - -function runProbe(mode: string): Promise<{ code: number | null; stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - const child = NodeChildProcess.spawn( - process.execPath, - [ - "--experimental-strip-types", - "--disable-warning=ExperimentalWarning", - "--input-type=module", - "-e", - probeProgram, - mode, - ], - { stdio: ["ignore", "pipe", "pipe"] }, - ); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - const timer = setTimeout(() => { - child.kill(); - reject(new Error(`probe ${mode} timed out`)); - }, 5_000); - child.on("error", reject); - child.on("exit", (code) => { - clearTimeout(timer); - resolve({ code, stdout, stderr }); - }); - }); -} - -describe("isCursorShellSpawnFailure", () => { - it("matches only Cursor's shell wrapper", async () => { - const result = await runProbe("predicate"); - expect(result.code).toBe(0); - expect(JSON.parse(result.stdout)).toEqual({ - cursorShell: true, - bashShell: true, - sandboxRestore: true, - gitSpawn: false, - plainZsh: false, - open: false, - plain: false, - string: false, - }); - }); -}); - -describe("Cursor shell spawn guard", () => { - it("keeps the process alive when Cursor's shell spawn rejects", async () => { - const result = await runProbe("cursor-shell"); - expect(result.code).toBe(0); - expect(result.stderr).toContain("The server will keep running."); - expect(result.stderr).toContain("ENOENT"); - }); - - it("still exits when a different spawn failure is unhandled", async () => { - const result = await runProbe("other-spawn"); - expect(result.code).toBe(1); - expect(result.stderr).toContain("spawn git ENOENT"); - expect(result.stderr).not.toContain("The server will keep running."); - }); - - it("still exits on unrelated unhandled rejections", async () => { - const result = await runProbe("other-rejection"); - expect(result.code).toBe(1); - expect(result.stderr).toContain("boom"); - }); - - it("leaves a spawn with no error listener fatal", async () => { - const result = await runProbe("spawn-without-listener"); - expect(result.code).toBe(1); - expect(result.stderr).toContain("Unhandled"); - expect(result.stderr).not.toContain("The server will keep running."); - }); -}); diff --git a/apps/server/src/provider/cursorSdk.ts b/apps/server/src/provider/cursorSdk.ts deleted file mode 100644 index 07381bcf3a6f3ca959322f9c66d6654ef745ad53..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/cursorSdk.ts +++ /dev/null @@ -1,77 +0,0 @@ -// @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- Installed before the SDK loads, outside an Effect runtime. stderr must match Node's default unhandled-rejection print. -import * as NodeModule from "node:module"; - -/** - * The Cursor Agent SDK runs in this process and spawns a shell for tool - * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`, - * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory - * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error, - * then rejects an internal promise nothing awaits. Node exits on that - * unhandled rejection and takes the server with it. - * - * This listener ignores only that rejection. Every other unhandled rejection - * still prints and exits when this is the process's only listener, matching - * Node's default. `child_process.spawn` itself is left alone, so git, - * terminals, and other providers keep their own error handling. - */ -const CURSOR_SHELL_SPAWN_MARKERS = [ - "dump_zsh_state", - "dump_bash_state", - "__CURSOR_SANDBOX_ENV_RESTORE", -] as const; - -export function isCursorShellSpawnFailure(reason: unknown): boolean { - if (typeof reason !== "object" || reason === null) { - return false; - } - const syscall = Reflect.get(reason, "syscall"); - const code = Reflect.get(reason, "code"); - const spawnargs = Reflect.get(reason, "spawnargs"); - if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") { - return false; - } - if (!Array.isArray(spawnargs)) { - return false; - } - return spawnargs.some( - (arg) => - typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)), - ); -} - -let cursorShellSpawnGuardInstalled = false; - -function installCursorShellSpawnGuard(): void { - if (cursorShellSpawnGuardInstalled) { - return; - } - cursorShellSpawnGuardInstalled = true; - - process.on("unhandledRejection", (reason) => { - if (isCursorShellSpawnFailure(reason)) { - console.error("Cursor shell spawn failed. The server will keep running.", reason); - return; - } - // Another handler registered besides this one owns the decision. - if (process.listenerCount("unhandledRejection") > 1) { - return; - } - console.error(reason); - process.exit(1); - }); -} - -// Arm before the SDK loads. Its shell tools spawn from this process. -installCursorShellSpawnGuard(); - -// Cursor's Webpack chunks and local helpers must stay beside the SDK entry. -// createRequire also loads that disk-backed package from a Node SEA executable. -const requireCursorSdk = NodeModule.createRequire(import.meta.url); -export const { - Agent, - AuthenticationError, - createAgentPlatform, - Cursor, - CursorSdkError, - InMemoryCredentialStore, -} = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk"); diff --git a/apps/server/src/provider/cursorSdkModel.ts b/apps/server/src/provider/cursorSdkModel.ts deleted file mode 100644 index 3332cdbf86714b8439e73fb052193340ae742891..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/cursorSdkModel.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { ModelSelection as CursorSdkModelSelection, ModelParameterValue } from "@cursor/sdk"; -import type { ModelSelection } from "@t3tools/contracts"; - -const CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION: Readonly> = { - context: "contextWindow", - fast: "fastMode", -}; - -const PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER: Readonly> = { - contextWindow: "context", - fastMode: "fast", -}; - -export function cursorSdkProviderOptionId(parameterId: string): string { - return CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION[parameterId] ?? parameterId; -} - -function cursorSdkParameterId(providerOptionId: string): string { - return PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER[providerOptionId] ?? providerOptionId; -} - -export function cursorSdkParameterPriority(parameterId: string): number { - switch (parameterId) { - case "effort": - case "reasoning": - return 0; - case "context": - return 1; - case "fast": - return 2; - case "thinking": - return 3; - default: - return 4; - } -} - -export function cursorSdkModelSelection(modelSelection: ModelSelection): CursorSdkModelSelection { - return { - id: modelSelection.model === "auto" ? "default" : modelSelection.model, - ...(modelSelection.options === undefined || modelSelection.options.length === 0 - ? {} - : { - params: modelSelection.options.map((option): ModelParameterValue => ({ - id: cursorSdkParameterId(option.id), - value: String(option.value), - })), - }), - }; -} diff --git a/apps/server/src/provider/cursorUsageLimits.test.ts b/apps/server/src/provider/cursorUsageLimits.test.ts deleted file mode 100644 index 5ce2cee7fe8c2f4eb9cb9aeebf786219284bdbe6..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/cursorUsageLimits.test.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { describe, expect, it } from "@effect/vitest"; -import { NodeServices } from "@effect/platform-node"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import { HttpClient, HttpClientResponse } from "effect/http"; -import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts"; - -const withNodeServices = (effect: Effect.Effect) => - effect.pipe(Effect.provide(NodeServices.layer)); - -describe("Cursor usage limits", () => { - const checkedAt = "2026-09-16T00:00:00.000Z"; - - it("uses the advertised percentages and billing-cycle reset", () => { - const limits = cursorUsageResponseToLimits( - { - billingCycleEnd: "1789876386000", - planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, - }, - checkedAt, - ); - expect(limits.windows).toEqual( - expect.arrayContaining([ - { - id: "totalPercentUsed", - kind: "monthly", - label: "Overall", - usedPercent: 72.4, - resetsAt: "2026-09-20T03:53:06.000Z", - }, - { - id: "autoPercentUsed", - kind: "monthly", - label: "Cursor Models", - usedPercent: 69.5, - resetsAt: "2026-09-20T03:53:06.000Z", - }, - { - id: "apiPercentUsed", - kind: "monthly", - label: "Other Models", - usedPercent: 100, - resetsAt: "2026-09-20T03:53:06.000Z", - }, - ]), - ); - }); - - it("does not invent unused allowance for absent buckets", () => { - expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( - "unsupported", - ); - expect( - cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); - expect( - cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, - ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); - }); - - it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => - Effect.gen(function* () { - yield* withNodeServices( - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fs.makeTempDirectoryScoped(); - yield* fs.makeDirectory(path.join(directory, "cursor")); - yield* fs.writeFileString( - path.join(directory, "cursor", "auth.json"), - '{"accessToken":"instance-token"}', - ); - const client = HttpClient.make((request) => { - expect(request.url).toBe( - "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", - ); - expect(request.method).toBe("POST"); - expect(request.headers.authorization).toBe("Bearer instance-token"); - expect(request.headers["connect-protocol-version"]).toBe("1"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), - ), - ); - }); - yield* fs.makeDirectory(path.join(directory, ".cursor")); - yield* fs.writeFileString( - path.join(directory, ".cursor", "auth.json"), - '{"accessToken":"instance-token"}', - ); - for (const platform of ["linux", "darwin"] as const) { - const limits = yield* readCursorUsageLimits( - { apiEndpoint: "https://cursor.example/" }, - { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, - ).pipe( - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService(HttpClient.HttpClient, client), - ); - expect(limits.windows[0]?.usedPercent).toBe(42); - } - }).pipe(Effect.scoped), - ); - }), - ); - - it.effect( - "never reads stale files for keychain or memory logins, but accepts an explicit auth token", - () => - Effect.gen(function* () { - for (const platform of ["linux", "darwin"] as const) { - for (const token of [undefined, "explicit-token"]) { - const limits = yield* withNodeServices( - readCursorUsageLimits( - { apiEndpoint: "" }, - { - AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", - ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), - }, - false, - async () => { - throw new Error("must not read Keychain before opt-in"); - }, - ).pipe( - Effect.provideService(HostProcessPlatform, platform), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read an unrelated credential file"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(token).toBe("explicit-token"); - expect(request.headers.authorization).toBe("Bearer explicit-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ planUsage: { totalPercentUsed: 10 } }), - ), - ); - }), - ), - ), - ); - if (token) expect(limits.windows[0]?.usedPercent).toBe(10); - else expect(limits.unavailable?.reason).toBe("unsupported"); - } - } - }), - ); - - it.effect("reports failed requests without exposing credentials or response bodies", () => - Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response("private response", { status: 401 }), - ), - ), - ), - ), - ), - ); - expect(limits.unavailable).toEqual({ - reason: "probeFailed", - message: "Cursor could not read usage limits.", - }); - }), - ); - - it.effect("does not use a stored login for an explicit API key", () => - Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request usage")), - ), - ), - ); - expect(limits.unavailable?.reason).toBe("unsupported"); - }), - ); - - it.effect("reads the default macOS Cursor login from Keychain for limits", () => - Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( - Effect.provideService(HostProcessPlatform, "darwin"), - Effect.provideService( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: () => Effect.die("must not read a stale credential file"), - }), - ), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make((request) => { - expect(request.headers.authorization).toBe("Bearer keychain-token"); - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - Response.json({ planUsage: { totalPercentUsed: 42 } }), - ), - ); - }), - ), - ), - ); - expect(limits.windows[0]?.usedPercent).toBe(42); - }), - ); - - it.effect("reports a Keychain initialization failure without failing the provider refresh", () => - Effect.gen(function* () { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { - throw new Error("Keychain initialization failed"); - }).pipe( - Effect.provideService(HostProcessPlatform, "darwin"), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not request limits without a login")), - ), - ), - ); - expect(limits.unavailable?.reason).toBe("probeFailed"); - }), - ); - - it.effect("does not read Keychain or send its token to a custom endpoint", () => - Effect.gen(function* () { - for (const [apiEndpoint, environment] of [ - ["http://localhost:3000", {}], - ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], - ["https://cursor-proxy.example", {}], - ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], - ] as const) { - const limits = yield* withNodeServices( - readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { - throw new Error("must not read Keychain for a custom endpoint"); - }).pipe( - Effect.provideService(HostProcessPlatform, "darwin"), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), - ), - ), - ); - expect(limits.unavailable?.reason).toBe("unsupported"); - expect(limits.unavailable?.message).toContain("default Cursor endpoint"); - } - }), - ); -}); diff --git a/apps/server/src/provider/cursorUsageLimits.ts b/apps/server/src/provider/cursorUsageLimits.ts deleted file mode 100644 index 987eeda748503469bdc30ede212f3b04a678f085..0000000000000000000000000000000000000000 --- a/apps/server/src/provider/cursorUsageLimits.ts +++ /dev/null @@ -1,151 +0,0 @@ -import * as NodeOS from "node:os"; -import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; -import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; -import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; -import * as DateTime from "effect/DateTime"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Option from "effect/Option"; -import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; -import { - clampPercent, - makeUnavailableUsageLimits, - makeUsageLimits, -} from "@t3tools/provider-core/server/usageLimits"; -import { readMacCursorAccessToken } from "./cursorKeychainToken.ts"; - -const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); -const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; -const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); -const CursorUsageResponse = Schema.Struct({ - billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), - planUsage: Schema.optional( - Schema.Struct({ - totalPercentUsed: Schema.optional(Schema.Number), - autoPercentUsed: Schema.optional(Schema.Number), - apiPercentUsed: Schema.optional(Schema.Number), - }), - ), -}); - -/** Cursor's dashboard percentages include bonus usage; spend / limit does not. */ -export function cursorUsageResponseToLimits( - response: typeof CursorUsageResponse.Type, - checkedAt: string, -) { - const reset = DateTime.make(Number(response.billingCycleEnd)); - const resetsAt = - Number(response.billingCycleEnd) > 0 && Option.isSome(reset) - ? DateTime.formatIso(reset.value) - : undefined; - const windows: ServerProviderUsageWindow[] = []; - if (response.planUsage) { - for (const { id, label } of CURSOR_USAGE_WINDOWS) { - const usedPercent = response.planUsage[id]; - if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; - windows.push({ - id, - kind: "monthly", - label, - usedPercent: clampPercent(usedPercent), - ...(resetsAt ? { resetsAt } : {}), - }); - } - } - return windows.length > 0 - ? makeUsageLimits({ checkedAt, windows }) - : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); -} - -export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( - settings: Pick, - environment: NodeJS.ProcessEnv = process.env, - allowKeychain = false, - keychainToken: () => Promise = readMacCursorAccessToken, -) { - const checkedAt = DateTime.formatIso(yield* DateTime.now); - return yield* Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcessPlatform; - const endpoint = ( - settings.apiEndpoint?.trim() || - environment.CURSOR_API_ENDPOINT?.trim() || - DEFAULT_CURSOR_API_ENDPOINT - ).replace(/\/$/, ""); - let token = environment.CURSOR_AUTH_TOKEN?.trim(); - // An explicit API key can name a different account from the stored login. - if (!token && environment.CURSOR_API_KEY?.trim()) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - } - const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; - if (!token && credentialStore === "memory") { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", - }); - } - if (!token && platform === "darwin" && credentialStore !== "file") { - if (!allowKeychain) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Enable Cursor account usage in T3 Code to read its Keychain login.", - }); - } - if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { - return makeUnavailableUsageLimits({ - checkedAt, - reason: "unsupported", - message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", - }); - } - token = (yield* Effect.tryPromise(keychainToken))?.trim(); - } else if (!token) { - const home = - (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); - const directory = - platform === "win32" - ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") - : platform === "darwin" - ? path.join(home, ".cursor") - : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); - const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - Effect.flatMap(decodeCredentials), - ); - token = credentials.accessToken?.trim(); - } - if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - const client = yield* HttpClient.HttpClient; - const response = yield* client.execute( - HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( - HttpClientRequest.bearerToken(token), - HttpClientRequest.setHeaders({ - "connect-protocol-version": "1", - "x-cursor-client-type": "cli", - }), - HttpClientRequest.bodyJsonUnsafe({}), - ), - ); - const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( - yield* HttpClientResponse.filterStatusOk(response), - ); - return cursorUsageResponseToLimits(body, checkedAt); - }).pipe( - Effect.timeout("10 seconds"), - Effect.orElseSucceed(() => - makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "Cursor could not read usage limits.", - }), - ), - ); -}); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.test.ts b/apps/server/src/textGeneration/CursorTextGeneration.test.ts deleted file mode 100644 index 4b3ce0eebbe53d275a1e35bd5c736082122ecc18..0000000000000000000000000000000000000000 --- a/apps/server/src/textGeneration/CursorTextGeneration.test.ts +++ /dev/null @@ -1,415 +0,0 @@ -import type { RunResult } from "@cursor/sdk"; -import { CursorSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; -import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Fiber from "effect/Fiber"; -import * as TestClock from "effect/testing/TestClock"; -import * as Schema from "effect/Schema"; -import { createModelSelection } from "@t3tools/shared/model"; -import { beforeEach, vi } from "vite-plus/test"; - -import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; - -const cursorSdkMock = vi.hoisted(() => ({ - create: vi.fn<(options: unknown) => Promise>(), - send: vi.fn<(prompt: string) => Promise>(), - close: vi.fn(), - cancel: vi.fn(async () => {}), - prompt: vi.fn<(prompt: string, options: unknown) => Promise>(async () => ({ - id: "run-cursor-text-generation-test", - status: "finished", - result: - '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}', - })), -})); - -vi.mock("../provider/cursorSdk.ts", () => ({ Agent: { create: cursorSdkMock.create } })); - -let hasCustomPolicy = false; -const layerFs = FileSystem.layerNoop({ - exists: () => Effect.succeed(hasCustomPolicy), - makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"), -}); - -const decodeCursorSettings = Schema.decodeSync(CursorSettings); -const cursorSettings = decodeCursorSettings({ enabled: true }); - -beforeEach(() => { - hasCustomPolicy = false; - cursorSdkMock.create.mockReset(); - cursorSdkMock.send.mockReset(); - cursorSdkMock.create.mockImplementation(async (options) => { - cursorSdkMock.send.mockImplementation(async (prompt) => ({ - status: "running", - cancel: cursorSdkMock.cancel, - wait: () => cursorSdkMock.prompt(prompt, options), - })); - return { - close: cursorSdkMock.close, - [Symbol.asyncDispose]: async () => { - cursorSdkMock.close(); - }, - send: cursorSdkMock.send, - }; - }); - cursorSdkMock.close.mockClear(); - cursorSdkMock.cancel.mockClear(); - cursorSdkMock.prompt.mockReset(); - cursorSdkMock.prompt.mockResolvedValue({ - id: "run-cursor-text-generation-test", - status: "finished", - result: - '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}', - }); -}); - -describe("CursorTextGeneration", () => { - it.effect("resolves the browser credential for every request after an account change", () => - Effect.gen(function* () { - let apiKey = "first-browser-key"; - const generation = yield* makeCursorTextGeneration( - cursorSettings, - {}, - Effect.sync(() => apiKey), - ); - const input = { - cwd: process.cwd(), - branch: "feature/cursor", - stagedSummary: "M file.ts", - stagedPatch: "diff", - modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "auto"), - }; - yield* generation.generateCommitMessage(input); - expect(cursorSdkMock.create).toHaveBeenLastCalledWith( - expect.objectContaining({ apiKey: "first-browser-key" }), - ); - apiKey = "second-browser-key"; - yield* generation.generateCommitMessage(input); - expect(cursorSdkMock.create).toHaveBeenLastCalledWith( - expect.objectContaining({ apiKey: "second-browser-key" }), - ); - }).pipe(Effect.provide(layerFs)), - ); - - it.effect("uses the Cursor SDK prompt API with model parameters and API key", () => - Effect.gen(function* () { - const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { - CURSOR_API_KEY: "test-cursor-key", - }); - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-text-generation", - stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", - stagedPatch: - "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", - modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ - { id: "thinking", value: "high" }, - { id: "contextWindow", value: "1m" }, - { id: "fastMode", value: true }, - ]), - }); - - expect(generated.subject).toBe("Add generated commit message"); - expect(generated.body).toBe("- verify cursor sdk text generation"); - - expect(cursorSdkMock.prompt).toHaveBeenCalledTimes(1); - const [prompt, options] = ( - cursorSdkMock.prompt.mock.calls as unknown as Array<[string, unknown]> - )[0]!; - expect(prompt).toContain("Staged patch:"); - expect(options).toEqual({ - apiKey: "test-cursor-key", - mode: "plan", - model: { - id: "gpt-5.4", - params: [ - { id: "thinking", value: "high" }, - { id: "context", value: "1m" }, - { id: "fast", value: "true" }, - ], - }, - local: { - cwd: "/isolated-text-generation", - autoReview: false, - sandboxOptions: { enabled: true }, - settingSources: [], - enableAgentRetries: true, - }, - }); - }).pipe(Effect.provide(layerFs)), - ); - - it.effect("continues in the temp directory when the SDK cannot sandbox", () => - Effect.gen(function* () { - cursorSdkMock.create.mockImplementationOnce(async () => { - throw new Error( - "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.", - ); - }); - const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { - CURSOR_API_KEY: "test-cursor-key", - }); - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-text-generation", - stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", - stagedPatch: "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts", - modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), - }); - - expect(generated.subject).toBe("Add generated commit message"); - expect(cursorSdkMock.create).toHaveBeenCalledTimes(2); - expect(cursorSdkMock.create.mock.calls[0]?.[0]).toMatchObject({ - local: { sandboxOptions: { enabled: true } }, - }); - expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({ - local: { - cwd: "/isolated-text-generation", - autoReview: false, - sandboxOptions: { enabled: false }, - settingSources: [], - enableAgentRetries: true, - }, - }); - }).pipe(Effect.provide(layerFs)), - ); - - it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () => - Effect.gen(function* () { - cursorSdkMock.create.mockImplementationOnce(async () => { - throw new Error("Cursor SDK network down"); - }); - const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { - CURSOR_API_KEY: "test-cursor-key", - }); - - const error = yield* Effect.flip( - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-text-generation", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), - }), - ); - - expect(error.detail).toBe("Cursor SDK text generation failed."); - expect(cursorSdkMock.create).toHaveBeenCalledTimes(1); - }).pipe(Effect.provide(layerFs)), - ); - - it.effect("accepts json objects with extra assistant text around them", () => - Effect.gen(function* () { - cursorSdkMock.prompt.mockResolvedValueOnce({ - id: "run-cursor-text-generation-test", - status: "finished", - result: - 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', - }); - const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { - CURSOR_API_KEY: "test-cursor-key", - }); - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-noisy-json", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: { - instanceId: ProviderInstanceId.make("cursor"), - model: "composer-2", - }, - }); - - expect(generated.subject).toBe("Update README dummy comment with attribution and date"); - expect(generated.body).toBe(""); - }).pipe(Effect.provide(layerFs)), - ); - - it.effect("generates thread titles through Cursor SDK text generation", () => - Effect.gen(function* () { - cursorSdkMock.prompt.mockResolvedValueOnce({ - id: "run-cursor-title-generation-test", - status: "finished", - result: '{"title":"\\"Trim reconnect spinner status after resume.\\""}', - }); - const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { - CURSOR_API_KEY: "test-cursor-key", - }); - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Fix the reconnect spinner after a resumed session.", - modelSelection: { - instanceId: ProviderInstanceId.make("cursor"), - model: "composer-2", - }, - }); - - expect(generated.title).toBe("Trim reconnect spinner status after resume."); - }).pipe(Effect.provide(layerFs)), - ); - - it.effect.each(["error", "cancelled"] as const)( - "rejects a %s Cursor SDK run that includes valid title JSON", - (status) => - Effect.gen(function* () { - const promptResult = { - id: "run-cursor-partial-title-test", - status, - result: '{"title":"Partial title from a failed run."}', - } satisfies RunResult; - cursorSdkMock.prompt.mockResolvedValueOnce(promptResult); - const generation = yield* makeCursorTextGeneration(cursorSettings, { - CURSOR_API_KEY: "test-cursor-key", - }); - const failure = yield* Effect.flip( - generation.generateThreadTitle({ - cwd: process.cwd(), - message: "Fix the reconnect spinner after a resumed session.", - modelSelection: { - instanceId: ProviderInstanceId.make("cursor"), - model: "composer-2", - }, - }), - ); - expect(failure).toBeInstanceOf(TextGenerationError); - expect(failure.operation).toBe("generateThreadTitle"); - expect(failure.detail).toBe( - status === "cancelled" - ? "Cursor SDK request was cancelled." - : "Cursor SDK request finished with an error.", - ); - expect(cursorSdkMock.close).toHaveBeenCalledOnce(); - }).pipe(Effect.provide(layerFs)), - ); - - it.effect("fails closed when ambient sandbox policy can expand write access", () => - Effect.gen(function* () { - hasCustomPolicy = true; - const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" }); - const failure = yield* Effect.flip( - generation.generateThreadTitle({ - cwd: "/real-workspace", - message: "Title", - modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, - }), - ); - expect(failure.detail).toContain("custom ~/.cursor/sandbox.json"); - expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layerFs)), - ); - - it.effect("cancels the native run when text generation times out", () => - Effect.gen(function* () { - let started!: () => void; - const called = new Promise((resolve) => { - started = resolve; - }); - cursorSdkMock.prompt.mockImplementationOnce(() => { - started(); - return new Promise(() => {}); - }); - const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" }); - const result = yield* generation - .generateThreadTitle({ - cwd: "/real-workspace", - message: "Title", - modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, - }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Effect.promise(() => called); - yield* TestClock.adjust("180 seconds"); - expect((yield* Fiber.join(result)).detail).toContain("timed out"); - expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); - expect(cursorSdkMock.close).toHaveBeenCalledOnce(); - }).pipe(Effect.provide(layerFs), Effect.scoped), - ); - - it.effect.each(["create", "send"] as const)( - "times out pending %s and releases its late SDK resource", - (phase) => - Effect.gen(function* () { - let started!: () => void; - const called = new Promise((resolve) => { - started = resolve; - }); - let resolveLate!: (resource: unknown) => void; - const pending = new Promise((resolve) => { - resolveLate = resolve; - }); - const wait = vi.fn(); - const agent = { - close: cursorSdkMock.close, - [Symbol.asyncDispose]: async () => { - cursorSdkMock.close(); - }, - send: async () => { - started(); - return pending; - }, - }; - cursorSdkMock.create.mockImplementation(async () => { - if (phase === "create") { - started(); - return pending; - } - return agent; - }); - const generation = yield* makeCursorTextGeneration(cursorSettings, { - CURSOR_API_KEY: "key", - }); - const result = yield* generation - .generateThreadTitle({ - cwd: "/real-workspace", - message: "Title", - modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, - }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Effect.promise(() => called); - yield* TestClock.adjust("180 seconds"); - expect((yield* Fiber.join(result)).detail).toContain("timed out"); - let cleanup!: () => void; - const cleaned = new Promise((resolve) => { - cleanup = resolve; - }); - if (phase === "create") cursorSdkMock.close.mockImplementationOnce(cleanup); - else - cursorSdkMock.cancel.mockImplementationOnce(async () => { - cleanup(); - }); - resolveLate( - phase === "create" ? agent : { status: "running", cancel: cursorSdkMock.cancel, wait }, - ); - yield* Effect.promise(() => cleaned); - expect(cursorSdkMock.close).toHaveBeenCalledOnce(); - expect(wait).not.toHaveBeenCalled(); - if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); - }).pipe(Effect.provide(layerFs), Effect.scoped), - ); - - it.effect("requires CURSOR_API_KEY before calling the SDK", () => - Effect.gen(function* () { - const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {}); - - const error = yield* Effect.flip( - textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-api-key", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: { - instanceId: ProviderInstanceId.make("cursor"), - model: "composer-2", - }, - }), - ); - - expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings."); - expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); - }).pipe(Effect.provide(layerFs)), - ); -}); diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts deleted file mode 100644 index fb796438158b168bcd85b180ad88a4e3cf3fdb95..0000000000000000000000000000000000000000 --- a/apps/server/src/textGeneration/CursorTextGeneration.ts +++ /dev/null @@ -1,204 +0,0 @@ -import * as NodeOS from "node:os"; -import * as FileSystem from "effect/FileSystem"; - -import type { AgentOptions, RunResult } from "@cursor/sdk"; -import { Agent } from "../provider/cursorSdk.ts"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -import { - type CursorSettings, - type ProviderSetupError, - TextGenerationError, -} from "@t3tools/contracts"; - -import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; -import { cursorSdkModelSelection } from "../provider/cursorSdkModel.ts"; -import type { CursorAuth } from "../provider/CursorAuth.ts"; - -const CURSOR_TIMEOUT_MS = 180_000; - -const isTextGenerationError = Schema.is(TextGenerationError); - -function cursorSdkResultDetail(result: RunResult): string { - switch (result.status) { - case "cancelled": - return "Cursor SDK request was cancelled."; - case "error": - return "Cursor SDK request finished with an error."; - case "finished": - return "Cursor SDK returned empty output."; - } -} - -/** - * The SDK throws this when `sandboxOptions.enabled` is set and local sandboxing - * is unavailable. That happens on hosts that cannot launch `cursorsandbox`, and - * also after an unsandboxed run caches "unsupported" for the process. - */ -function cursorSandboxUnsupported(cause: unknown): boolean { - const seen = new Set(); - let current = cause; - while (typeof current === "object" && current !== null && !seen.has(current)) { - seen.add(current); - if (current instanceof Error && current.message.includes("sandboxing is not supported")) { - return true; - } - current = Reflect.get(current, "cause"); - } - return false; -} - -/** - * Build a Cursor text-generation closure bound to a specific `CursorSettings` - * payload. See `makeCodexAdapter` for the overall per-instance rationale. - */ -export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( - cursorSettings: CursorSettings, - environment?: NodeJS.ProcessEnv, - resolveApiKey?: Effect.Effect, - withAccess?: CursorAuth["withAccess"], -) { - const fs = yield* FileSystem.FileSystem; - const resolvedEnvironment = environment ?? process.env; - - const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) => - Effect.gen(function* () { - if (!cursorSettings.enabled) { - return yield* new TextGenerationError({ - operation, - detail: "Cursor is disabled in T3 Code settings.", - }); - } - - const apiKey = resolveApiKey - ? yield* resolveApiKey - : resolvedEnvironment.CURSOR_API_KEY?.trim(); - if (!apiKey) { - return yield* new TextGenerationError({ - operation, - detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", - }); - } - - return apiKey; - }); - - // Ignores `cwd`: the agent runs in an empty temp directory, away from the project. - const runCursorJson: TextGenerationOperations.Runner = (input) => { - const { operation, prompt, modelSelection } = input; - return Effect.gen(function* () { - const apiKey = yield* resolveCursorApiKey(operation); - // The SDK loads sandbox.json independently of settingSources and lets it - // expand the writable paths. Its public API cannot override that policy. - if (yield* fs.exists(`${NodeOS.homedir()}/.cursor/sandbox.json`)) { - return yield* new TextGenerationError({ - operation, - detail: - "Cursor text generation cannot enforce workspace isolation with a custom ~/.cursor/sandbox.json. Use another text-generation provider.", - }); - } - const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" }); - const agentOptions = { - apiKey, - mode: "plan", - model: cursorSdkModelSelection(modelSelection), - local: { - cwd, - autoReview: false, - sandboxOptions: { enabled: true }, - settingSources: [], - enableAgentRetries: true, - }, - } satisfies AgentOptions; - const createCursorAgent = (sandboxEnabled: boolean) => - Effect.tryPromise((signal) => - Agent.create( - sandboxEnabled - ? agentOptions - : { - ...agentOptions, - local: { - ...agentOptions.local, - sandboxOptions: { enabled: false }, - }, - }, - ).then((agent) => { - if (signal.aborted) agent.close(); - return agent; - }), - ); - - const request = Effect.gen(function* () { - // Prefer the sandbox. When the SDK refuses it, the empty temp directory - // and empty setting sources still keep this run off the user's project. - const agent = yield* Effect.acquireRelease( - createCursorAgent(true).pipe( - Effect.catchIf(cursorSandboxUnsupported, () => createCursorAgent(false)), - ), - (agent) => - Effect.tryPromise(() => agent[Symbol.asyncDispose]()).pipe( - Effect.timeout("5 seconds"), - Effect.ignore({ log: true }), - ), - { interruptible: true }, - ); - const run = yield* Effect.tryPromise((signal) => - agent.send(prompt).then((run) => { - if (signal.aborted) void run.cancel().catch(() => undefined); - return run; - }), - ); - yield* Effect.addFinalizer(() => - run.status === "running" - ? Effect.tryPromise(() => run.cancel()).pipe( - Effect.timeout("5 seconds"), - Effect.ignore({ log: true }), - ) - : Effect.void, - ); - return yield* Effect.tryPromise(() => run.wait()); - }).pipe(Effect.scoped); - const promptResult = yield* request.pipe( - Effect.timeoutOption(CURSOR_TIMEOUT_MS), - Effect.flatMap( - Option.match({ - onNone: () => - Effect.fail( - new TextGenerationError({ - operation, - detail: "Cursor SDK request timed out.", - }), - ), - onSome: (value) => Effect.succeed(value), - }), - ), - ); - - const rawResult = promptResult.result?.trim() ?? ""; - if (promptResult.status !== "finished" || !rawResult) { - return yield* new TextGenerationError({ - operation, - detail: cursorSdkResultDetail(promptResult), - }); - } - - return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult); - }).pipe( - (effect) => (withAccess ? withAccess(effect) : effect), - Effect.scoped, - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : new TextGenerationError({ - operation, - detail: "Cursor SDK text generation failed.", - cause, - }), - ), - ); - }; - - return TextGenerationOperations.fromRunner("CursorTextGeneration", runCursorJson); -}); diff --git a/apps/server/src/usage/cursorUsageReader.test.ts b/apps/server/src/usage/cursorUsageReader.test.ts index 0c6783757748072b08af91480d4469d1790f319b..4897f4604a9e30a027a9c6d31ac4973cf1c73f06 100644 --- a/apps/server/src/usage/cursorUsageReader.test.ts +++ b/apps/server/src/usage/cursorUsageReader.test.ts @@ -1,6 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; -import { CursorKeychainTimeoutError } from "../provider/cursorKeychainToken.ts"; +import { CursorKeychainTimeoutError } from "@t3tools/provider-cursor/server"; import { readCursorAccountUsage } from "./cursorUsageReader.ts"; describe("readCursorAccountUsage", () => { diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 86ba226a0529de528493bc75fa74fb4f4742c1c6..95b87f1984bb3f58e591e6668314fad4facca163 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -12,7 +12,7 @@ import type { UsageRecord } from "./usageTranscripts.ts"; import { CursorKeychainTimeoutError, readMacCursorAccessToken, -} from "../provider/cursorKeychainToken.ts"; +} from "@t3tools/provider-cursor/server"; function object(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/apps/web/package.json b/apps/web/package.json index 372c956ec04ea06c17a98e5bdaafc940c6d65def..64012d9bdab346789d7677e19a99767157eae830 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-cursor": "workspace:*", "@t3tools/provider-muse": "workspace:*", "@t3tools/provider-opencode": "workspace:*", "@t3tools/provider-pi": "workspace:*", diff --git a/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 5337fbc9cf3fbfecc7e6ffac4e155126804b41e9..927f78ceb4f279df0dd0416019543227afd5b551 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -3,7 +3,7 @@ import { type CSSProperties, memo } from "react"; import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; import { ProviderDriverKind } from "@t3tools/contracts"; -import { AntigravityIcon, ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI } from "../Icons"; +import { AntigravityIcon, ClaudeAI, GrokIcon, Icon, OpenAI } from "../Icons"; import { cn } from "~/lib/utils"; import { providerClients } from "../settings/providerDriverMeta"; @@ -17,7 +17,6 @@ import { const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: OpenAI, [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, - [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, }; @@ -25,7 +24,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial> = { const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("codex")]: "text-black dark:text-white", [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]", - [ProviderDriverKind.make("cursor")]: "text-[#26251E] dark:text-[#EDECEC]", [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]", [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]", }; diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index ae70d22ec9d7195c3c9b57453c7d81e4784eed4e..aca6ffbc724f21ae6b51d0a2f56552666dcecacf 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -3,11 +3,11 @@ import { AntigravitySettings, ClaudeSettings, CodexSettings, - CursorSettings, GrokSettings, ProviderDriverKind, } from "@t3tools/contracts"; import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { cursorClient } from "@t3tools/provider-cursor/client"; import { museClient } from "@t3tools/provider-muse/client"; import { openCodeClient } from "@t3tools/provider-opencode/client"; import { piClient } from "@t3tools/provider-pi/client"; @@ -24,20 +24,7 @@ export const providerClients = makeProviderClientRegistry([ label: "Claude", settingsSchema: ClaudeSettings, }, - { - driverKind: ProviderDriverKind.make("cursor"), - label: "Cursor", - settingsSchema: CursorSettings, - environmentFields: [ - { - name: "CURSOR_API_KEY", - label: "Cursor API key", - description: "Optional. Overrides browser sign-in for this provider.", - placeholder: "Paste API key", - sensitive: true, - }, - ], - }, + cursorClient, { driverKind: ProviderDriverKind.make("grok"), label: "Grok", diff --git a/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index 3c6fa7f006492667e5ee23c78213d63feeb4e5f2..b442053c8001c809e729ff5bebf19a482b79b6be 100644 --- a/docs/internals/adding-a-provider.md +++ b/docs/internals/adding-a-provider.md @@ -115,7 +115,7 @@ A provider lives in its own `packages/provider-` package, with Settings live in `providerInstances`; new providers are off by default. - **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts, and shared. It reaches the server through `ProviderHost` (paths, settings, background demand, - attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`. + attachments, stored credentials), never through `apps/server`. Tests use `@t3tools/provider-testing`. - **Server registration:** the driver's entry in [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts) and the adapter driver's in [`builtInProviderAdapterDrivers.ts`](../../apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts). diff --git a/knip.jsonc b/knip.jsonc index 24d0f85db881fef00bd627936cb745db3d905d7c..c3fed7f19200a324046f6451ecc951ea0830d2ce 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -36,7 +36,9 @@ ], // Keep the transitive Effect runtime pinned for standalone npm installs. // The Vite+ web build prerequisite. - "ignoreDependencies": ["@effect/platform-node-shared", "@t3tools/web"], + // Native and disk-loaded provider packages stay external to the bundle; the + // CLI archive and desktop sidecar stage them from this manifest. + "ignoreDependencies": ["@effect/platform-node-shared", "@t3tools/web", "@napi-rs/keyring"], }, "apps/desktop": { // Electron loads these bundles by filename rather than importing them. diff --git a/package.json b/package.json index e261ffedfb3509af6d12ab3f16eb00e1aa35f9aa..545a4247c098df9650aadf7f163f536f70645b96 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a302412f179e77931910b91d45fea8fcc49a328d..c43df190af3f724b9d934c7d6966f485ee72bcb8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -707,32 +707,6 @@ export const ClaudeSettings = makeProviderSettingsSchema( ); export type ClaudeSettings = typeof ClaudeSettings.Type; -export const CursorSettings = makeProviderSettingsSchema( - { - // Off by default like Grok and OpenCode. Users opt in from Settings. - enabled: Schema.Boolean.pipe( - Schema.withDecodingDefault(Effect.succeed(false)), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - // Keep V1's CLI configuration when V2 rewrites the shared settings file. - // V2's Cursor SDK does not use these fields. - binaryPath: Schema.optionalKey(TrimmedString).pipe( - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - apiEndpoint: Schema.optionalKey(TrimmedString).pipe( - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - customModels: Schema.Array(CustomModelSetting).pipe( - Schema.withDecodingDefault(Effect.succeed([])), - Schema.annotateKey({ providerSettingsForm: { hidden: true } }), - ), - }, - { - order: [], - }, -); -export type CursorSettings = typeof CursorSettings.Type; - export const GrokSettings = makeProviderSettingsSchema( { // Off by default (like Cursor and OpenCode): the binding is not yet diff --git a/packages/provider-core/src/server/ProviderHost.ts b/packages/provider-core/src/server/ProviderHost.ts index f0a695da43d404eddf47d8c3ff16fd39eb381dcb..318037cdcb3bf0b5e92bb65e9ff480e9c1fd4177 100644 --- a/packages/provider-core/src/server/ProviderHost.ts +++ b/packages/provider-core/src/server/ProviderHost.ts @@ -17,9 +17,12 @@ import type { } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; import type * as Scope from "effect/Scope"; import type * as Stream from "effect/Stream"; +import type { ProviderCredentialError } from "./errors.ts"; + export interface ProviderHostPaths { /** Directory the server was started in; the fallback cwd for sessions without a project. */ readonly cwd: string; @@ -31,6 +34,15 @@ export interface ProviderHostPaths { readonly providerStatusCacheDir: string; } +/** One opaque credential blob in the server's secret store. Only the provider decodes it. */ +export interface ProviderCredentials { + /** Instances whose bindings are equal share these credentials. */ + readonly binding: { readonly owner: "t3"; readonly key: string }; + readonly get: Effect.Effect, ProviderCredentialError>; + readonly set: (credentials: Uint8Array) => Effect.Effect; + readonly remove: Effect.Effect; +} + export interface ProviderHostShape { readonly paths: ProviderHostPaths; readonly settings: { @@ -47,6 +59,11 @@ export interface ProviderHostShape { * resolve inside the attachments directory. */ readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; + /** The credentials stored for `bindingId`, usually an instance id, under a provider-chosen namespace. */ + readonly credentials: ( + namespace: string, + bindingId: string, + ) => Effect.Effect; } export class ProviderHost extends Context.Service()( diff --git a/packages/provider-core/src/server/errors.ts b/packages/provider-core/src/server/errors.ts index eda2eaa686ecef6c18b89e1fe66ca821d62ce7f5..46ca6e6541fecdb349a8900b7c1e6370ec628478 100644 --- a/packages/provider-core/src/server/errors.ts +++ b/packages/provider-core/src/server/errors.ts @@ -19,3 +19,16 @@ export class ProviderDriverError extends Schema.TaggedError return `Provider driver '${this.driver}' failed to create instance '${this.instanceId}': ${this.detail}`; } } + +/** Reading or writing a provider's stored credentials failed. */ +export class ProviderCredentialError extends Schema.TaggedError()( + "ProviderCredentialError", + { + operation: Schema.Literals(["get", "set", "remove"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not ${this.operation} stored provider credentials.`; + } +} diff --git a/packages/provider-core/src/server/managedProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts index a4d7dc752f1ca2a0e8e4bf5a18782f79fc8bbc67..6d9e3827531bd58f600bcbda8028c16d6fcbd865 100644 --- a/packages/provider-core/src/server/managedProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -117,6 +117,7 @@ function layerProviderHost(input: { }, shouldRunBackgroundWork: () => Effect.succeed(input.runBackgroundWork), resolveAttachmentPath: () => null, + credentials: () => Effect.die("unused"), }), ); } diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json new file mode 100644 index 0000000000000000000000000000000000000000..4a50b964733230731860c719b7e11d5c757c6072 --- /dev/null +++ b/packages/provider-cursor/package.json @@ -0,0 +1,47 @@ +{ + "name": "@t3tools/provider-cursor", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./settings": { + "types": "./src/settings.ts", + "import": "./src/settings.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./testing": { + "types": "./src/testing.ts", + "import": "./src/testing.ts" + }, + "./server/CursorAgentSdk": { + "types": "./src/server/CursorAgentSdk.ts", + "import": "./src/server/CursorAgentSdk.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "vp test run --config ../../vite.config.ts --dir ." + }, + "dependencies": { + "@cursor/sdk": "1.0.35", + "@napi-rs/keyring": "^1.3.0", + "@t3tools/contracts": "workspace:*", + "@t3tools/provider-core": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:", + "yaml": "catalog:" + }, + "devDependencies": { + "@effect/platform-node": "catalog:", + "@effect/vitest": "catalog:", + "@t3tools/provider-testing": "workspace:*", + "@types/node": "catalog:", + "vite-plus": "catalog:" + } +} diff --git a/packages/provider-cursor/src/client.ts b/packages/provider-cursor/src/client.ts new file mode 100644 index 0000000000000000000000000000000000000000..c76378ab17391c57ede37d31c708e835f3643c59 --- /dev/null +++ b/packages/provider-cursor/src/client.ts @@ -0,0 +1,34 @@ +/** + * Cursor's client definition: settings form, label, sign-in environment + * field, and glyph. Browser- and React Native-safe. + * + * @module provider-cursor/client + */ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { defineProviderClient } from "@t3tools/provider-core/client"; + +import { CursorSettings } from "./settings.ts"; + +export const cursorClient = defineProviderClient({ + driverKind: ProviderDriverKind.make("cursor"), + label: "Cursor", + settingsSchema: CursorSettings, + environmentFields: [ + { + name: "CURSOR_API_KEY", + label: "Cursor API key", + description: "Optional. Overrides browser sign-in for this provider.", + placeholder: "Paste API key", + sensitive: true, + }, + ], + icon: { + viewBox: "0 0 466.73 532.09", + fill: { light: "#26251E", dark: "#EDECEC" }, + paths: [ + { + d: "M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z", + }, + ], + }, +}); diff --git a/packages/provider-cursor/src/server.ts b/packages/provider-cursor/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..471737aa58fd92afc6b127399baa98610bee32a1 --- /dev/null +++ b/packages/provider-cursor/src/server.ts @@ -0,0 +1,10 @@ +/** + * Cursor's server entry: the driver the server registers, its adapter + * driver, the SDK runner layer the server provides once, and the keychain + * token reader the usage scanner shares. + * + * @module provider-cursor/server + */ +export { CursorDriver, type CursorDriverEnv } from "./server/driver.ts"; +export { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./server/adapter.ts"; +export { CursorKeychainTimeoutError, readMacCursorAccessToken } from "./server/keychainToken.ts"; diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a4e209bf1921de819b99fae8ae9b3a22dcde399 --- /dev/null +++ b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts @@ -0,0 +1,304 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as CursorAgentSdk from "./CursorAgentSdk.ts"; + +const cursorSdkMock = vi.hoisted(() => { + const closeExecutionOrder: Array = []; + const runOperations: Array = []; + let agentCloseFailure: unknown; + let staleRunActive = false; + const runWait = vi.fn(async () => ({ + id: "run-cursor-agent-sdk-test", + requestId: "request-cursor-agent-sdk-test", + status: "finished", + result: "ok", + model: { id: "default" }, + durationMs: 1, + })); + const runCancel = vi.fn(async () => {}); + const agentClose = vi.fn(() => { + closeExecutionOrder.push("sdk:agent.close"); + if (agentCloseFailure !== undefined) { + throw agentCloseFailure; + } + }); + const send = vi.fn( + async ( + _message: unknown, + options: { readonly onDelta?: (input: { readonly update: unknown }) => Promise }, + ) => { + runOperations.push("run.start"); + if (staleRunActive) { + throw new Error("Agent agent-cursor-agent-sdk-test already has active run"); + } + await options.onDelta?.({ + update: { + type: "assistant-message-chunk", + text: "hello", + }, + }); + return { + id: "run-cursor-agent-sdk-test", + agentId: "agent-cursor-agent-sdk-test", + wait: runWait, + cancel: runCancel, + }; + }, + ); + const create = vi.fn(async () => ({ + agentId: "agent-cursor-agent-sdk-test", + send, + close: agentClose, + })); + const resume = vi.fn(async () => ({ + agentId: "agent-cursor-agent-sdk-test", + send, + close: agentClose, + })); + const listRuns = vi.fn(async () => { + runOperations.push("agent.listRuns"); + return { + items: staleRunActive ? [{ id: "run-stale", status: "running" }] : [], + }; + }); + const cancelRun = vi.fn(async () => { + runOperations.push("agent.cancelRun"); + staleRunActive = false; + }); + + return { + agentClose, + cancelRun, + closeExecutionOrder, + create, + listRuns, + resume, + runOperations, + runCancel, + runWait, + send, + setAgentCloseFailure: (failure: unknown) => { + agentCloseFailure = failure; + }, + setStaleRunActive: (active: boolean) => { + staleRunActive = active; + }, + }; +}); + +vi.mock("./sdk.ts", () => ({ + Agent: { + create: cursorSdkMock.create, + resume: cursorSdkMock.resume, + listRuns: cursorSdkMock.listRuns, + cancelRun: cursorSdkMock.cancelRun, + messages: { + list: vi.fn(async () => []), + }, + }, +})); + +const layerTest = CursorAgentSdk.layer.pipe( + Layer.provide( + Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, { + native: { + filePath: "cursor-agent-sdk-test.log", + write: (event: unknown) => + Effect.sync(() => { + if ( + typeof event === "object" && + event !== null && + Reflect.get(Reflect.get(event, "event"), "payload")?.type === "agent.close" + ) { + cursorSdkMock.closeExecutionOrder.push("protocol:agent.close"); + } + }), + close: () => Effect.void, + }, + canonical: undefined, + }), + ), + Layer.provide(NodeServices.layer), +); + +describe("CursorAgentSdkRunner", () => { + it.effect("leaves a resumed agent alone when its next send succeeds", () => + Effect.gen(function* () { + cursorSdkMock.runOperations.length = 0; + cursorSdkMock.setStaleRunActive(false); + + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const session = yield* runner.open({ + operation: "resume", + agentId: "agent-cursor-agent-sdk-test", + options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } }, + threadId: ThreadId.make("thread-cursor-agent-sdk-normal-resume-test"), + providerSessionId: ProviderSessionId.make( + "provider-session-cursor-agent-sdk-normal-resume-test", + ), + }); + const run = yield* session.send({ message: "continue" }); + yield* run.wait; + + assert.deepStrictEqual(cursorSdkMock.runOperations, ["run.start"]); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("retries a resumed send after cancelling its abandoned local run", () => + Effect.gen(function* () { + cursorSdkMock.runOperations.length = 0; + cursorSdkMock.listRuns.mockClear(); + cursorSdkMock.cancelRun.mockClear(); + cursorSdkMock.setStaleRunActive(true); + + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const session = yield* runner.open({ + operation: "resume", + agentId: "agent-cursor-agent-sdk-test", + options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } }, + threadId: ThreadId.make("thread-cursor-agent-sdk-stale-run-test"), + providerSessionId: ProviderSessionId.make( + "provider-session-cursor-agent-sdk-stale-run-test", + ), + }); + assert.deepStrictEqual(cursorSdkMock.runOperations, []); + const run = yield* session.send({ message: "continue" }); + yield* run.wait; + + assert.deepStrictEqual(cursorSdkMock.runOperations, [ + "run.start", + "agent.listRuns", + "agent.cancelRun", + "run.start", + ]); + assert.deepStrictEqual(cursorSdkMock.listRuns.mock.calls[0], [ + "agent-cursor-agent-sdk-test", + { runtime: "local", cwd: process.cwd(), limit: 1 }, + ]); + assert.deepStrictEqual(cursorSdkMock.cancelRun.mock.calls[0], [ + "run-stale", + { + runtime: "local", + cwd: process.cwd(), + }, + ]); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("logs agent.close before invoking the Cursor SDK", () => + Effect.gen(function* () { + cursorSdkMock.agentClose.mockClear(); + cursorSdkMock.closeExecutionOrder.length = 0; + cursorSdkMock.setAgentCloseFailure(undefined); + + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const session = yield* runner.open({ + operation: "create", + options: { + model: { id: "default" }, + mode: "agent", + local: { + cwd: process.cwd(), + }, + }, + threadId: ThreadId.make("thread-cursor-agent-sdk-close-order-test"), + providerSessionId: ProviderSessionId.make( + "provider-session-cursor-agent-sdk-close-order-test", + ), + }); + + cursorSdkMock.closeExecutionOrder.length = 0; + yield* session.close; + + assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [ + "protocol:agent.close", + "sdk:agent.close", + ]); + assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("logs agent.close even when the Cursor SDK close fails", () => + Effect.gen(function* () { + cursorSdkMock.agentClose.mockClear(); + cursorSdkMock.closeExecutionOrder.length = 0; + const closeFailure = new Error("agent close failed"); + cursorSdkMock.setAgentCloseFailure(closeFailure); + + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const session = yield* runner.open({ + operation: "create", + options: { + model: { id: "default" }, + mode: "agent", + local: { + cwd: process.cwd(), + }, + }, + threadId: ThreadId.make("thread-cursor-agent-sdk-close-failure-test"), + providerSessionId: ProviderSessionId.make( + "provider-session-cursor-agent-sdk-close-failure-test", + ), + }); + + cursorSdkMock.closeExecutionOrder.length = 0; + const error = yield* Effect.flip(session.close); + + assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError); + assert.equal(error.method, "agent.close"); + assert.strictEqual(error.cause, closeFailure); + assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [ + "protocol:agent.close", + "sdk:agent.close", + ]); + assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1); + cursorSdkMock.setAgentCloseFailure(undefined); + }).pipe(Effect.provide(layerTest)), + ); + + it.effect("surfaces interaction callback failures through run.wait", () => + Effect.gen(function* () { + cursorSdkMock.create.mockClear(); + cursorSdkMock.runWait.mockClear(); + cursorSdkMock.send.mockClear(); + + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const session = yield* runner.open({ + operation: "create", + options: { + model: { id: "default" }, + mode: "agent", + local: { + cwd: process.cwd(), + autoReview: false, + sandboxOptions: { enabled: false }, + enableAgentRetries: true, + }, + }, + threadId: ThreadId.make("thread-cursor-agent-sdk-test"), + providerSessionId: ProviderSessionId.make("provider-session-cursor-agent-sdk-test"), + }); + + const callbackFailure = new Error("delta callback failed"); + const run = yield* session.send({ + message: "hello", + onDelta: () => Effect.fail(callbackFailure), + }); + + const error = yield* Effect.flip(run.wait); + + assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError); + assert.equal(error.method, "run.wait"); + assert.strictEqual(error.cause, callbackFailure); + assert.equal(cursorSdkMock.create.mock.calls.length, 1); + assert.equal(cursorSdkMock.send.mock.calls.length, 1); + assert.equal(cursorSdkMock.runWait.mock.calls.length, 1); + }).pipe(Effect.provide(layerTest)), + ); +}); diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts new file mode 100644 index 0000000000000000000000000000000000000000..30d6090fab4e9a9efb7a87701c6208edb49e70df --- /dev/null +++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts @@ -0,0 +1,611 @@ +import type { + AgentMessage, + AgentOptions, + InteractionUpdate, + RunResult, + SDKUserMessage, + SendOptions, +} from "@cursor/sdk"; +import { + type OrchestrationV2ProviderSession, + ProviderDriverKind, + type ThreadId, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { Agent, createAgentPlatform } from "./sdk.ts"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; + +export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; +export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor"); + +export class CursorAgentSdkRunnerError extends Schema.TaggedError()( + "CursorAgentSdkRunnerError", + { + method: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Cursor Agent SDK ${this.method} failed.`; + } +} + +const isCursorAgentSdkRunnerError = Schema.is(CursorAgentSdkRunnerError); + +export interface CursorAgentSdkOpenInput { + readonly operation: "create" | "resume"; + readonly agentId?: string; + readonly options: AgentOptions; + readonly threadId: ThreadId; + readonly providerSessionId: OrchestrationV2ProviderSession["id"]; +} + +export interface CursorAgentSdkSendInput { + readonly message: string | SDKUserMessage; + readonly options?: Omit; + readonly onDelta?: (update: InteractionUpdate) => Effect.Effect; +} + +export interface CursorAgentSdkRun { + readonly runId: string; + readonly agentId: string; + readonly wait: Effect.Effect; + readonly cancel: Effect.Effect; +} + +export interface CursorAgentSdkSession { + readonly agentId: string; + readonly send: ( + input: CursorAgentSdkSendInput, + ) => Effect.Effect; + readonly listMessages: Effect.Effect, CursorAgentSdkRunnerError>; + readonly close: Effect.Effect; +} + +export interface CursorAgentSdkRunnerShape { + readonly open: ( + input: CursorAgentSdkOpenInput, + ) => Effect.Effect; + readonly assertComplete: Effect.Effect; +} + +export class CursorAgentSdkRunner extends Context.Service< + CursorAgentSdkRunner, + CursorAgentSdkRunnerShape +>()("@t3tools/provider-cursor/server/CursorAgentSdk/CursorAgentSdkRunner") {} + +export interface CursorAgentSdkLoggedAgentOptions { + readonly model?: AgentOptions["model"]; + readonly hasName?: boolean; + readonly mode?: AgentOptions["mode"]; + readonly local?: { + readonly hasCwd?: boolean; + readonly autoReview?: boolean; + readonly settingSources?: AgentOptions["local"] extends infer Local + ? Local extends { readonly settingSources?: infer Sources } + ? Sources + : never + : never; + readonly sandboxEnabled?: boolean; + readonly enableAgentRetries?: boolean; + readonly hasCustomTools?: boolean; + }; + readonly agents?: ReadonlyArray; +} + +export interface CursorAgentSdkLoggedSendOptions { + readonly model?: SendOptions["model"]; + readonly mode?: SendOptions["mode"]; + readonly local?: { + readonly force?: boolean; + readonly hasCustomTools?: boolean; + }; + readonly idempotencyKey?: string; +} + +export type CursorAgentSdkProtocolLogEvent = + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "agent.open"; + readonly operation: CursorAgentSdkOpenInput["operation"]; + readonly agentId?: string; + readonly options: CursorAgentSdkLoggedAgentOptions; + }; + } + | { + readonly direction: "incoming"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "agent.opened"; + readonly agentId: string; + }; + } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "run.start"; + readonly message: string | SDKUserMessage; + readonly options: CursorAgentSdkLoggedSendOptions; + }; + } + | { + readonly direction: "incoming"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "run.started"; + readonly runId: string; + readonly agentId: string; + }; + } + | { + readonly direction: "incoming"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "interaction.update"; + readonly runId: string; + readonly update: InteractionUpdate; + }; + } + | { + readonly direction: "incoming"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "run.completed"; + readonly result: RunResult; + }; + } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "run.cancel"; + readonly runId: string; + }; + } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "agent.messages.list"; + readonly agentId: string; + }; + } + | { + readonly direction: "incoming"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "agent.messages"; + readonly agentId: string; + readonly messages: ReadonlyArray; + }; + } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "agent.close"; + readonly agentId: string; + }; + }; + +export type CursorAgentSdkProtocolLogger = ( + event: CursorAgentSdkProtocolLogEvent, +) => Effect.Effect; + +function runnerError(cause: unknown, method: string): CursorAgentSdkRunnerError { + return isCursorAgentSdkRunnerError(cause) + ? cause + : new CursorAgentSdkRunnerError({ method, cause }); +} + +function isActiveRunConflict(cause: unknown): boolean { + return cause instanceof Error && /already has active run/i.test(cause.message); +} + +export function isCursorCancellationError(cause: unknown): boolean { + let current = cause; + const seen = new Set(); + + while (typeof current === "object" && current !== null && !seen.has(current)) { + if (Reflect.get(current, "name") === "AbortError") { + return true; + } + seen.add(current); + current = Reflect.get(current, "cause"); + } + + return false; +} + +export function loggedCursorAgentOptions(options: AgentOptions): CursorAgentSdkLoggedAgentOptions { + return { + ...(options.model === undefined ? {} : { model: options.model }), + ...(options.name === undefined ? {} : { hasName: true }), + ...(options.mode === undefined ? {} : { mode: options.mode }), + ...(options.local === undefined + ? {} + : { + local: { + ...(options.local.cwd === undefined ? {} : { hasCwd: true }), + ...(options.local.autoReview === undefined + ? {} + : { autoReview: options.local.autoReview }), + ...(options.local.settingSources === undefined + ? {} + : { settingSources: options.local.settingSources }), + ...(options.local.sandboxOptions === undefined + ? {} + : { sandboxEnabled: options.local.sandboxOptions.enabled }), + ...(options.local.enableAgentRetries === undefined + ? {} + : { enableAgentRetries: options.local.enableAgentRetries }), + ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }), + }, + }), + ...(options.agents === undefined ? {} : { agents: Object.keys(options.agents).toSorted() }), + }; +} + +export function loggedCursorSendOptions( + options: Omit | undefined, +): CursorAgentSdkLoggedSendOptions { + if (options === undefined) { + return {}; + } + return { + ...(options.model === undefined ? {} : { model: options.model }), + ...(options.mode === undefined ? {} : { mode: options.mode }), + ...(options.local === undefined + ? {} + : { + local: { + ...(options.local.force === undefined ? {} : { force: options.local.force }), + ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }), + }, + }), + ...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }), + }; +} + +function makeCursorAgentSdkProtocolLogger(input: { + readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; + readonly threadId: ThreadId; + readonly providerSessionId: OrchestrationV2ProviderSession["id"]; +}): CursorAgentSdkProtocolLogger | undefined { + if (input.nativeEventLogger === undefined) { + return undefined; + } + const nativeEventLogger = input.nativeEventLogger; + return (event) => + nativeEventLogger + .write( + { + provider: CURSOR_PROVIDER, + protocol: CURSOR_AGENT_SDK_PROTOCOL, + kind: "protocol", + providerSessionId: input.providerSessionId, + event, + }, + input.threadId, + ) + .pipe(Effect.ignore); +} + +/** + * The Cursor SDK decides once per process whether local sandboxing works, and + * caches the answer the first time any run starts. Only sandboxed runs point + * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access) + * run it caches "unsupported" and rejects every later sandboxed run until the + * server restarts. Warming a bare sandboxed executor before the first + * unsandboxed agent opens lets the SDK find the helper and cache the real + * answer. Warming is best effort: on a machine without sandbox support it + * fails, the SDK caches "unsupported", and sandboxed runs report that as + * before. + */ +let cursorSandboxSupportPrime: Promise | undefined; + +function primeCursorSandboxSupport(options: AgentOptions): Promise { + cursorSandboxSupportPrime ??= (async () => { + const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined; + const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd }); + const release = await platform.prewarmLocalWorkspace({ + ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), + local: { + ...(cwd === undefined ? {} : { cwd }), + settingSources: [], + sandboxOptions: { enabled: true }, + }, + }); + await release(); + })().catch(() => undefined); + return cursorSandboxSupportPrime; +} + +/** + * Runs agents through the Cursor SDK, logging every frame to the protocol + * logger chosen for each opened agent. The live layer writes the native + * provider event log; the replay recorder turns the same frames into a + * transcript. + */ +export function makeCursorAgentSdkRunner( + protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined, +): CursorAgentSdkRunnerShape { + return CursorAgentSdkRunner.of({ + open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) { + if (input.options.local?.sandboxOptions?.enabled === false) { + yield* Effect.promise(() => primeCursorSandboxSupport(input.options)); + } + const protocolLogger = protocolLoggerFor(input); + const log = (event: CursorAgentSdkProtocolLogEvent) => + protocolLogger === undefined ? Effect.void : protocolLogger(event); + + yield* log({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "agent.open", + operation: input.operation, + ...(input.agentId === undefined ? {} : { agentId: input.agentId }), + options: loggedCursorAgentOptions(input.options), + }, + }); + + const agent = yield* Effect.tryPromise({ + try: () => + input.operation === "create" + ? Agent.create(input.options) + : Agent.resume(input.agentId!, input.options), + catch: (cause) => runnerError(cause, `agent.${input.operation}`), + }); + + yield* log({ + direction: "incoming", + stage: "decoded", + payload: { + type: "agent.opened", + agentId: agent.agentId, + }, + }); + + const cwd = + typeof input.options.local?.cwd === "string" + ? input.options.local.cwd + : input.options.local?.cwd?.[0]; + const runOptions = { + runtime: "local" as const, + ...(cwd === undefined ? {} : { cwd }), + ...(input.options.local?.store === undefined ? {} : { store: input.options.local.store }), + }; + let abandonedRunRecoveryAvailable = input.operation === "resume"; + + return { + agentId: agent.agentId, + send: Effect.fn("CursorAgentSdkSession.send")(function* (sendInput) { + const context = yield* Effect.context(); + yield* log({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "run.start", + message: sendInput.message, + options: loggedCursorSendOptions(sendInput.options), + }, + }); + + let callbacksReady = false; + const pendingUpdates: Array = []; + let callbackFailure: { readonly cause: unknown } | undefined; + let callbackChain = Promise.resolve(); + let runId = ""; + const dispatchUpdate = (update: InteractionUpdate): Promise => { + callbackChain = callbackChain + .then(() => { + if (callbackFailure !== undefined) { + return; + } + return Effect.runPromiseWith(context)( + log({ + direction: "incoming", + stage: "decoded", + payload: { + type: "interaction.update", + runId, + update, + }, + }).pipe(Effect.andThen(sendInput.onDelta?.(update) ?? Effect.void)), + ); + }) + .catch((cause) => { + callbackFailure ??= { cause }; + }); + return callbackChain; + }; + + const startRun = () => + Effect.tryPromise({ + try: () => + agent.send(sendInput.message, { + ...sendInput.options, + onDelta: async ({ update }) => { + if (!callbacksReady) { + pendingUpdates.push(update); + return; + } + await dispatchUpdate(update); + }, + }), + catch: (cause) => runnerError(cause, "run.start"), + }); + const run = yield* startRun().pipe( + Effect.catchIf( + (error) => abandonedRunRecoveryAvailable && isActiveRunConflict(error.cause), + (error) => + Effect.gen(function* () { + abandonedRunRecoveryAvailable = false; + const latestRun = yield* Effect.tryPromise({ + try: () => Agent.listRuns(agent.agentId, { ...runOptions, limit: 1 }), + catch: (cause) => runnerError(cause, "agent.listRuns"), + }); + const activeRun = latestRun.items.find( + (candidate) => candidate.status === "running", + ); + if (activeRun === undefined) { + return yield* error; + } + yield* log({ + direction: "outgoing", + stage: "decoded", + payload: { type: "run.cancel", runId: activeRun.id }, + }); + yield* Effect.tryPromise({ + try: () => Agent.cancelRun(activeRun.id, runOptions), + catch: (cause) => runnerError(cause, "agent.cancelRun"), + }); + return yield* startRun(); + }), + ), + ); + abandonedRunRecoveryAvailable = false; + runId = run.id; + yield* log({ + direction: "incoming", + stage: "decoded", + payload: { + type: "run.started", + runId: run.id, + agentId: run.agentId, + }, + }); + callbacksReady = true; + for (const update of pendingUpdates) { + yield* Effect.tryPromise({ + try: () => dispatchUpdate(update), + catch: (cause) => runnerError(cause, "run.onDelta"), + }); + } + + return { + runId: run.id, + agentId: run.agentId, + wait: Effect.tryPromise({ + try: async () => { + const result = await run.wait(); + await callbackChain; + if (callbackFailure !== undefined) { + throw callbackFailure.cause; + } + return result; + }, + catch: (cause) => runnerError(cause, "run.wait"), + }).pipe( + Effect.tap((result) => + log({ + direction: "incoming", + stage: "decoded", + payload: { + type: "run.completed", + result, + }, + }), + ), + ), + cancel: log({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "run.cancel", + runId: run.id, + }, + }).pipe( + Effect.andThen( + Effect.tryPromise({ + try: async () => { + try { + await run.cancel(); + } catch (cause) { + if (!isCursorCancellationError(cause)) { + throw cause; + } + } + }, + catch: (cause) => runnerError(cause, "run.cancel"), + }), + ), + ), + } satisfies CursorAgentSdkRun; + }), + listMessages: log({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "agent.messages.list", + agentId: agent.agentId, + }, + }).pipe( + Effect.andThen( + Effect.tryPromise({ + try: () => + Agent.messages.list(agent.agentId, { + runtime: "local", + ...(cwd === undefined ? {} : { cwd }), + }), + catch: (cause) => runnerError(cause, "agent.messages.list"), + }), + ), + Effect.tap((messages) => + log({ + direction: "incoming", + stage: "decoded", + payload: { + type: "agent.messages", + agentId: agent.agentId, + messages, + }, + }), + ), + ), + close: log({ + direction: "outgoing", + stage: "decoded", + payload: { + type: "agent.close", + agentId: agent.agentId, + }, + }).pipe( + Effect.andThen( + Effect.try({ + try: () => agent.close(), + catch: (cause) => runnerError(cause, "agent.close"), + }), + ), + ), + } satisfies CursorAgentSdkSession; + }), + assertComplete: Effect.void, + }); +} + +export const layer: Layer.Layer< + CursorAgentSdkRunner, + never, + ProviderEventLoggers.ProviderEventLoggers +> = Layer.effect( + CursorAgentSdkRunner, + Effect.gen(function* () { + const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; + return makeCursorAgentSdkRunner((input) => + makeCursorAgentSdkProtocolLogger({ + nativeEventLogger, + threadId: input.threadId, + providerSessionId: input.providerSessionId, + }), + ); + }), +); diff --git a/packages/provider-cursor/src/server/CursorSdkCatalog.test.ts b/packages/provider-cursor/src/server/CursorSdkCatalog.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cbd24f8b6be705b93f1cd4a48799b695587ded5 --- /dev/null +++ b/packages/provider-cursor/src/server/CursorSdkCatalog.test.ts @@ -0,0 +1,66 @@ +import type { SDKModel, SDKUser } from "@cursor/sdk"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; + +import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; + +const user = { + apiKeyName: "test-key", + userEmail: "cursor@example.com", + createdAt: "2026-01-01T00:00:00.000Z", +} satisfies SDKUser; + +const model = { + id: "claude-opus-4-8", + displayName: "Opus 4.8", + parameters: [], +} satisfies SDKModel; + +const catalogError = () => + new CursorSdkCatalog.CursorSdkCatalogError({ + authenticationFailure: false, + cause: new Error("catalog unavailable"), + }); + +describe("CursorSdkCatalog", () => { + it.effect("keeps the user probe fresh while caching models by API key", () => + Effect.gen(function* () { + let userCalls = 0; + let modelCalls = 0; + const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ + readUser: () => Effect.sync(() => ((userCalls += 1), user)), + readModels: () => Effect.sync(() => ((modelCalls += 1), [model])), + }); + + yield* catalog.read("first-key"); + yield* catalog.read("first-key"); + yield* catalog.read("second-key"); + + expect(userCalls).toBe(3); + expect(modelCalls).toBe(2); + }), + ); + + it.effect("does not cache failed or empty model discovery", () => + Effect.gen(function* () { + let modelCalls = 0; + const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ + readUser: () => Effect.succeed(user), + readModels: () => + Effect.suspend(() => { + modelCalls += 1; + if (modelCalls === 1) return Effect.fail(catalogError()); + if (modelCalls === 2) return Effect.succeed([]); + return Effect.succeed([model]); + }), + }); + + expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true); + expect((yield* catalog.read("test-key")).models).toEqual([]); + expect((yield* catalog.read("test-key")).models).toEqual([model]); + expect((yield* catalog.read("test-key")).models).toEqual([model]); + expect(modelCalls).toBe(3); + }), + ); +}); diff --git a/packages/provider-cursor/src/server/CursorSdkCatalog.ts b/packages/provider-cursor/src/server/CursorSdkCatalog.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a0f6b6494967b6b5da5eb2af09c3b083e100c1f --- /dev/null +++ b/packages/provider-cursor/src/server/CursorSdkCatalog.ts @@ -0,0 +1,92 @@ +import type { SDKModel, SDKUser } from "@cursor/sdk"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { AuthenticationError, Cursor, CursorSdkError } from "./sdk.ts"; + +export interface CursorSdkCatalogSnapshot { + readonly user: SDKUser; + readonly models: ReadonlyArray; +} + +export class CursorSdkCatalogError extends Schema.TaggedError()( + "CursorSdkCatalogError", + { + authenticationFailure: Schema.Boolean, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return this.authenticationFailure + ? "Cursor SDK authentication failed." + : "Cursor SDK catalog request failed."; + } +} + +export interface CursorSdkCatalogShape { + readonly read: (apiKey: string) => Effect.Effect; +} + +export class CursorSdkCatalog extends Context.Service()( + "@t3tools/provider-cursor/server/CursorSdkCatalog", +) {} + +function isAuthenticationFailure(cause: unknown): boolean { + return ( + cause instanceof AuthenticationError || + (cause instanceof CursorSdkError && cause.status === 401) + ); +} + +export interface CursorSdkCatalogProbes { + readonly readUser: (apiKey: string) => Effect.Effect; + readonly readModels: ( + apiKey: string, + ) => Effect.Effect, CursorSdkCatalogError>; +} + +const sdkRequest = (tryRequest: () => Promise) => + Effect.tryPromise({ + try: tryRequest, + catch: (cause) => + new CursorSdkCatalogError({ + authenticationFailure: isAuthenticationFailure(cause), + cause, + }), + }); + +const liveProbes: CursorSdkCatalogProbes = { + readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })), + readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })), +}; + +export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* ( + probes: CursorSdkCatalogProbes = liveProbes, +) { + const modelCache = yield* Cache.makeWith(probes.readModels, { + capacity: 32, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, + }); + return CursorSdkCatalog.of({ + read: (apiKey) => + Effect.all( + { + user: probes.readUser(apiKey), + models: Cache.get(modelCache, apiKey), + }, + { concurrency: "unbounded" }, + ), + }); +}); + +export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); + +export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer { + return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read })); +} diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..730cbb7649f899e7b819828246058791fbdd41ce --- /dev/null +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -0,0 +1,955 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import type { InteractionUpdate } from "@cursor/sdk"; +import { + EnvironmentId, + MessageId, + NodeId, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + RunAttemptId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import { CursorSettings } from "../settings.ts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { + cursorMcpServers, + cursorRuntimeAgentPolicy, + cursorSdkModelSelection, + makeCursorAgentOptions, + makeCursorAdapterV2, + nestedToolCallFromEnvelope, +} from "./adapter.ts"; +import * as CursorAgentSdk from "./CursorAgentSdk.ts"; + +const decodeCursorSettings = Schema.decodeEffect(CursorSettings); + +describe("CursorAdapterV2", () => { + it.effect.each([ + { status: "finished", model: undefined, lateModel: undefined }, + { status: "cancelled", model: "claude-opus-4-6", lateModel: undefined }, + { status: "error", model: "custom-fable", lateModel: undefined }, + { status: "finished", model: undefined, lateModel: "gpt-6-sol" }, + { status: "finished", model: "gpt-6-sol", lateModel: null }, + ] as const)( + "projects Cursor tasks: $status, late model $lateModel", + ({ status, model, lateModel }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "cursor-v2-lifecycle-", + }); + const instanceId = ProviderInstanceId.make("cursor"); + const threadId = ThreadId.make("cursor-lifecycle-thread"); + const modelSelection = { instanceId, model: "composer-2.5" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const adapter = makeCursorAdapterV2({ + instanceId, + settings: yield* decodeCursorSettings({}), + environment: { HOME: workspace }, + fileSystem, + path, + idAllocator: yield* IdAllocator.IdAllocatorV2, + host: yield* ProviderHost.ProviderHost.pipe( + Effect.provide(layerTestProviderHost({ cwd: workspace })), + ), + runner: { + assertComplete: Effect.void, + open: () => + Effect.succeed({ + agentId: "native-cursor-lifecycle", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.gen(function* () { + // Recorded Cursor task calls (see fixtures/subagent) stream a + // partial-tool-call before tool-call-started with the same args. + // The run then ends without tool-call-completed, which a live + // run cannot produce on demand. + const taskToolCall = { + type: "task" as const, + args: { + description: "Review", + prompt: "Review the code.", + subagentType: { kind: "unspecified" }, + ...(model === undefined ? {} : { model }), + agentId: "cursor-task-agent", + mode: "unspecified" as const, + }, + }; + const updates = + lateModel !== undefined + ? (["tool-call-started", "tool-call-completed"] as const) + : (["partial-tool-call", "tool-call-started"] as const); + for (const type of updates) { + yield* input.onDelta!({ + type, + modelCallId: "model-call", + callId: "task-call", + toolCall: { + ...taskToolCall, + args: { + ...taskToolCall.args, + ...(type === "tool-call-completed" + ? { model: lateModel ?? undefined } + : {}), + }, + }, + }).pipe(Effect.orDie); + } + return { + agentId: "native-cursor-lifecycle", + runId: "native-cursor-run", + wait: Effect.succeed({ + id: "native-cursor-run", + requestId: "native-request", + status, + model: { id: "composer-2.5" }, + durationMs: 1, + }), + cancel: Effect.void, + }; + }), + }), + }, + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-lifecycle-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn({ + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make("cursor-lifecycle-run"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("cursor-lifecycle-attempt"), + rootNodeId: NodeId.make("cursor-lifecycle-root"), + appThread: { + id: threadId, + projectId: ProjectId.make("cursor-lifecycle-project"), + createdBy: "user", + creationSource: "web", + title: "Cursor lifecycle", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + message: { + messageId: MessageId.make("cursor-lifecycle-message"), + createdBy: "user", + creationSource: "web", + text: "Review the code.", + attachments: [], + }, + }); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const rows = events.filter((event) => event.type === "subagent.updated"); + assert.equal(rows[0]?.subagent.status, "running"); + assert.equal(rows[0]?.subagent.model, model ?? null); + assert.equal(rows.at(-1)?.subagent.model, lateModel ?? model ?? null); + assert.equal( + rows.at(-1)?.subagent.status, + lateModel !== undefined + ? "completed" + : status === "finished" + ? "idle" + : status === "cancelled" + ? "cancelled" + : "failed", + ); + assert.isNotNull(rows.at(-1)?.subagent.completedAt); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), + ); + + it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-errors-" }); + const sentMessages: Array = []; + let sendCalls = 0; + const instanceId = ProviderInstanceId.make("cursor"); + const threadId = ThreadId.make("cursor-transport-error-thread"); + const modelSelection = { instanceId, model: "composer-2.5" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const adapter = makeCursorAdapterV2({ + instanceId, + settings: yield* decodeCursorSettings({}), + environment: { HOME: workspace }, + fileSystem, + path, + idAllocator: yield* IdAllocator.IdAllocatorV2, + host: yield* ProviderHost.ProviderHost.pipe( + Effect.provide(layerTestProviderHost({ cwd: workspace })), + ), + runner: { + assertComplete: Effect.void, + open: () => + Effect.succeed({ + agentId: "native-cursor-error", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.sync(() => { + sendCalls += 1; + sentMessages.push( + typeof input.message === "string" ? input.message : input.message.text, + ); + const runId = `native-cursor-run-${sendCalls}`; + return { + agentId: "native-cursor-error", + runId, + wait: Effect.succeed({ + id: runId, + requestId: `native-request-${sendCalls}`, + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + ...(sendCalls === 1 + ? { result: "Error: RetriableError: WritableIterable is closed" } + : {}), + }), + cancel: Effect.void, + }; + }), + }), + }, + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-error-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + const appThread = { + id: threadId, + projectId: ProjectId.make("cursor-error-project"), + createdBy: "user" as const, + creationSource: "web" as const, + title: "Cursor transport failure", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + yield* runtime.startTurn({ + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make("cursor-error-run"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("cursor-error-attempt"), + rootNodeId: NodeId.make("cursor-error-root"), + appThread, + message: { + messageId: MessageId.make("cursor-error-message"), + createdBy: "user", + creationSource: "web", + text: "continue", + attachments: [], + }, + }); + const first = yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.isTrue(Option.isSome(first)); + if (Option.isSome(first)) { + assert.equal(first.value.status, "failed"); + assert.equal(first.value.failure?.class, "transport_error"); + } + + assert.isDefined(runtime.compactThread); + yield* runtime.compactThread!({ + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make("cursor-compact-run"), + runOrdinal: 2, + providerTurnOrdinal: 2, + attemptId: RunAttemptId.make("cursor-compact-attempt"), + rootNodeId: NodeId.make("cursor-compact-root"), + appThread, + message: { + messageId: MessageId.make("cursor-compact-message"), + createdBy: "user", + creationSource: "web", + text: "ignored by compactThread", + attachments: [], + }, + }); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + assert.equal(sentMessages[1], "/compress"); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), + ); + + it.effect("projects Cursor directory trees and lint diagnostics as file search results", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-search-" }); + const instanceId = ProviderInstanceId.make("cursor"); + const threadId = ThreadId.make("cursor-search-thread"); + const modelSelection = { instanceId, model: "composer-2.5" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const emptyLsNode = { + childrenDirs: [], + childrenFiles: [], + childrenWereProcessed: true, + fullSubtreeExtensionCounts: {}, + numFiles: 0, + }; + const updates: ReadonlyArray = [ + ...(["tool-call-started", "tool-call-completed"] as const).map((type) => ({ + type, + modelCallId: "native-model-call", + callId: "mcp-weather", + toolCall: { + type: "mcp" as const, + args: { + providerIdentifier: "weather", + toolName: "get_weather", + args: { city: "Berlin" }, + }, + ...(type === "tool-call-completed" + ? { result: { status: "success" as const, value: { content: [], isError: false } } } + : {}), + }, + })), + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "read-file", + toolCall: { + type: "read", + args: { path: "src/env.ts" }, + result: { + status: "success", + value: { fileSize: 12, content: "---\nfile body", totalLines: 2 }, + }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "ls-nested", + toolCall: { + type: "ls", + args: { path: workspace }, + result: { + status: "success", + value: { + directoryTreeRoot: { + ...emptyLsNode, + absPath: workspace, + childrenFiles: [{ name: "README.md" }], + childrenDirs: [ + { + ...emptyLsNode, + absPath: path.join(workspace, "src"), + childrenFiles: [{ name: "index.ts" }], + childrenDirs: [ + { + ...emptyLsNode, + absPath: path.join(workspace, "src", "nested"), + childrenFiles: [{ name: "util.ts" }], + }, + ], + }, + ], + }, + }, + }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "ls-empty", + toolCall: { + type: "ls", + args: { path: path.join(workspace, "empty") }, + result: { + status: "success", + value: { + directoryTreeRoot: { ...emptyLsNode, absPath: path.join(workspace, "empty") }, + }, + }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "ls-failed", + toolCall: { + type: "ls", + args: { path: path.join(workspace, "missing") }, + result: { status: "error", error: "ENOENT" }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "grep-failed", + toolCall: { + type: "grep", + args: { pattern: "TODO", path: "src" }, + result: { status: "error", error: "search failed" }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "glob-failed", + toolCall: { + type: "glob", + args: { globPattern: "*.ts" }, + result: { status: "error", error: "search failed" }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "lints", + toolCall: { + type: "readLints", + args: { paths: ["src/a.ts", "src/b.ts"] }, + result: { + status: "success", + value: { + totalFiles: 2, + totalDiagnostics: 3, + fileDiagnostics: [ + { + path: "src/a.ts", + diagnosticsCount: 2, + diagnostics: [ + { + message: "Unused variable", + code: "TS6133", + source: "ts", + severity: "warning", + range: { start: { line: 0, character: 4 } }, + }, + { + message: "Cannot find name", + code: "TS2304", + source: "ts", + severity: "error", + range: { start: { line: 11 } }, + }, + ], + }, + { + path: "src/b.ts", + diagnosticsCount: 1, + diagnostics: [ + { + message: "File-level diagnostic", + code: "TS0", + source: "ts", + severity: "information", + }, + ], + }, + ], + }, + }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "lints-empty", + toolCall: { + type: "readLints", + args: { paths: ["src/clean.ts"] }, + result: { + status: "success", + value: { + totalFiles: 1, + totalDiagnostics: 0, + fileDiagnostics: [{ path: "src/clean.ts", diagnosticsCount: 0, diagnostics: [] }], + }, + }, + }, + }, + { + type: "tool-call-completed", + modelCallId: "native-model-call", + callId: "lints-failed", + toolCall: { + type: "readLints", + args: { paths: ["src/a.ts"] }, + result: { status: "error", error: "lint failed" }, + }, + }, + ]; + const adapter = makeCursorAdapterV2({ + instanceId, + settings: yield* decodeCursorSettings({}), + environment: { HOME: workspace }, + fileSystem, + path, + idAllocator: yield* IdAllocator.IdAllocatorV2, + host: yield* ProviderHost.ProviderHost.pipe( + Effect.provide(layerTestProviderHost({ cwd: workspace })), + ), + runner: { + assertComplete: Effect.void, + open: () => + Effect.succeed({ + agentId: "native-cursor-search", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.gen(function* () { + for (const update of updates) { + if (input.onDelta !== undefined) { + yield* input.onDelta(update).pipe(Effect.orDie); + } + } + return { + agentId: "native-cursor-search", + runId: "native-cursor-run", + wait: Effect.succeed({ + id: "native-cursor-run", + requestId: "native-request", + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + }), + cancel: Effect.void, + }; + }), + }), + }, + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-search-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime.startTurn({ + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make("cursor-search-run"), + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId: RunAttemptId.make("cursor-search-attempt"), + rootNodeId: NodeId.make("cursor-search-root"), + appThread: { + id: threadId, + projectId: ProjectId.make("cursor-search-project"), + createdBy: "user", + creationSource: "web", + title: "Cursor search results", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + message: { + messageId: MessageId.make("cursor-search-message"), + createdBy: "user", + creationSource: "web", + text: "inspect the workspace", + attachments: [], + }, + }); + const events = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + ); + const mcpItems = events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "mcp__weather__get_weather" + ? [event.turnItem] + : [], + ); + assert.deepEqual( + mcpItems.map((item) => item.status), + ["running", "completed"], + ); + for (const item of mcpItems) { + assert.equal(item.title, "get weather"); + assert.deepEqual(item.toolSource, { + key: "mcp:weather", + name: "weather", + kind: "integration", + }); + assert.deepEqual(item.input, { + providerIdentifier: "weather", + toolName: "get_weather", + args: { city: "Berlin" }, + }); + } + const fileSearchItems = events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "file_search" && + event.turnItem.status !== "running" + ? [event.turnItem] + : [], + ); + const readItems = events.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.toolName === "Read" && + event.turnItem.status === "completed" + ? [event.turnItem] + : [], + ); + assert.deepEqual( + readItems.map((item) => ({ title: item.title, input: item.input })), + [{ title: "Read src/env.ts", input: { path: "src/env.ts" } }], + ); + assert.deepEqual( + fileSearchItems.map((item) => ({ + pattern: item.pattern, + status: item.status, + results: item.results, + })), + [ + { + pattern: workspace, + status: "completed", + results: [ + { fileName: path.join(workspace, "README.md") }, + { fileName: path.join(workspace, "src", "index.ts") }, + { fileName: path.join(workspace, "src", "nested", "util.ts") }, + ], + }, + { + pattern: path.join(workspace, "empty"), + status: "completed", + results: undefined, + }, + { + pattern: path.join(workspace, "missing"), + status: "failed", + results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }], + }, + { + pattern: "TODO", + status: "failed", + results: [{ fileName: "src", preview: "search failed" }], + }, + { + pattern: "*.ts", + status: "failed", + results: [{ fileName: ".", preview: "search failed" }], + }, + { + pattern: "src/a.ts, src/b.ts", + status: "completed", + results: [ + { fileName: "src/a.ts", line: 1, column: 5, preview: "Unused variable" }, + { fileName: "src/a.ts", line: 12, preview: "Cannot find name" }, + { fileName: "src/b.ts", preview: "File-level diagnostic" }, + ], + }, + { + pattern: "src/clean.ts", + status: "completed", + results: undefined, + }, + { + pattern: "src/a.ts", + status: "failed", + results: [{ fileName: "src/a.ts", preview: "lint failed" }], + }, + ], + ); + }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), + ); + + it("maps Cursor auto and model parameters to SDK selections", () => { + assert.deepEqual( + cursorSdkModelSelection({ + instanceId: ProviderInstanceId.make("cursor"), + model: "auto", + options: [ + { id: "thinking", value: "high" }, + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ], + }), + { + id: "default", + params: [ + { id: "thinking", value: "high" }, + { id: "context", value: "1m" }, + { id: "fast", value: "true" }, + ], + }, + ); + }); + + it("maps runtime modes to the SDK sandbox and auto-review controls", () => { + const base = { + interactionMode: "default" as const, + cwd: "/tmp/cursor-adapter", + }; + assert.deepEqual( + cursorRuntimeAgentPolicy({ + ...base, + runtimeMode: "full-access", + }), + { + autoReview: false, + sandboxEnabled: false, + }, + ); + assert.deepEqual( + cursorRuntimeAgentPolicy({ + ...base, + runtimeMode: "auto-accept-edits", + }), + { + autoReview: false, + sandboxEnabled: true, + }, + ); + assert.deepEqual( + cursorRuntimeAgentPolicy({ + ...base, + runtimeMode: "approval-required", + }), + { + autoReview: true, + sandboxEnabled: true, + }, + ); + assert.deepEqual( + cursorRuntimeAgentPolicy({ + ...base, + runtimeMode: "full-access", + approvalPolicy: "never", + sandboxPolicy: { type: "readOnly" }, + }), + { + autoReview: false, + sandboxEnabled: true, + }, + ); + assert.deepEqual( + cursorRuntimeAgentPolicy({ + ...base, + runtimeMode: "approval-required", + approvalPolicy: "never", + sandboxPolicy: { type: "dangerFullAccess" }, + }), + { + autoReview: false, + sandboxEnabled: false, + }, + ); + }); + + it("loads the user's Cursor settings layers in every runtime mode", () => { + // The SDK loads no rules, skills, hooks, or MCP config from disk unless + // settingSources names them, so an omitted list silently drops AGENTS.md. + for (const runtimeMode of ["full-access", "auto-accept-edits", "approval-required"] as const) { + const options = makeCursorAgentOptions({ + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2.5", + }, + runtimePolicy: { runtimeMode, interactionMode: "default", cwd: "/workspace" }, + threadId: ThreadId.make("thread-cursor-setting-sources"), + }); + assert.deepEqual(options.local?.settingSources, [ + "project", + "user", + "team", + "mdm", + "plugins", + ]); + } + }); + + it("injects thread-scoped MCP credentials without logging them", () => { + const threadId = ThreadId.make("thread-cursor-mcp"); + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("environment-cursor-mcp"), + threadId, + providerSessionId: "mcp-session-cursor", + providerInstanceId: ProviderInstanceId.make("cursor"), + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer secret-cursor-mcp-token", + browserToolsAvailable: true, + }); + + try { + assert.deepEqual(cursorMcpServers(threadId), { + "t3-code": { + type: "http", + url: "http://127.0.0.1:43123/mcp", + headers: { + Authorization: "Bearer secret-cursor-mcp-token", + }, + }, + }); + + const options = makeCursorAgentOptions({ + apiKey: "secret-cursor-api-key", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2.5", + }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: "/workspace", + }, + threadId, + }); + assert.deepEqual(options.mcpServers, cursorMcpServers(threadId)); + + const logged = JSON.stringify(CursorAgentSdk.loggedCursorAgentOptions(options)); + assert.notInclude(logged, "secret-cursor-api-key"); + assert.notInclude(logged, "secret-cursor-mcp-token"); + } finally { + McpProviderSession.clearMcpProviderSession(threadId); + } + }); + + it("recognizes direct and SDK-wrapped abort failures as cancellation", () => { + assert.isTrue(CursorAgentSdk.isCursorCancellationError({ name: "AbortError" })); + assert.isTrue( + CursorAgentSdk.isCursorCancellationError({ + name: "ConnectError", + cause: { + name: "ConnectError", + cause: { name: "AbortError" }, + }, + }), + ); + assert.isFalse(CursorAgentSdk.isCursorCancellationError(new Error("request failed"))); + assert.isFalse(CursorAgentSdk.isCursorCancellationError(null)); + }); + + it("preserves failed nested read calls when Cursor omits their path", () => { + assert.deepEqual( + nestedToolCallFromEnvelope({ + toolCallId: "tool:failed-read", + readToolCall: { + args: {}, + result: { error: "File path was not provided." }, + }, + }), + { + callId: "tool:failed-read", + toolCall: { + type: "read", + args: { path: "" }, + result: { + status: "error", + error: "File path was not provided.", + }, + }, + }, + ); + }); +}); diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts new file mode 100644 index 0000000000000000000000000000000000000000..33796270205a9fae152e030e87ba3bc57566131a --- /dev/null +++ b/packages/provider-cursor/src/server/adapter.ts @@ -0,0 +1,2680 @@ +import type { + AgentMessage, + AgentOptions, + InteractionUpdate, + McpServerConfig, + RunResult, + SDKUserMessage, + SettingSource, + ToolCall, +} from "@cursor/sdk"; +import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import { + isOrchestrationV2WorkActive, + defaultInstanceIdForDriver, + type ChatAttachment, + type ModelSelection, + type OrchestrationV2ConversationMessage, + type OrchestrationV2ExecutionNode, + type OrchestrationV2PlanArtifact, + type OrchestrationV2PlanStep, + type OrchestrationV2ProviderCapabilities, + type OrchestrationV2ProviderFailure, + type OrchestrationV2ProviderSession, + type OrchestrationV2ProviderThread, + type OrchestrationV2ProviderTurn, + type OrchestrationV2Subagent, + type OrchestrationV2TurnItem, + type ProviderInstanceId, + type ThreadId, +} from "@t3tools/contracts"; +import { CursorSettings } from "../settings.ts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import { CursorTransportFailure } from "./transportFailure.ts"; +import { cursorSdkModelSelection } from "./sdkModel.ts"; +import { + discoverCursorSkills, + hasCursorSkillMention, + rewriteCursorSkillMentions, +} from "./skills.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; +import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; +import { + isProviderNativeImageAttachment, + providerMessageTextWithAttachmentPaths, +} from "@t3tools/provider-core/server/attachmentPrompt"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { + ProviderAdapterDriverCreateError, + type ProviderAdapterDriver, + type ProviderAdapterDriverCreateInput, +} from "@t3tools/provider-core/server/adapterDriver"; +import { + makeSubagentChildThread, + makeSubagentConversationArtifacts, + subagentThreadTitle, +} from "@t3tools/provider-core/server/subagentProjection"; +import * as CursorAgentSdk from "./CursorAgentSdk.ts"; +export { cursorSdkModelSelection } from "./sdkModel.ts"; + +export const CURSOR_DRIVER_KIND = CursorAgentSdk.CURSOR_PROVIDER; +export const CURSOR_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CURSOR_DRIVER_KIND); +const DEFAULT_CURSOR_SETTINGS = Schema.decodeSync(CursorSettings)({}); + +export const CursorProviderCapabilitiesV2 = { + sessions: { + supportsMultipleProviderThreadsPerSession: false, + supportsModelSwitchInSession: true, + supportsProviderSwitchingViaHandoff: true, + supportsRuntimeModeSwitchInSession: false, + pendingRequestsSurviveRestart: false, + }, + threads: { + canCreateEmptyThread: true, + canReadThreadSnapshot: true, + canRollbackThread: false, + canForkThread: false, + canForkFromTurn: false, + canForkFromSubagentThread: false, + exposesNativeThreadId: true, + }, + turns: { + exposesNativeTurnId: true, + emitsTurnStarted: true, + emitsTurnCompleted: true, + supportsInterrupt: true, + supportsActiveSteering: false, + supportsSteeringByInterruptRestart: true, + supportsQueuedMessages: true, + terminalStatusQuality: "strong", + }, + streaming: { + streamsAssistantText: true, + streamsReasoning: true, + streamsToolOutput: true, + streamsPlanText: true, + emitsMessageCompleted: true, + }, + tools: { + exposesToolItemIds: true, + emitsToolStarted: true, + emitsToolCompleted: true, + emitsToolOutput: true, + supportsMcpTools: true, + supportsDynamicToolCallbacks: false, + }, + approvals: { + supportsCommandApproval: false, + supportsFileReadApproval: false, + supportsFileChangeApproval: false, + supportsApplyPatchApproval: false, + approvalsHaveNativeRequestIds: false, + approvalCallbacksAreLiveOnly: false, + approvalsCanOriginateFromSubagents: false, + }, + planning: { + emitsPlanUpdated: true, + emitsTodoList: true, + emitsProposedPlan: true, + supportsStructuredQuestions: false, + planDeltasHaveItemIds: true, + }, + subagents: { + supportsSubagents: true, + exposesSubagentThreadIds: false, + emitsSubagentLifecycle: true, + canWaitForSubagents: true, + canCloseSubagents: false, + canForkSubagentThread: false, + }, + context: { + acceptsSystemContext: false, + acceptsDeveloperContext: false, + acceptsSyntheticUserContext: true, + canGenerateSummaries: true, + canConsumeHandoffSummaries: true, + supportsDeltaHandoff: true, + supportsFullThreadHandoff: true, + maxRecommendedHandoffChars: null, + }, + checkpointing: { + appCanCheckpointFilesystem: true, + supportsNestedCheckpointScopes: true, + providerCanRollbackConversation: false, + providerRollbackReturnsSnapshot: false, + providerCanReadConversationSnapshot: true, + }, + identity: { + nativeThreadIds: "strong", + nativeTurnIds: "strong", + nativeItemIds: "weak", + nativeRequestIds: "none", + }, + runtimePolicy: { + enforcement: "native", + }, +} satisfies OrchestrationV2ProviderCapabilities; + +export interface CursorRuntimeAgentPolicy { + readonly autoReview: boolean; + readonly sandboxEnabled: boolean; +} + +export function cursorRuntimeAgentPolicy( + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, +): CursorRuntimeAgentPolicy { + const sandboxPolicyType = + typeof runtimePolicy.sandboxPolicy === "object" && + runtimePolicy.sandboxPolicy !== null && + "type" in runtimePolicy.sandboxPolicy && + typeof runtimePolicy.sandboxPolicy.type === "string" + ? runtimePolicy.sandboxPolicy.type + : undefined; + return { + autoReview: + runtimePolicy.approvalPolicy === undefined + ? runtimePolicy.runtimeMode === "approval-required" + : runtimePolicy.approvalPolicy !== "never", + sandboxEnabled: + runtimePolicy.sandboxPolicy === undefined + ? runtimePolicy.runtimeMode !== "full-access" + : sandboxPolicyType !== "dangerFullAccess", + }; +} + +export function cursorMcpServers(threadId: ThreadId): Record | undefined { + const session = McpProviderSession.readMcpProviderSession(threadId); + if (session === undefined) { + return undefined; + } + return { + "t3-code": { + type: "http", + url: session.endpoint, + headers: { + Authorization: session.authorizationHeader, + }, + }, + }; +} + +function providerSession(input: { + readonly providerSessionId: OrchestrationV2ProviderSession["id"]; + readonly providerInstanceId: ProviderInstanceId; + readonly cwd: string | null; + readonly model: string; + readonly now: DateTime.Utc; +}): OrchestrationV2ProviderSession { + return { + id: input.providerSessionId, + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerInstanceId: input.providerInstanceId, + status: "ready", + cwd: input.cwd ?? process.cwd(), + model: input.model, + capabilities: CursorProviderCapabilitiesV2, + createdAt: input.now, + updatedAt: input.now, + lastError: null, + }; +} + +function makeProviderThread(input: { + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly providerInstanceId: ProviderInstanceId; + readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; + readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; + readonly nativeThreadId: string; + readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; + readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; + readonly now: DateTime.Utc; +}): OrchestrationV2ProviderThread { + return { + id: input.idAllocator.derive.providerThread({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeThreadId: input.nativeThreadId, + }), + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerInstanceId: input.providerInstanceId, + providerSessionId: input.providerSessionId, + appThreadId: input.appThreadId, + ownerNodeId: input.ownerNodeId ?? null, + nativeThreadRef: { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: input.nativeThreadId, + strength: "strong", + }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: null, + lastRunOrdinal: null, + handoffIds: [], + forkedFrom: input.forkedFrom ?? null, + createdAt: input.now, + updatedAt: input.now, + }; +} + +function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { + const id = providerThread.nativeThreadRef?.nativeId; + if (id === null || id === undefined) { + throw new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: `Provider thread ${providerThread.id} is missing its Cursor agent id.`, + }); + } + return id; +} + +/** + * Every Cursor settings layer the Cursor CLI loads: project and user rules, + * skills, hooks, and MCP servers, team and MDM admin policy, and account + * plugins. The SDK loads none of them when `settingSources` is omitted. + * Sandbox policy files are read either way, and hooks can only deny or ask + * (which local SDK runs reject), so these layers do not loosen the sandbox or + * approval mode T3 sets. + */ +const CURSOR_AGENT_SETTING_SOURCES = [ + "project", + "user", + "team", + "mdm", + "plugins", +] as const satisfies ReadonlyArray; + +export function makeCursorAgentOptions(input: { + readonly apiKey?: string; + readonly modelSelection: ModelSelection; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + readonly threadId: ThreadId; +}): AgentOptions { + const policy = cursorRuntimeAgentPolicy(input.runtimePolicy); + const mcpServers = cursorMcpServers(input.threadId); + return { + model: cursorSdkModelSelection(input.modelSelection), + name: `T3 Code ${input.threadId}`, + mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", + ...(input.apiKey === undefined ? {} : { apiKey: input.apiKey }), + local: { + ...(input.runtimePolicy.cwd === null ? {} : { cwd: input.runtimePolicy.cwd }), + autoReview: policy.autoReview, + settingSources: [...CURSOR_AGENT_SETTING_SOURCES], + sandboxOptions: { + enabled: policy.sandboxEnabled, + }, + enableAgentRetries: true, + }, + ...(mcpServers === undefined ? {} : { mcpServers }), + }; +} + +function stableJson(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +function unknownRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function nestedString(value: unknown, keys: ReadonlyArray): string | undefined { + let current: unknown = value; + for (const key of keys) { + current = unknownRecord(current)?.[key]; + } + return typeof current === "string" ? current : undefined; +} + +function cursorToolFailed(toolCall: ToolCall): boolean { + if (toolCall.result?.status === "error") { + return true; + } + return toolCall.type === "mcp" && toolCall.result?.status === "success" + ? toolCall.result.value.isError + : false; +} + +function cursorToolOutput(toolCall: ToolCall): unknown { + const result = toolCall.result; + if (result === undefined) { + return undefined; + } + return result.status === "success" ? result.value : result.error; +} + +function cursorToolOutputText(toolCall: ToolCall): string { + if (toolCall.type === "shell" && toolCall.result?.status === "success") { + return [toolCall.result.value.stdout, toolCall.result.value.stderr] + .filter((part) => part.length > 0) + .join("\n"); + } + if (toolCall.type === "mcp" && toolCall.result?.status === "success") { + return toolCall.result.value.content + .flatMap((part) => (part.text === undefined ? [] : [part.text.text])) + .join("\n"); + } + const output = cursorToolOutput(toolCall); + if (output === undefined) { + return ""; + } + return typeof output === "string" ? output : stableJson(output); +} + +function cursorToolName(toolCall: ToolCall): string { + if (toolCall.type !== "mcp") { + return toolCall.type; + } + const provider = toolCall.args.providerIdentifier ?? "mcp"; + const tool = toolCall.args.toolName ?? "unknown"; + return `mcp__${provider}__${tool}`; +} + +function cursorToolFileName(toolCall: ToolCall): string { + switch (toolCall.type) { + case "write": + case "delete": + case "read": + case "edit": + case "ls": + return toolCall.args.path; + case "generateImage": + return toolCall.args.filePath ?? "generated-image"; + default: + return cursorToolName(toolCall); + } +} + +function cursorToolSearchPattern(toolCall: ToolCall): string | undefined { + switch (toolCall.type) { + case "glob": + return toolCall.args.globPattern; + case "grep": + return toolCall.args.pattern; + case "semSearch": + return toolCall.args.query; + case "ls": + return toolCall.args.path; + case "readLints": + return toolCall.args.paths.join(", "); + default: + return undefined; + } +} + +type CursorLsDirectoryNode = Extract< + Extract["result"], + { readonly status: "success" } +>["value"]["directoryTreeRoot"]; + +function cursorLsSearchResults( + node: CursorLsDirectoryNode, + path: Path.Path, +): ReadonlyArray<{ readonly fileName: string }> { + return [ + ...node.childrenFiles.map((file) => ({ fileName: path.join(node.absPath, file.name) })), + ...node.childrenDirs.flatMap((child) => cursorLsSearchResults(child, path)), + ]; +} + +function cursorToolSearchResults( + toolCall: ToolCall, + path: Path.Path, +): ReadonlyArray<{ + readonly fileName: string; + readonly line?: number; + readonly column?: number; + readonly preview?: string; +}> { + if (toolCall.result?.status !== "success") { + return []; + } + switch (toolCall.type) { + case "glob": + return toolCall.result.value.files.map((fileName) => ({ fileName })); + case "grep": + return Object.values(toolCall.result.value.workspaceResults ?? {}).flatMap((result) => { + if (result.type === "files") { + return result.output.files.map((fileName) => ({ fileName })); + } + if (result.type === "count") { + return result.output.counts.map((entry) => ({ + fileName: entry.file, + preview: `${entry.count} matches`, + })); + } + return result.output.matches.map((entry) => ({ + fileName: entry.file, + ...(entry.lineNumber === undefined ? {} : { line: entry.lineNumber }), + preview: entry.line, + })); + }); + case "ls": + return cursorLsSearchResults(toolCall.result.value.directoryTreeRoot, path); + case "readLints": + return toolCall.result.value.fileDiagnostics.flatMap((file) => + file.diagnostics.map((diagnostic) => { + const line = diagnostic.range?.start?.line; + const character = diagnostic.range?.start?.character; + const hasLine = typeof line === "number" && Number.isInteger(line) && line >= 0; + return { + fileName: file.path, + ...(hasLine ? { line: line + 1 } : {}), + ...(hasLine && + typeof character === "number" && + Number.isInteger(character) && + character >= 0 + ? { column: character + 1 } + : {}), + preview: diagnostic.message, + }; + }), + ); + case "semSearch": + return [ + { + fileName: "semantic-search", + preview: toolCall.result.value.results, + }, + ]; + default: + return []; + } +} + +function cursorTodoSteps( + toolCall: Extract, +): ReadonlyArray { + const todos = + toolCall.result?.status === "success" ? toolCall.result.value.todos : toolCall.args.todos; + return todos + .filter((todo) => todo.status !== "cancelled" && todo.content.trim().length > 0) + .map((todo, index) => ({ + id: `todo-${index + 1}`, + text: todo.content, + status: + todo.status === "inProgress" + ? "running" + : todo.status === "completed" + ? "completed" + : "pending", + })); +} + +function assistantTextsFromConversationSteps(steps: ReadonlyArray): ReadonlyArray { + return steps.flatMap((step) => { + const record = unknownRecord(step); + if (record?.type === "assistantMessage") { + const text = nestedString(record, ["message", "text"]); + return text === undefined || text.length === 0 ? [] : [text]; + } + const text = nestedString(record, ["assistantMessage", "text"]); + return text === undefined || text.length === 0 ? [] : [text]; + }); +} + +function nestedToolResult>( + rawResult: unknown, + mapSuccess: (success: Record) => Value = (success) => success as Value, +): + | { readonly status: "success"; readonly value: Value } + | { readonly status: "error"; readonly error: unknown } + | undefined { + const result = unknownRecord(rawResult); + if (result === undefined) { + return undefined; + } + const success = unknownRecord(result.success); + if (success !== undefined) { + return { + status: "success", + value: mapSuccess(success), + }; + } + return { + status: "error", + error: result.error ?? result.permissionDenied ?? result, + }; +} + +function nestedGrepWorkspaceResults(success: Record): Record { + const workspaces = unknownRecord(success.workspaceResults); + if (workspaces === undefined) { + return {}; + } + return Object.fromEntries( + Object.entries(workspaces).map(([workspace, rawWorkspaceResult]) => { + const workspaceResult = unknownRecord(rawWorkspaceResult); + const content = unknownRecord(workspaceResult?.content); + const rawMatches = Array.isArray(content?.matches) ? content.matches : []; + const matches = rawMatches.flatMap((rawFileMatch) => { + const fileMatch = unknownRecord(rawFileMatch); + const file = typeof fileMatch?.file === "string" ? fileMatch.file : ""; + const lineMatches = Array.isArray(fileMatch?.matches) ? fileMatch.matches : []; + return lineMatches.flatMap((rawLineMatch) => { + const lineMatch = unknownRecord(rawLineMatch); + if (lineMatch === undefined) { + return []; + } + const line = typeof lineMatch.content === "string" ? lineMatch.content : undefined; + if (file.length === 0 || line === undefined) { + return []; + } + return [ + { + file, + line, + ...(typeof lineMatch.lineNumber === "number" + ? { lineNumber: lineMatch.lineNumber } + : {}), + }, + ]; + }); + }); + return [ + workspace, + { + type: "content", + output: { + matches, + totalMatches: + typeof content?.totalMatchedLines === "number" + ? content.totalMatchedLines + : matches.length, + }, + }, + ]; + }), + ); +} + +export function nestedToolCallFromEnvelope( + envelope: Record, +): { readonly callId: string; readonly toolCall: ToolCall } | undefined { + const callId = typeof envelope.toolCallId === "string" ? envelope.toolCallId : undefined; + const wrapperEntry = Object.entries(envelope).find(([key]) => key.endsWith("ToolCall")); + if (wrapperEntry === undefined) { + return undefined; + } + const [wrapperName, rawCall] = wrapperEntry; + const call = unknownRecord(rawCall); + if (call === undefined) { + return undefined; + } + const args = unknownRecord(call.args) ?? {}; + const fallbackCallId = `nested-${wrapperName}`; + const nestedCallId = callId ?? fallbackCallId; + + switch (wrapperName) { + case "readToolCall": { + const success = unknownRecord(unknownRecord(call.result)?.success); + const path = + typeof args.path === "string" + ? args.path + : typeof success?.path === "string" + ? success.path + : ""; + return { + callId: nestedCallId, + toolCall: { + type: "read", + args: { path }, + result: nestedToolResult(call.result, (value) => ({ + content: typeof value.content === "string" ? value.content : "", + totalLines: typeof value.totalLines === "number" ? value.totalLines : 0, + fileSize: typeof value.fileSize === "number" ? value.fileSize : 0, + })), + } as unknown as ToolCall, + }; + } + case "globToolCall": + return { + callId: nestedCallId, + toolCall: { + type: "glob", + args: { + globPattern: typeof args.globPattern === "string" ? args.globPattern : "**/*", + ...(typeof args.targetDirectory === "string" + ? { targetDirectory: args.targetDirectory } + : {}), + }, + result: nestedToolResult(call.result, (value) => ({ + files: Array.isArray(value.files) + ? value.files.filter((file): file is string => typeof file === "string") + : [], + totalFiles: typeof value.totalFiles === "number" ? value.totalFiles : 0, + clientTruncated: false, + ripgrepTruncated: false, + })), + } as unknown as ToolCall, + }; + case "grepToolCall": + return { + callId: nestedCallId, + toolCall: { + type: "grep", + args: { + pattern: typeof args.pattern === "string" ? args.pattern : "", + ...(typeof args.path === "string" ? { path: args.path } : {}), + ...(typeof args.glob === "string" ? { glob: args.glob } : {}), + ...(typeof args.outputMode === "string" ? { outputMode: args.outputMode } : {}), + ...(typeof args.caseInsensitive === "boolean" + ? { caseInsensitive: args.caseInsensitive } + : {}), + ...(typeof args.offset === "number" ? { offset: args.offset } : {}), + ...(typeof args.multiline === "boolean" ? { multiline: args.multiline } : {}), + }, + result: nestedToolResult(call.result, (value) => ({ + workspaceResults: nestedGrepWorkspaceResults(value), + })), + } as unknown as ToolCall, + }; + case "shellToolCall": { + const rawResult = unknownRecord(call.result); + const permissionDenied = unknownRecord(rawResult?.permissionDenied); + const command = + typeof args.command === "string" + ? args.command + : typeof permissionDenied?.command === "string" + ? permissionDenied.command + : ""; + return { + callId: nestedCallId, + toolCall: { + type: "shell", + args: { command }, + result: nestedToolResult(call.result, (value) => ({ + stdout: typeof value.stdout === "string" ? value.stdout : "", + stderr: typeof value.stderr === "string" ? value.stderr : "", + exitCode: typeof value.exitCode === "number" ? value.exitCode : 0, + signal: typeof value.signal === "string" ? value.signal : "", + executionTime: typeof value.executionTime === "number" ? value.executionTime : 0, + })), + } as unknown as ToolCall, + }; + } + default: { + const type = wrapperName.slice(0, -"ToolCall".length); + return { + callId: nestedCallId, + toolCall: { + type, + args, + result: nestedToolResult(call.result), + } as unknown as ToolCall, + }; + } + } +} + +function toolCallsFromConversationSteps( + steps: ReadonlyArray, +): ReadonlyArray<{ readonly callId: string; readonly toolCall: ToolCall }> { + return steps.flatMap((step) => { + const record = unknownRecord(step); + if (record?.type === "toolCall") { + const message = unknownRecord(record.message); + return typeof message?.type === "string" + ? [ + { + callId: typeof record.callId === "string" ? record.callId : `nested-${message.type}`, + toolCall: message as ToolCall, + }, + ] + : []; + } + const envelope = unknownRecord(record?.toolCall); + const nested = envelope === undefined ? undefined : nestedToolCallFromEnvelope(envelope); + return nested === undefined ? [] : [nested]; + }); +} + +function textFromAgentMessage(message: AgentMessage): string { + const value = message.message; + if (typeof value === "string") { + return value; + } + const direct = + nestedString(value, ["text"]) ?? + nestedString(value, ["message", "text"]) ?? + nestedString(value, ["userMessage", "text"]) ?? + nestedString(value, ["assistantMessage", "text"]); + if (direct !== undefined) { + return direct; + } + const content = unknownRecord(value)?.content; + if (!Array.isArray(content)) { + return ""; + } + return content + .flatMap((part) => { + const text = unknownRecord(part)?.text; + return typeof text === "string" ? [text] : []; + }) + .join(""); +} + +interface CursorProjectionTarget { + readonly threadId: ThreadId; + readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; + readonly rootNodeId: OrchestrationV2ExecutionNode["rootNodeId"]; + readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; + readonly providerThreadId: OrchestrationV2ProviderThread["id"] | null; + readonly providerTurnId: OrchestrationV2ProviderTurn["id"] | null; +} + +interface ActiveCursorToolCall { + readonly callId: string; + toolCall: ToolCall; + readonly target: CursorProjectionTarget; + readonly ordinal: number; + readonly startedAt: DateTime.Utc; + streamedOutput: string; +} + +interface ActiveCursorSubagent { + task: OrchestrationV2Subagent; + toolCall: Extract; + readonly callId: string; + readonly childThreadId: ThreadId; + readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; + readonly turnItemId: OrchestrationV2TurnItem["id"]; + readonly turnItemOrdinal: number; + nextChildOrdinal: number; + resultProjected: boolean; +} + +interface ActiveCursorTextSegment { + readonly nativeItemId: string; + readonly startedAt: DateTime.Utc; + text: string; +} + +interface ActiveCursorTextStream { + current: ActiveCursorTextSegment | null; + nextSegment: number; +} + +interface ActiveCursorTurn { + readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; + readonly run: CursorAgentSdk.CursorAgentSdkRun; + readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; + readonly startedAt: DateTime.Utc; + readonly completed: Deferred.Deferred; + // Item ordinals allocated in this turn. No later turn looks items up here. + readonly itemOrdinals: Map; + readonly tools: Map; + readonly subagents: Map; + readonly assistant: ActiveCursorTextStream; + readonly assistantReply: CursorTransportFailure; + readonly reasoning: ActiveCursorTextStream; + interrupted: boolean; + finalized: boolean; +} + +interface CursorLiveAgent { + readonly nativeThreadId: string; + readonly session: CursorAgentSdk.CursorAgentSdkSession; +} + +export interface CursorAdapterV2Options { + readonly instanceId: ProviderInstanceId; + readonly settings: CursorSettings; + readonly environment: NodeJS.ProcessEnv; + readonly fileSystem: FileSystem.FileSystem; + readonly path: Path.Path; + readonly idAllocator: IdAllocator.IdAllocatorV2Shape; + readonly runner: CursorAgentSdk.CursorAgentSdkRunnerShape; + readonly host: ProviderHost.ProviderHostShape; +} + +export function makeCursorAdapterV2( + adapterOptions: CursorAdapterV2Options, +): ProviderAdapter.ProviderAdapterV2Shape { + const { fileSystem, path, idAllocator, runner, host } = adapterOptions; + const apiKey = adapterOptions.environment.CURSOR_API_KEY?.trim() || undefined; + + return ProviderAdapter.ProviderAdapterV2.of({ + instanceId: adapterOptions.instanceId, + driver: CursorAgentSdk.CURSOR_PROVIDER, + getCapabilities: () => Effect.succeed(CursorProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), + openSession: Effect.fn("CursorAdapterV2.openSession")( + function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { + const sessionScope = yield* Effect.scope; + const createdAt = yield* DateTime.now; + const session = providerSession({ + providerSessionId: input.providerSessionId, + providerInstanceId: adapterOptions.instanceId, + cwd: input.runtimePolicy.cwd, + model: input.modelSelection.model, + now: createdAt, + }); + const events = yield* Queue.unbounded(); + const liveAgent = yield* Ref.make(null); + const activeTurn = yield* Ref.make(null); + const planIds = yield* Ref.make(new Map()); + + const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => + Queue.offer(events, event).pipe(Effect.asVoid); + + const resolveItemOrdinal = (context: ActiveCursorTurn, nativeItemId: string) => + Effect.sync(() => { + const existing = context.itemOrdinals.get(nativeItemId); + if (existing !== undefined) { + return existing; + } + const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1; + context.itemOrdinals.set(nativeItemId, ordinal); + return ordinal; + }); + + const resolvePlanId = Effect.fnUntraced(function* ( + context: ActiveCursorTurn, + nativeItemId: string, + ) { + const existing = (yield* Ref.get(planIds)).get(nativeItemId); + if (existing !== undefined) { + return existing; + } + const planId = yield* idAllocator.allocate.plan({ + threadId: context.input.threadId, + runId: context.input.runId, + driver: CursorAgentSdk.CURSOR_PROVIDER, + }); + yield* Ref.update(planIds, (current) => { + const updated = new Map(current); + updated.set(nativeItemId, planId); + return updated; + }); + return planId; + }); + + const parentTarget = (context: ActiveCursorTurn): CursorProjectionTarget => ({ + threadId: context.input.threadId, + runId: context.input.runId, + rootNodeId: context.input.rootNodeId, + parentNodeId: context.input.rootNodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + }); + + const emitAssistant = Effect.fnUntraced(function* ( + context: ActiveCursorTurn, + completed: boolean, + ) { + const segment = context.assistant.current; + if (segment === null || segment.text.length === 0) { + return; + } + const now = yield* DateTime.now; + const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: segment.nativeItemId, + }); + const messageId = idAllocator.derive.messageFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: segment.nativeItemId, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: segment.nativeItemId, + }); + const nativeItemRef = { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: segment.nativeItemId, + strength: "weak" as const, + }; + yield* emitProviderEvent({ + type: "node.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + node: { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + rootNodeId: context.input.rootNodeId, + kind: "assistant_message", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: segment.startedAt, + completedAt: completed ? now : null, + }, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + message: { + createdBy: "agent", + creationSource: "provider", + id: messageId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + role: "assistant", + text: segment.text, + attachments: [], + streaming: !completed, + createdAt: segment.startedAt, + updatedAt: now, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + turnItem: { + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: completed ? "completed" : "running", + title: null, + startedAt: segment.startedAt, + completedAt: completed ? now : null, + updatedAt: now, + type: "assistant_message", + messageId, + text: segment.text, + streaming: !completed, + }, + }); + }); + + const emitReasoning = Effect.fnUntraced(function* ( + context: ActiveCursorTurn, + completed: boolean, + ) { + const segment = context.reasoning.current; + if (segment === null || segment.text.length === 0) { + return; + } + const now = yield* DateTime.now; + const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: segment.nativeItemId, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: segment.nativeItemId, + }); + const nativeItemRef = { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: segment.nativeItemId, + strength: "weak" as const, + }; + yield* emitProviderEvent({ + type: "node.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + node: { + id: nodeId, + threadId: context.input.threadId, + runId: context.input.runId, + parentNodeId: context.input.rootNodeId, + rootNodeId: context.input.rootNodeId, + kind: "reasoning", + status: completed ? "completed" : "running", + countsForRun: false, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: segment.startedAt, + completedAt: completed ? now : null, + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + turnItem: { + id: turnItemId, + threadId: context.input.threadId, + runId: context.input.runId, + nodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status: completed ? "completed" : "running", + title: null, + startedAt: segment.startedAt, + completedAt: completed ? now : null, + updatedAt: now, + type: "reasoning", + text: segment.text, + streaming: !completed, + }, + }); + }); + + const appendTextSegment = Effect.fnUntraced(function* (input: { + readonly context: ActiveCursorTurn; + readonly stream: ActiveCursorTextStream; + readonly kind: "assistant" | "reasoning"; + readonly text: string; + }) { + if (input.stream.current === null) { + input.stream.nextSegment += 1; + input.stream.current = { + nativeItemId: `${input.kind}:${input.context.run.runId}:${input.stream.nextSegment}`, + startedAt: yield* DateTime.now, + text: "", + }; + } + input.stream.current.text += input.text; + }); + + const completeAssistant = Effect.fnUntraced(function* (context: ActiveCursorTurn) { + if (context.assistant.current === null) { + return; + } + yield* emitAssistant(context, true); + context.assistant.current = null; + }); + + const completeReasoning = Effect.fnUntraced(function* (context: ActiveCursorTurn) { + if (context.reasoning.current === null) { + return; + } + yield* emitReasoning(context, true); + context.reasoning.current = null; + }); + + const emitToolArtifacts = Effect.fnUntraced(function* (input: { + readonly active: ActiveCursorToolCall; + readonly completed: boolean; + /** Terminal status for a tool the turn ended before Cursor completed it. */ + readonly unfinishedStatus?: "interrupted" | "failed" | "cancelled"; + }) { + const { active } = input; + const toolCall = active.toolCall; + const now = yield* DateTime.now; + const failed = input.completed && cursorToolFailed(toolCall); + const status = !input.completed + ? "running" + : (input.unfinishedStatus ?? (failed ? "failed" : "completed")); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: active.callId, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: active.callId, + }); + const nativeItemRef = { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: active.callId, + strength: "strong" as const, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, + threadId: active.target.threadId, + runId: active.target.runId, + parentNodeId: active.target.parentNodeId, + rootNodeId: active.target.rootNodeId, + kind: "tool_call", + status, + countsForRun: false, + providerThreadId: active.target.providerThreadId, + providerTurnId: active.target.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: active.startedAt, + completedAt: input.completed ? now : null, + }; + const base = { + id: turnItemId, + threadId: active.target.threadId, + runId: active.target.runId, + nodeId, + providerThreadId: active.target.providerThreadId, + providerTurnId: active.target.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal: active.ordinal, + status, + title: null, + startedAt: active.startedAt, + completedAt: input.completed ? now : null, + updatedAt: now, + } satisfies Pick< + OrchestrationV2TurnItem, + | "id" + | "threadId" + | "runId" + | "nodeId" + | "providerThreadId" + | "providerTurnId" + | "nativeItemRef" + | "parentItemId" + | "ordinal" + | "status" + | "title" + | "startedAt" + | "completedAt" + | "updatedAt" + >; + const outputText = cursorToolOutputText(toolCall) || active.streamedOutput; + let turnItem: OrchestrationV2TurnItem; + switch (toolCall.type) { + case "shell": + turnItem = { + ...base, + type: "command_execution", + input: toolCall.args.command, + ...(outputText.length === 0 ? {} : { output: outputText }), + ...(toolCall.result?.status === "success" + ? { exitCode: toolCall.result.value.exitCode } + : {}), + }; + break; + case "write": + case "delete": + case "edit": + case "generateImage": + turnItem = { + ...base, + type: "file_change", + fileName: cursorToolFileName(toolCall), + ...(toolCall.type === "edit" && + toolCall.result?.status === "success" && + toolCall.result.value.linesAdded !== undefined + ? { additions: toolCall.result.value.linesAdded } + : {}), + ...(toolCall.type === "edit" && + toolCall.result?.status === "success" && + toolCall.result.value.linesRemoved !== undefined + ? { deletions: toolCall.result.value.linesRemoved } + : {}), + ...(toolCall.type === "edit" && + toolCall.result?.status === "success" && + toolCall.result.value.diffString !== undefined + ? { diffStr: toolCall.result.value.diffString } + : // A failed change keeps its error where the diff would be. + toolCall.result?.status === "error" && outputText.trim().length > 0 + ? { diffStr: outputText } + : {}), + ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}), + }; + break; + case "read": + turnItem = { + ...base, + title: formatReadToolLabel(toolCall.args.path), + type: "dynamic_tool", + toolName: "Read", + input: toolCall.args, + ...(cursorToolOutput(toolCall) === undefined + ? {} + : { output: cursorToolOutput(toolCall) }), + }; + break; + case "glob": + case "grep": + case "ls": + case "readLints": + case "semSearch": { + const pattern = cursorToolSearchPattern(toolCall); + const searchPath = + toolCall.type === "grep" + ? toolCall.args.path + : toolCall.type === "glob" + ? toolCall.args.targetDirectory + : toolCall.type === "semSearch" + ? toolCall.args.targetDirectories?.join(", ") + : pattern; + // A failed search keeps its error as one row under the searched path. + const results = + toolCall.result?.status === "error" && outputText.trim().length > 0 + ? [{ fileName: searchPath?.trim() || ".", preview: outputText }] + : cursorToolSearchResults(toolCall, path); + turnItem = { + ...base, + title: + formatSearchToolLabel({ + input: toolCall.args, + ...(pattern === undefined ? {} : { pattern }), + }) ?? null, + type: "file_search", + ...(pattern === undefined ? {} : { pattern }), + ...(results.length === 0 ? {} : { results: [...results] }), + }; + break; + } + default: + turnItem = { + ...base, + type: "dynamic_tool", + ...(toolCall.type === "mcp" + ? mcpToolPresentation({ + serverName: toolCall.args.providerIdentifier, + toolName: toolCall.args.toolName, + }) + : {}), + toolName: cursorToolName(toolCall), + input: toolCall.args, + ...(cursorToolOutput(toolCall) === undefined + ? {} + : { output: cursorToolOutput(toolCall) }), + }; + } + yield* emitProviderEvent({ + type: "node.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + node, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + turnItem, + }); + }); + + const ensureToolStarted = Effect.fnUntraced(function* ( + context: ActiveCursorTurn, + callId: string, + toolCall: ToolCall, + ) { + const existing = context.tools.get(callId); + if (existing !== undefined) { + existing.toolCall = toolCall; + return existing; + } + const startedAt = yield* DateTime.now; + const active: ActiveCursorToolCall = { + callId, + toolCall, + target: parentTarget(context), + ordinal: yield* resolveItemOrdinal(context, callId), + startedAt, + streamedOutput: "", + }; + context.tools.set(callId, active); + yield* emitToolArtifacts({ active, completed: false }); + return active; + }); + + const emitPlanArtifacts = Effect.fnUntraced(function* (input: { + readonly context: ActiveCursorTurn; + readonly callId: string; + readonly markdown: string; + readonly completed: boolean; + readonly failed: boolean; + }) { + const nativeItemId = `plan:${input.callId}`; + const now = yield* DateTime.now; + const planId = yield* resolvePlanId(input.context, nativeItemId); + const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId, + }); + const status = input.failed ? "failed" : input.completed ? "completed" : "running"; + const nativeItemRef = { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: input.callId, + strength: "strong" as const, + }; + const plan: OrchestrationV2PlanArtifact = { + id: planId, + threadId: input.context.input.threadId, + runId: input.context.input.runId, + nodeId, + kind: "proposed_plan", + status: input.completed ? "active" : "draft", + markdown: input.markdown, + }; + yield* emitProviderEvent({ + type: "node.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + node: { + id: nodeId, + threadId: input.context.input.threadId, + runId: input.context.input.runId, + parentNodeId: input.context.input.rootNodeId, + rootNodeId: input.context.input.rootNodeId, + kind: "plan", + status, + countsForRun: false, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: input.context.startedAt, + completedAt: input.completed ? now : null, + }, + }); + yield* emitProviderEvent({ + type: "plan.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + plan, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + turnItem: { + id: turnItemId, + threadId: input.context.input.threadId, + runId: input.context.input.runId, + nodeId, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status, + title: null, + startedAt: input.context.startedAt, + completedAt: input.completed ? now : null, + updatedAt: now, + type: "proposed_plan", + planId, + markdown: input.markdown, + streaming: !input.completed, + }, + }); + }); + + const emitTodoArtifacts = Effect.fnUntraced(function* (input: { + readonly context: ActiveCursorTurn; + readonly callId: string; + readonly toolCall: Extract; + readonly completed: boolean; + readonly failed: boolean; + }) { + const nativeItemId = `todos:${input.callId}`; + const now = yield* DateTime.now; + const planId = yield* resolvePlanId(input.context, nativeItemId); + const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId); + const nodeId = idAllocator.derive.nodeFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId, + }); + const turnItemId = idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId, + }); + const status = input.failed ? "failed" : input.completed ? "completed" : "running"; + const steps = cursorTodoSteps(input.toolCall); + const nativeItemRef = { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: input.callId, + strength: "strong" as const, + }; + yield* emitProviderEvent({ + type: "node.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + node: { + id: nodeId, + threadId: input.context.input.threadId, + runId: input.context.input.runId, + parentNodeId: input.context.input.rootNodeId, + rootNodeId: input.context.input.rootNodeId, + kind: "todo_list", + status, + countsForRun: false, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: input.context.startedAt, + completedAt: input.completed ? now : null, + }, + }); + yield* emitProviderEvent({ + type: "plan.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + plan: { + id: planId, + threadId: input.context.input.threadId, + runId: input.context.input.runId, + nodeId, + kind: "todo_list", + status: input.completed ? "active" : "draft", + steps: [...steps], + }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + turnItem: { + id: turnItemId, + threadId: input.context.input.threadId, + runId: input.context.input.runId, + nodeId, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef, + parentItemId: null, + ordinal, + status, + title: null, + startedAt: input.context.startedAt, + completedAt: input.completed ? now : null, + updatedAt: now, + type: "todo_list", + planId, + steps: [...steps], + }, + }); + }); + + const emitSubagent = Effect.fnUntraced(function* (input: { + readonly context: ActiveCursorTurn; + readonly callId: string; + readonly toolCall: Extract; + readonly completed: boolean; + readonly status?: OrchestrationV2Subagent["status"]; + }) { + const args = input.toolCall.args; + const result = + input.toolCall.result?.status === "success" ? input.toolCall.result.value : undefined; + const existing = input.context.subagents.get(input.callId); + if ( + existing !== undefined && + !isOrchestrationV2WorkActive(existing.task.status) && + !input.completed + ) + return; + const now = yield* DateTime.now; + const status: OrchestrationV2Subagent["status"] = + input.status ?? + (input.completed + ? cursorToolFailed(input.toolCall) + ? "failed" + : "completed" + : "running"); + const resultText = [ + ...assistantTextsFromConversationSteps(result?.conversationSteps ?? []), + ...(result?.resultSuffix === undefined ? [] : [result.resultSuffix]), + ] + .filter((part) => part.trim().length > 0) + .join("\n"); + const nativeItemId = input.callId; + const nodeId = + existing?.task.id ?? + idAllocator.derive.nodeFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId, + }); + const childRootNodeId = + existing?.childRootNodeId ?? + idAllocator.derive.nodeFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: `${nativeItemId}:child-root`, + }); + const childThreadId = + existing?.childThreadId ?? + idAllocator.derive.threadFromProviderThread({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeThreadId: `${input.context.run.runId}:task:${input.callId}`, + }); + const task: OrchestrationV2Subagent = { + ...(existing?.task ?? { + id: nodeId, + threadId: input.context.input.threadId, + runId: input.context.input.runId, + parentNodeId: input.context.input.rootNodeId, + origin: "provider_native" as const, + createdBy: "agent" as const, + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerInstanceId: input.context.input.modelSelection.instanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: input.callId, + strength: "strong" as const, + }, + prompt: args.prompt, + title: args.description, + result: null, + startedAt: now, + }), + model: args.model?.trim() || existing?.task.model || null, + nativeTaskRef: { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: input.callId, + strength: "strong" as const, + }, + status, + result: resultText.length === 0 ? (existing?.task.result ?? null) : resultText, + completedAt: input.completed ? (existing?.task.completedAt ?? now) : null, + updatedAt: now, + }; + const subagent: ActiveCursorSubagent = { + task, + toolCall: input.toolCall, + callId: input.callId, + childThreadId, + childRootNodeId, + turnItemId: + existing?.turnItemId ?? + idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId, + }), + turnItemOrdinal: + existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(input.context, nativeItemId)), + nextChildOrdinal: existing?.nextChildOrdinal ?? 100, + resultProjected: existing?.resultProjected ?? false, + }; + input.context.subagents.set(input.callId, subagent); + + if (existing === undefined) { + yield* emitProviderEvent({ + type: "app_thread.created", + driver: CursorAgentSdk.CURSOR_PROVIDER, + appThread: makeSubagentChildThread({ + parentThread: input.context.input.appThread, + childThreadId, + parentNodeId: nodeId, + activeProviderThreadId: null, + providerInstanceId: input.context.input.modelSelection.instanceId, + modelSelection: input.context.input.modelSelection, + title: subagentThreadTitle({ + parentTitle: input.context.input.appThread.title, + title: args.description, + prompt: args.prompt, + ordinal: input.context.subagents.size, + }), + now, + createdBy: "agent", + creationSource: "provider", + }), + }); + const promptNativeId = `${nativeItemId}:prompt`; + const promptArtifacts = makeSubagentConversationArtifacts({ + senderThreadId: input.context.input.threadId, + messageId: idAllocator.derive.messageFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: promptNativeId, + }), + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: promptNativeId, + }), + threadId: childThreadId, + rootNodeId: childRootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: promptNativeId, + strength: "weak", + }, + role: "user", + text: args.prompt, + ordinal: 100, + now, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + message: promptArtifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + turnItem: promptArtifacts.turnItem, + }); + } + + yield* emitProviderEvent({ + type: "node.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + node: { + id: nodeId, + threadId: input.context.input.threadId, + runId: input.context.input.runId, + parentNodeId: input.context.input.rootNodeId, + rootNodeId: input.context.input.rootNodeId, + kind: "subagent", + status, + countsForRun: false, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef: task.nativeTaskRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: task.startedAt, + completedAt: task.completedAt, + }, + }); + yield* emitProviderEvent({ + type: "node.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + node: { + id: childRootNodeId, + threadId: childThreadId, + runId: null, + parentNodeId: null, + rootNodeId: childRootNodeId, + kind: "root_turn", + status, + countsForRun: false, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: task.nativeTaskRef, + runtimeRequestId: null, + checkpointScopeId: null, + startedAt: task.startedAt, + completedAt: task.completedAt, + }, + }); + yield* emitProviderEvent({ + type: "subagent.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + subagent: task, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + turnItem: { + id: subagent.turnItemId, + threadId: input.context.input.threadId, + runId: input.context.input.runId, + nodeId, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef: task.nativeTaskRef, + parentItemId: null, + ordinal: subagent.turnItemOrdinal, + status, + title: task.title, + startedAt: task.startedAt, + completedAt: task.completedAt, + updatedAt: now, + type: "subagent", + subagentId: task.id, + origin: task.origin, + driver: task.driver, + providerInstanceId: task.providerInstanceId, + childThreadId, + prompt: task.prompt, + result: task.result, + }, + }); + + if (input.completed && !subagent.resultProjected) { + for (const [index, nestedTool] of toolCallsFromConversationSteps( + result?.conversationSteps ?? [], + ).entries()) { + const callId = `${nativeItemId}:child-tool:${nestedTool.callId || index + 1}`; + const startedAt = task.startedAt ?? now; + const active: ActiveCursorToolCall = { + callId, + toolCall: nestedTool.toolCall, + target: { + threadId: childThreadId, + runId: null, + rootNodeId: childRootNodeId, + parentNodeId: childRootNodeId, + providerThreadId: null, + providerTurnId: null, + }, + ordinal: ++subagent.nextChildOrdinal, + startedAt, + streamedOutput: "", + }; + yield* emitToolArtifacts({ active, completed: true }); + } + if (task.result !== null && task.result.length > 0) { + const resultNativeId = `${nativeItemId}:result`; + const resultArtifacts = makeSubagentConversationArtifacts({ + messageId: idAllocator.derive.messageFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: resultNativeId, + }), + turnItemId: idAllocator.derive.turnItemFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: resultNativeId, + }), + threadId: childThreadId, + rootNodeId: childRootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: resultNativeId, + strength: "weak", + }, + role: "assistant", + text: task.result, + ordinal: ++subagent.nextChildOrdinal, + now, + }); + yield* emitProviderEvent({ + type: "message.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + message: resultArtifacts.message, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + turnItem: resultArtifacts.turnItem, + }); + } + subagent.resultProjected = true; + } + }); + + const handleToolUpdate = Effect.fnUntraced(function* ( + context: ActiveCursorTurn, + update: Extract< + InteractionUpdate, + { + readonly type: "tool-call-started" | "partial-tool-call" | "tool-call-completed"; + } + >, + ) { + const completed = update.type === "tool-call-completed"; + const toolCall = update.toolCall; + switch (toolCall.type) { + case "createPlan": + yield* emitPlanArtifacts({ + context, + callId: update.callId, + markdown: toolCall.args.plan, + completed, + failed: completed && cursorToolFailed(toolCall), + }); + return; + case "updateTodos": + yield* emitTodoArtifacts({ + context, + callId: update.callId, + toolCall, + completed, + failed: completed && cursorToolFailed(toolCall), + }); + return; + case "task": + if (update.type === "partial-tool-call") { + return; + } + yield* emitSubagent({ + context, + callId: update.callId, + toolCall, + completed, + }); + return; + default: { + const active = yield* ensureToolStarted(context, update.callId, toolCall); + active.toolCall = toolCall; + if (update.type !== "tool-call-started") { + yield* emitToolArtifacts({ active, completed }); + } + if (completed) { + context.tools.delete(update.callId); + } + } + } + }); + + const shellOutputText = (event: Record): string => { + const candidates = [ + event.text, + event.output, + event.data, + event.stdout, + event.stderr, + event.chunk, + ]; + return candidates.filter((value): value is string => typeof value === "string").join(""); + }; + + const handleInteractionUpdate = Effect.fnUntraced(function* ( + context: ActiveCursorTurn, + update: InteractionUpdate, + ) { + if (context.finalized) { + return; + } + switch (update.type) { + case "text-delta": + context.assistantReply.push(update.text); + yield* completeReasoning(context); + yield* appendTextSegment({ + context, + stream: context.assistant, + kind: "assistant", + text: update.text, + }); + yield* emitAssistant(context, false); + return; + case "thinking-delta": + yield* completeAssistant(context); + yield* appendTextSegment({ + context, + stream: context.reasoning, + kind: "reasoning", + text: update.text, + }); + yield* emitReasoning(context, false); + return; + case "thinking-completed": + yield* completeReasoning(context); + return; + case "tool-call-started": + case "partial-tool-call": + case "tool-call-completed": + yield* completeAssistant(context); + yield* completeReasoning(context); + yield* handleToolUpdate(context, update); + return; + case "step-completed": + case "turn-ended": + yield* completeAssistant(context); + yield* completeReasoning(context); + return; + case "shell-output-delta": { + const shell = Array.from(context.tools.values()) + .toReversed() + .find((candidate) => candidate.toolCall.type === "shell"); + if (shell === undefined) { + return; + } + shell.streamedOutput += shellOutputText(update.event); + yield* emitToolArtifacts({ active: shell, completed: false }); + return; + } + default: + return; + } + }); + + const providerTurnPayload = (input: { + readonly context: ActiveCursorTurn; + readonly status: OrchestrationV2ProviderTurn["status"]; + readonly completedAt: DateTime.Utc | null; + }): OrchestrationV2ProviderTurn => ({ + id: input.context.providerTurnId, + providerThreadId: input.context.input.providerThread.id, + nodeId: input.context.input.rootNodeId, + runAttemptId: input.context.input.attemptId, + nativeTurnRef: { + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeId: input.context.run.runId, + strength: "strong", + }, + ordinal: input.context.input.providerTurnOrdinal, + status: input.status, + startedAt: input.context.startedAt, + completedAt: input.completedAt, + }); + + const finalizeTurn = Effect.fnUntraced(function* (input: { + readonly context: ActiveCursorTurn; + readonly status: Extract< + OrchestrationV2ProviderTurn["status"], + "completed" | "interrupted" | "failed" | "cancelled" + >; + readonly failure?: OrchestrationV2ProviderFailure; + readonly threadDisposition?: "reusable" | "broken"; + }) { + if (input.context.finalized) { + return; + } + input.context.finalized = true; + const completedAt = yield* DateTime.now; + // Tools still here never got a tool-call-completed. A stopped or + // failed turn cut them short, so they end with the turn's status. + for (const tool of input.context.tools.values()) { + yield* emitToolArtifacts({ + active: tool, + completed: true, + ...(input.status === "completed" ? {} : { unfinishedStatus: input.status }), + }); + } + input.context.tools.clear(); + // This interaction stops delivering updates at finalization. Tasks + // without a completion have an unknown outcome. Background launch + // acknowledgements have already settled their rows. + for (const subagent of input.context.subagents.values()) { + if (!isOrchestrationV2WorkActive(subagent.task.status)) continue; + yield* emitSubagent({ + context: input.context, + callId: subagent.callId, + toolCall: subagent.toolCall, + completed: true, + status: input.status === "completed" ? "idle" : input.status, + }); + } + yield* completeReasoning(input.context); + yield* completeAssistant(input.context); + yield* emitProviderEvent({ + type: "provider_turn.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerTurn: providerTurnPayload({ + context: input.context, + status: input.status, + completedAt, + }), + }); + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThread: { + ...input.context.input.providerThread, + providerSessionId: session.id, + status: "active", + firstRunOrdinal: + input.context.input.providerThread.firstRunOrdinal ?? + input.context.input.runOrdinal, + lastRunOrdinal: input.context.input.runOrdinal, + updatedAt: completedAt, + }, + }); + const threadDisposition = input.threadDisposition ?? "reusable"; + yield* emitProviderEvent( + input.status === "failed" + ? { + type: "turn.terminal", + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + runOrdinal: input.context.input.runOrdinal, + failureItemOrdinal: yield* resolveItemOrdinal( + input.context, + `terminal-failure:${input.context.providerTurnId}`, + ), + status: input.status, + failure: input.failure ?? makeProviderFailure({ class: "provider_error" }), + threadDisposition, + } + : { + type: "turn.terminal", + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + runOrdinal: input.context.input.runOrdinal, + status: input.status, + failure: null, + threadDisposition, + }, + ); + yield* Ref.update(activeTurn, (current) => + current?.providerTurnId === input.context.providerTurnId ? null : current, + ); + yield* Deferred.succeed(input.context.completed, undefined); + }); + + const terminalStatus = ( + context: ActiveCursorTurn, + result: RunResult, + ): Extract< + OrchestrationV2ProviderTurn["status"], + "completed" | "interrupted" | "failed" | "cancelled" + > => { + if (context.interrupted) { + return "interrupted"; + } + switch (result.status) { + case "finished": + return "completed"; + case "cancelled": + return "cancelled"; + case "error": + return "failed"; + } + }; + + const openAgent = Effect.fnUntraced(function* (openInput: { + readonly operation: "create" | "resume"; + readonly threadId: ThreadId; + readonly modelSelection: ModelSelection; + readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; + readonly agentId?: string; + }) { + const existing = yield* Ref.get(liveAgent); + if ( + existing !== null && + openInput.operation === "resume" && + existing.nativeThreadId === openInput.agentId + ) { + return existing; + } + if (existing !== null) { + yield* existing.session.close.pipe(Effect.ignore); + yield* Ref.set(liveAgent, null); + } + const sdkSession = yield* runner.open({ + operation: openInput.operation, + ...(openInput.agentId === undefined ? {} : { agentId: openInput.agentId }), + options: makeCursorAgentOptions({ + ...(apiKey === undefined ? {} : { apiKey }), + modelSelection: openInput.modelSelection, + runtimePolicy: openInput.runtimePolicy, + threadId: openInput.threadId, + }), + threadId: openInput.threadId, + providerSessionId: input.providerSessionId, + }); + const next = { + nativeThreadId: sdkSession.agentId, + session: sdkSession, + } satisfies CursorLiveAgent; + yield* Ref.set(liveAgent, next); + return next; + }); + + let cursorSkillNames: ReadonlySet | undefined; + const resolveUserMessage = Effect.fnUntraced(function* ( + turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, + ) { + const rawText = turnInput.message.text; + if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) { + return "/compress"; + } + if (hasCursorSkillMention(rawText) && cursorSkillNames === undefined) { + const skills = yield* discoverCursorSkills( + turnInput.runtimePolicy.cwd ?? undefined, + adapterOptions.environment, + ).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + cursorSkillNames = new Set( + skills + .filter((skill) => skill.enabled && skill.userInvocable !== false) + .map((skill) => skill.name), + ); + } + const userText = t3OrchestrationPromptForFirstRun({ + prompt: providerMessageTextWithAttachmentPaths({ + text: + cursorSkillNames === undefined + ? rawText + : rewriteCursorSkillMentions(rawText, cursorSkillNames), + attachments: turnInput.message.attachments, + resolveAttachmentPath: host.resolveAttachmentPath, + }), + runOrdinal: turnInput.runOrdinal, + hasT3Mcp: cursorMcpServers(turnInput.threadId) !== undefined, + }); + const images = yield* Effect.forEach( + turnInput.message.attachments.filter(isProviderNativeImageAttachment), + (attachment: ChatAttachment) => + Effect.gen(function* () { + const path = host.resolveAttachmentPath(attachment); + if (path === null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(path).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: `Failed to read attachment '${attachment.id}'.`, + payload: cause, + }), + ), + ); + return { + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }; + }), + { concurrency: 1 }, + ); + if (userText.length === 0 && images.length === 0) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: "Cursor turn requires non-empty text or attachments.", + }); + } + const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`; + return images.length === 0 + ? text + : ({ + text, + images, + } satisfies SDKUserMessage); + }); + + const startTurn = Effect.fn("CursorAdapterV2.startTurn")( + function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { + const current = yield* Ref.get(activeTurn); + if (current !== null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: `Cursor provider turn ${current.providerTurnId} is still active.`, + }); + } + const agentId = nativeThreadId(turnInput.providerThread); + const agent = yield* openAgent({ + operation: "resume", + agentId, + threadId: turnInput.threadId, + modelSelection: turnInput.modelSelection, + runtimePolicy: turnInput.runtimePolicy, + }); + const message = yield* resolveUserMessage(turnInput); + const mcpServers = cursorMcpServers(turnInput.threadId); + const pendingUpdates: Array = []; + let context: ActiveCursorTurn | null = null; + const sdkRun = yield* agent.session.send({ + message, + options: { + model: cursorSdkModelSelection(turnInput.modelSelection), + mode: turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", + ...(mcpServers === undefined ? {} : { mcpServers }), + }, + onDelta: (update) => { + if (context === null) { + return Effect.sync(() => { + pendingUpdates.push(update); + }); + } + return handleInteractionUpdate(context, update); + }, + }); + const startedAt = yield* DateTime.now; + const completed = yield* Deferred.make(); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeTurnId: sdkRun.runId, + }); + context = { + input: turnInput, + run: sdkRun, + providerTurnId, + startedAt, + completed, + itemOrdinals: new Map(), + tools: new Map(), + subagents: new Map(), + assistant: { + current: null, + nextSegment: 0, + }, + assistantReply: new CursorTransportFailure(), + reasoning: { + current: null, + nextSegment: 0, + }, + interrupted: false, + finalized: false, + }; + yield* Ref.set(activeTurn, context); + yield* emitProviderEvent({ + type: "provider_turn.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerTurn: providerTurnPayload({ + context, + status: "running", + completedAt: null, + }), + }); + yield* emitProviderEvent({ + type: "provider_thread.updated", + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThread: { + ...turnInput.providerThread, + providerSessionId: session.id, + status: "active", + updatedAt: startedAt, + }, + }); + for (const update of pendingUpdates) { + yield* handleInteractionUpdate(context, update); + } + + yield* sdkRun.wait.pipe( + Effect.flatMap((result) => + Effect.gen(function* () { + if ( + context !== null && + context.assistant.nextSegment === 0 && + result.result !== undefined && + result.result.length > 0 + ) { + context.assistantReply.push(result.result); + yield* appendTextSegment({ + context, + stream: context.assistant, + kind: "assistant", + text: result.result, + }); + } + if (context !== null) { + const transportFailure = context.assistantReply.failure; + const status = + transportFailure === undefined ? terminalStatus(context, result) : "failed"; + yield* finalizeTurn({ + context, + status, + ...(status === "failed" + ? { + failure: makeProviderFailure({ + cause: + transportFailure ?? (result as { readonly error?: unknown }).error, + class: + transportFailure === undefined + ? "provider_error" + : "transport_error", + }), + } + : {}), + }); + } + }), + ), + Effect.catch((cause) => + Effect.gen(function* () { + if (context !== null) { + yield* finalizeTurn({ + context, + status: context.interrupted ? "interrupted" : "failed", + ...(context.interrupted + ? {} + : { + failure: makeProviderFailure({ cause, class: "transport_error" }), + }), + }); + } + yield* Effect.logWarning("orchestration-v2.cursor-run-failed", { + providerSessionId: input.providerSessionId, + providerThreadId: turnInput.providerThread.id, + providerTurnId, + cause, + }); + }), + ), + Effect.forkIn(sessionScope), + ); + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterTurnStartError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + threadId: turnInput.threadId, + providerThreadId: turnInput.providerThread.id, + runId: turnInput.runId, + cause, + }), + ), + ), + ); + + const closeSession = Effect.fnUntraced(function* () { + const existing = yield* Ref.get(liveAgent); + if (existing !== null) { + yield* existing.session.close.pipe(Effect.ignore); + yield* Ref.set(liveAgent, null); + } + yield* runner.assertComplete.pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.cursor-runner-incomplete", { + providerSessionId: input.providerSessionId, + cause, + }), + ), + ); + }); + yield* Effect.addFinalizer(() => closeSession()); + + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { + instanceId: adapterOptions.instanceId, + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerSessionId: input.providerSessionId, + providerSession: session, + events: Stream.fromEffectRepeat(Queue.take(events)), + ensureThread: Effect.fn("CursorAdapterV2.ensureThread")( + function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) { + const opened = yield* openAgent({ + operation: "create", + threadId: threadInput.threadId, + modelSelection: threadInput.modelSelection, + runtimePolicy: threadInput.runtimePolicy, + }); + const now = yield* DateTime.now; + return makeProviderThread({ + idAllocator, + providerInstanceId: adapterOptions.instanceId, + appThreadId: threadInput.threadId, + providerSessionId: input.providerSessionId, + nativeThreadId: opened.nativeThreadId, + now, + }); + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterEnsureThreadError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + threadId: threadInput.threadId, + cause, + }), + ), + ), + ), + resumeThread: Effect.fn("CursorAdapterV2.resumeThread")( + function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) { + const agentId = nativeThreadId(threadInput.providerThread); + yield* openAgent({ + operation: "resume", + agentId, + threadId: threadInput.providerThread.appThreadId ?? input.threadId, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + }); + const now = yield* DateTime.now; + return { + ...threadInput.providerThread, + providerSessionId: input.providerSessionId, + status: "idle" as const, + updatedAt: now, + }; + }, + (effect, threadInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterResumeThreadError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerSessionId: input.providerSessionId, + providerThreadId: threadInput.providerThread.id, + cause, + }), + ), + ), + ), + startTurn, + compactThread: (turnInput) => + startTurn({ + ...turnInput, + message: { ...turnInput.message, text: "/compress" }, + }), + steerTurn: (turnInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThreadId: turnInput.providerThread.id, + }), + ), + interruptTurn: Effect.fn("CursorAdapterV2.interruptTurn")( + function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { + const context = yield* Ref.get(activeTurn); + // Stop on a settled turn: finalization already ended its tools + // and subagents, so nothing of it is left running to stop. + if (context === null && turnInput.requestRuntimeRestart === true) return; + if (context?.providerTurnId !== turnInput.providerTurnId) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: `Cursor provider turn ${turnInput.providerTurnId} is not active.`, + }); + } + context.interrupted = true; + yield* context.run.cancel; + const stopped = yield* Deferred.await(context.completed).pipe( + Effect.timeoutOption("10 seconds"), + ); + if (Option.isSome(stopped)) { + return; + } + yield* Effect.logWarning("orchestration-v2.cursor-interrupt-timeout", { + providerSessionId: input.providerSessionId, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + }); + yield* finalizeTurn({ context, status: "interrupted" }); + }, + (effect, turnInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterInterruptError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + cause, + }), + ), + ), + ), + respondToRuntimeRequest: (requestInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + requestId: requestInput.requestId, + cause: new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + detail: "Cursor Agent SDK does not expose interactive approval requests.", + }), + }), + ), + readThreadSnapshot: Effect.fn("CursorAdapterV2.readThreadSnapshot")( + function* (snapshotInput) { + const requestedAgentId = nativeThreadId(snapshotInput.providerThread); + const agent = yield* openAgent({ + operation: "resume", + agentId: requestedAgentId, + threadId: snapshotInput.providerThread.appThreadId ?? input.threadId, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + }); + const messages = yield* agent.session.listMessages; + const now = yield* DateTime.now; + const threadId = snapshotInput.providerThread.appThreadId ?? input.threadId; + const projectedMessages: Array = messages.flatMap( + (message) => { + const text = textFromAgentMessage(message); + if (text.length === 0) { + return []; + } + return [ + { + createdBy: message.type === "user" ? "user" : "agent", + creationSource: "provider", + id: idAllocator.derive.messageFromProviderItem({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + nativeItemId: message.uuid, + }), + threadId, + runId: null, + nodeId: null, + role: message.type, + text, + attachments: [], + streaming: false, + createdAt: now, + updatedAt: now, + }, + ]; + }, + ); + return { + providerThread: { + ...snapshotInput.providerThread, + providerSessionId: input.providerSessionId, + status: "idle" as const, + updatedAt: now, + }, + providerTurns: [], + messages: projectedMessages, + runtimeRequests: [], + providerPayload: { + agentId: requestedAgentId, + messages, + }, + }; + }, + (effect, snapshotInput) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThreadId: snapshotInput.providerThread.id, + cause, + }), + ), + ), + ), + rollbackThread: (rollbackInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterRollbackThreadError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThreadId: rollbackInput.providerThread.id, + checkpointId: rollbackInput.target.checkpointId, + cause: "Cursor Agent SDK does not expose conversation rollback.", + }), + ), + forkThread: (forkInput) => + Effect.fail( + new ProviderAdapter.ProviderAdapterForkThreadError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerThreadId: forkInput.sourceProviderThread.id, + cause: "Cursor Agent SDK does not expose native agent forks.", + }), + ), + }; + return runtime; + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: CursorAgentSdk.CURSOR_PROVIDER, + providerSessionId: input.providerSessionId, + cause, + }), + ), + ), + ), + }); +} + +export type CursorAdapterV2DriverEnv = + | CursorAgentSdk.CursorAgentSdkRunner + | FileSystem.FileSystem + | Path.Path + | IdAllocator.IdAllocatorV2 + | ProviderHost.ProviderHost; + +export const CursorAdapterV2Driver: ProviderAdapterDriver< + CursorSettings, + CursorAdapterV2DriverEnv +> = { + driverKind: CURSOR_DRIVER_KIND, + configSchema: CursorSettings, + defaultConfig: (): CursorSettings => DEFAULT_CURSOR_SETTINGS, + create: Effect.fn("CursorAdapterV2Driver.create")( + function* (input: ProviderAdapterDriverCreateInput) { + const hostEnvironment = yield* HostProcessEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const host = yield* ProviderHost.ProviderHost; + return makeCursorAdapterV2({ + instanceId: input.instanceId, + settings: { + ...input.config, + enabled: input.enabled, + }, + environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), + fileSystem, + path, + idAllocator, + runner, + host, + }); + }, + (effect, input) => + effect.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterDriverCreateError({ + driver: CURSOR_DRIVER_KIND, + instanceId: input.instanceId, + detail: "Failed to create Cursor Agent SDK adapter.", + cause, + }), + ), + ), + ), +}; + +const layer: Layer.Layer< + ProviderAdapter.ProviderAdapterV2, + never, + | CursorAgentSdk.CursorAgentSdkRunner + | FileSystem.FileSystem + | Path.Path + | IdAllocator.IdAllocatorV2 + | ProviderHost.ProviderHost +> = Layer.effect( + ProviderAdapter.ProviderAdapterV2, + Effect.gen(function* () { + const hostEnvironment = yield* HostProcessEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const host = yield* ProviderHost.ProviderHost; + return makeCursorAdapterV2({ + instanceId: CURSOR_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CURSOR_SETTINGS, + environment: hostEnvironment, + fileSystem, + path, + idAllocator, + runner, + host, + }); + }), +); diff --git a/packages/provider-cursor/src/server/auth.test.ts b/packages/provider-cursor/src/server/auth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d31211ac5aef579708ca1eab0382d859820d4f7 --- /dev/null +++ b/packages/provider-cursor/src/server/auth.test.ts @@ -0,0 +1,356 @@ +import type { SdkLoginOptions, StoredSdkCredentials } from "@cursor/sdk"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { makeCursorAuth, type CursorAuthOptions } from "./auth.ts"; +import { InMemoryCredentialStore } from "./sdk.ts"; + +const instanceId = ProviderInstanceId.make("cursor-personal"); +const owner = "owner-client"; +const otherOwner = "other-client"; +const authorizationUrl = "https://cursor.com/loginDeepControl?challenge=test-challenge"; + +const makeHarness = Effect.fn("makeCursorAuthHarness")(function* ( + overrides: Partial = {}, +) { + const store = new InMemoryCredentialStore(); + const complete = Promise.withResolvers(); + const returned = Promise.withResolvers(); + const now = yield* Clock.currentTimeMillis; + const credentials: StoredSdkCredentials = { + version: 1, + backendUrl: "https://api2.cursor.sh", + apiKey: "browser-key", + createdAtMs: now, + apiKeyExpiresAtMs: now + 86_400_000, + email: "cursor@example.com", + }; + let loginOptions: SdkLoginOptions | undefined; + const changes: boolean[] = []; + const auth = yield* makeCursorAuth({ + instanceId, + displayName: "Personal Cursor", + enabled: true, + store, + onChanged: (signedIn) => + Effect.sync(() => { + changes.push(signedIn); + }), + login: async (options) => { + loginOptions = options; + options.onLoginUrl?.(authorizationUrl); + await complete.promise; + await options.store?.save(credentials); + returned.resolve(); + return { + apiKey: credentials.apiKey, + apiKeyExpiresAtMs: credentials.apiKeyExpiresAtMs!, + email: credentials.email!, + }; + }, + ...overrides, + }); + return { + auth, + store, + complete, + returned, + credentials, + changes, + loginOptions: () => loginOptions, + }; +}); + +type Auth = Effect.Success>; +const phase = (auth: Auth, phase: ProviderAuthState["phase"], session = owner) => + auth.controller.subscribe(session).pipe( + Stream.filter((state) => state.phase === phase), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +it.layer(NodeServices.layer)("CursorAuth", (it) => { + it.effect("closes SDK sessions and interrupts in-flight credential work before signing out", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.promise(() => harness.store.save(harness.credentials)); + const acquired = yield* Deferred.make(); + let closedSession = false; + let closedRequest = false; + yield* harness.auth.withAccess( + Effect.acquireRelease(Effect.succeed("session"), () => + Effect.sync(() => { + closedSession = true; + }), + ), + ); + const request = yield* harness.auth + .withAccess( + Effect.gen(function* () { + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closedRequest = true; + }), + ); + yield* Deferred.succeed(acquired, undefined); + return yield* Effect.never; + }), + ) + .pipe(Effect.forkScoped); + yield* Deferred.await(acquired); + yield* harness.auth.controller.logout(Effect.void); + expect(closedSession).toBe(true); + expect(closedRequest).toBe(true); + expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true); + expect(yield* harness.auth.readApiKey).toBeUndefined(); + }).pipe(Effect.scoped), + ); + + it.effect("completes a remote browser login, saves its key and refreshes provider status", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + let stopped = false; + expect(yield* harness.auth.readApiKey).toBeUndefined(); + const starting = yield* harness.auth.controller.start( + owner, + Effect.sync(() => { + stopped = true; + }), + ); + const waiting = yield* phase(harness.auth, "waiting"); + expect(stopped).toBe(true); + expect(waiting.authorizationUrl).toBe(authorizationUrl); + expect(waiting.flowId).toBe(starting.flowId); + expect(harness.loginOptions()?.openBrowser).toBe(false); + expect((yield* harness.auth.controller.start(owner)).flowId).toBe(starting.flowId); + expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); + yield* Effect.sync(() => harness.complete.resolve()); + const succeeded = yield* phase(harness.auth, "succeeded"); + expect(succeeded.authorizationUrl).toBeNull(); + expect(yield* harness.auth.requireApiKey).toBe("browser-key"); + expect(harness.changes).toEqual([true]); + expect(Object.values(succeeded)).not.toContain("browser-key"); + expect(yield* Effect.promise(() => harness.store.load())).toEqual(harness.credentials); + }).pipe(Effect.scoped), + ); + + it.effect("keeps the login link private to its owner and rejects other clients' mutations", () => + Effect.gen(function* () { + const { auth } = yield* makeHarness(); + yield* auth.controller.start(owner); + const waiting = yield* phase(auth, "waiting"); + const foreign = yield* phase(auth, "waiting", otherOwner); + expect(foreign).toMatchObject({ flowId: null, authorizationUrl: null, expiresAt: null }); + expect(Exit.isFailure(yield* Effect.exit(auth.controller.start(otherOwner)))).toBe(true); + expect( + Exit.isFailure(yield* Effect.exit(auth.controller.cancel(otherOwner, waiting.flowId!))), + ).toBe(true); + expect( + Exit.isFailure( + yield* Effect.exit( + auth.controller.complete(owner, { + flowId: waiting.flowId!, + callbackUrl: "http://localhost/callback", + }), + ), + ), + ).toBe(true); + expect((yield* phase(auth, "waiting")).authorizationUrl).toBe(authorizationUrl); + }).pipe(Effect.scoped), + ); + + it.effect("aborts cancellation and ignores a late successful SDK login", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + const waiting = yield* phase(harness.auth, "waiting"); + const cancelled = yield* harness.auth.controller.cancel(owner, waiting.flowId!); + expect(cancelled.phase).toBe("cancelled"); + expect(cancelled.authorizationUrl).toBeNull(); + expect(harness.loginOptions()?.signal?.aborted).toBe(true); + yield* Effect.sync(() => harness.complete.resolve()); + yield* Effect.promise(() => harness.returned.promise); + expect(yield* harness.auth.readApiKey).toBeUndefined(); + expect(harness.changes).toEqual([]); + }).pipe(Effect.scoped), + ); + + it.effect("times out pending logins and permits a retry", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + const waiting = yield* phase(harness.auth, "waiting"); + yield* TestClock.adjust(300_000); + expect((yield* phase(harness.auth, "failed")).authorizationUrl).toBeNull(); + expect(harness.loginOptions()?.signal?.aborted).toBe(true); + const retry = yield* harness.auth.controller.start(owner); + expect(retry.flowId).not.toBe(waiting.flowId); + }).pipe(Effect.scoped), + ); + + it.effect("finishes saving a completed login atomically across the timeout", () => + Effect.gen(function* () { + const store = new InMemoryCredentialStore(); + const saving = Promise.withResolvers(); + const finishSave = Promise.withResolvers(); + const scope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); + const harness = yield* makeHarness({ + store: { + load: () => store.load(), + clear: () => store.clear(), + save: async (credentials) => { + saving.resolve(); + await finishSave.promise; + await store.save(credentials); + }, + }, + }).pipe(Effect.provideService(Scope.Scope, scope)); + yield* Effect.addFinalizer(() => Effect.sync(() => finishSave.resolve())); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* Effect.sync(() => harness.complete.resolve()); + yield* Effect.promise(() => saving.promise); + yield* TestClock.adjust(300_000); + yield* Effect.sync(() => finishSave.resolve()); + yield* phase(harness.auth, "succeeded"); + expect(yield* harness.auth.requireApiKey).toBe("browser-key"); + yield* Scope.close(scope, Exit.void); + const state = yield* harness.auth.controller.subscribe(owner).pipe(Stream.runHead); + expect(Option.getOrThrow(state).phase).toBe("succeeded"); + expect(harness.changes).toEqual([true]); + }).pipe(Effect.scoped), + ); + + it.effect("discards a credential rejected by verification and permits a fresh login", () => + Effect.gen(function* () { + let rejected = true; + const harness = yield* makeHarness({ + onChanged: (signedIn) => + signedIn && rejected + ? Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "start", + detail: "Could not verify the Cursor sign-in.", + }), + ) + : Effect.void, + }); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* Effect.sync(() => harness.complete.resolve()); + yield* phase(harness.auth, "failed"); + expect(yield* Effect.promise(() => harness.store.load())).toBeUndefined(); + expect(yield* harness.auth.readApiKey).toBeUndefined(); + expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); + rejected = false; + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "succeeded"); + expect(yield* harness.auth.requireApiKey).toBe("browser-key"); + }).pipe(Effect.scoped), + ); + + it.effect("clears only this instance's credential after its sessions stop", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const other = yield* makeHarness({ instanceId: ProviderInstanceId.make("cursor-work") }); + yield* Effect.promise(() => harness.store.save(harness.credentials)); + yield* Effect.promise(() => other.store.save({ ...other.credentials, apiKey: "other-key" })); + const state = yield* harness.auth.controller.logout( + Effect.gen(function* () { + expect(yield* harness.auth.readApiKey).toBe("browser-key"); + expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); + }), + ); + expect(state.message).toBe("Signed out of Cursor."); + expect(yield* harness.auth.readApiKey).toBeUndefined(); + expect(yield* other.auth.requireApiKey).toBe("other-key"); + expect(harness.changes).toEqual([false]); + }).pipe(Effect.scoped), + ); + + it.effect("cancels a pending sign-in on logout without saving its eventual key", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* harness.auth.controller.logout(Effect.void); + expect(harness.loginOptions()?.signal?.aborted).toBe(true); + yield* Effect.sync(() => harness.complete.resolve()); + yield* Effect.promise(() => harness.returned.promise); + expect(yield* harness.auth.readApiKey).toBeUndefined(); + }).pipe(Effect.scoped), + ); + + it.effect("retains credentials if sessions cannot be stopped for logout", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* Effect.promise(() => harness.store.save(harness.credentials)); + const result = yield* Effect.exit( + harness.auth.controller.logout( + Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "stop", + detail: "Unable to stop sessions", + }), + ), + ), + ); + expect(Exit.isFailure(result)).toBe(true); + expect(yield* harness.auth.requireApiKey).toBe("browser-key"); + }).pipe(Effect.scoped), + ); + + it.effect( + "prefers explicit API keys and prevents browser login from silently overriding them", + () => + Effect.gen(function* () { + const harness = yield* makeHarness({ apiKey: " explicit-key " }); + yield* Effect.promise(() => harness.store.save(harness.credentials)); + expect(yield* harness.auth.requireApiKey).toBe("explicit-key"); + expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true); + expect( + Exit.isFailure(yield* Effect.exit(harness.auth.controller.logout(Effect.void))), + ).toBe(true); + expect(harness.loginOptions()).toBeUndefined(); + expect((yield* Effect.promise(() => harness.store.load()))?.apiKey).toBe("browser-key"); + }).pipe(Effect.scoped), + ); + + it.effect("ignores expired browser credentials and rejects disabled sign-in", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ enabled: false }); + yield* Effect.promise(() => + harness.store.save({ ...harness.credentials, apiKeyExpiresAtMs: 0 }), + ); + expect(yield* harness.auth.readApiKey).toBeUndefined(); + expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true); + }).pipe(Effect.scoped), + ); + + it.effect("aborts pending login when the provider instance is disposed", () => + Effect.gen(function* () { + const scope = yield* Scope.make(); + const harness = yield* makeHarness().pipe(Effect.provideService(Scope.Scope, scope)); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* Scope.close(scope, Exit.void); + expect(harness.loginOptions()?.signal?.aborted).toBe(true); + yield* Effect.sync(() => harness.complete.resolve()); + yield* Effect.promise(() => harness.returned.promise); + expect(yield* harness.auth.readApiKey).toBeUndefined(); + }), + ); +}); diff --git a/packages/provider-cursor/src/server/auth.ts b/packages/provider-cursor/src/server/auth.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca1c64d9b2c0d49ac515930cc47ff4952ba64443 --- /dev/null +++ b/packages/provider-cursor/src/server/auth.ts @@ -0,0 +1,495 @@ +import type { SdkCredentialStore, SdkLoginOptions, SdkLoginResult } from "@cursor/sdk"; +import { + ProviderSetupError, + type ProviderAuthState, + type ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { Cursor, InMemoryCredentialStore } from "./sdk.ts"; +import type { ProviderAuthController } from "@t3tools/provider-core/server/auth"; + +const AUTH_TIMEOUT_MS = 300_000; + +interface AuthFlow { + readonly id: string; + readonly owner: string; + fiber?: Fiber.Fiber; +} + +export interface CursorAuth { + readonly controller: ProviderAuthController; + readonly readApiKey: Effect.Effect; + readonly requireApiKey: Effect.Effect; + readonly usesApiKey: boolean; + readonly withAccess: ( + task: Effect.Effect, + ) => Effect.Effect; +} + +export interface CursorAuthOptions { + readonly instanceId: ProviderInstanceId; + readonly displayName: string; + readonly enabled: boolean; + readonly apiKey?: string; + readonly store: SdkCredentialStore; + readonly credentialBinding?: NonNullable; + readonly onChanged: (signedIn: boolean) => Effect.Effect; + readonly login?: (options: SdkLoginOptions) => Promise; +} + +/** Each instance owns its browser credential; explicit API keys always take precedence. */ +export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: CursorAuthOptions) { + const scope = yield* Scope.Scope; + const crypto = yield* Crypto.Crypto; + const lock = yield* Semaphore.make(1); + const configuredKey = options.apiKey?.trim() || undefined; + const emptyState: ProviderAuthState = { + instanceId: options.instanceId, + phase: "idle", + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: null, + }; + const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: emptyState }); + let active: AuthFlow | undefined; + let operation: "idle" | "login" | "stopping" | "closed" = "idle"; + const sessions = new Set(); + + const publish = (flow: AuthFlow, patch: Partial) => + SubscriptionRef.update(snapshot, (current) => ({ + owner: flow.owner, + state: { ...current.state, ...patch }, + })); + + const readApiKey = Effect.gen(function* () { + if (configuredKey) return configuredKey; + const credentials = yield* Effect.tryPromise({ + try: () => options.store.load(), + catch: (cause) => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "credentials", + detail: "Could not read the Cursor sign-in. Try signing in again.", + cause, + }), + }); + const now = yield* Clock.currentTimeMillis; + return credentials && + (credentials.apiKeyExpiresAtMs === undefined || credentials.apiKeyExpiresAtMs > now) + ? credentials.apiKey + : undefined; + }); + + const requireApiKey = Effect.gen(function* () { + if (operation !== "idle") { + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "credentials", + detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.", + }); + } + const apiKey = yield* readApiKey; + if (!apiKey) { + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "credentials", + detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", + }); + } + return apiKey; + }); + + // Keep SDK sessions and one-shot text generation inside instance-owned scopes. + // Closing admission before draining these scopes prevents cached credentials surviving sign-out. + const withAccess: CursorAuth["withAccess"] = (task) => + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const parent = yield* Scope.Scope; + const child = yield* lock.withPermits(1)( + Effect.gen(function* () { + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "session", + detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.", + }); + const child = yield* Scope.make(); + sessions.add(child); + yield* Scope.addFinalizer( + child, + Effect.sync(() => { + sessions.delete(child); + }), + ); + yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); + return child; + }), + ); + const fiber = yield* restore(task).pipe( + Effect.provideService(Scope.Scope, child), + Effect.forkIn(child), + ); + return yield* restore(Fiber.await(fiber)).pipe( + Effect.flatMap((result) => result), + Effect.onExit((result) => + Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, + ), + ); + }), + ); + const stopSessionsWithCredentials = Effect.suspend(() => + Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { + discard: true, + concurrency: "unbounded", + }), + ); + + const runLogin = (flow: AuthFlow, stopSessions: Effect.Effect) => + Effect.gen(function* () { + yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials)); + // The SDK first writes into memory. Cancellation must not save a late login result. + const pendingStore = new InMemoryCredentialStore(); + const urls = yield* Queue.unbounded(); + yield* Queue.take(urls).pipe( + Effect.flatMap((authorizationUrl) => + lock.withPermits(1)( + Effect.gen(function* () { + if (active !== flow) return; + yield* publish(flow, { + phase: "waiting", + authorizationUrl, + message: + "Open the Cursor sign-in page and finish signing in. This page updates automatically.", + }); + }), + ), + ), + Effect.forever, + Effect.forkScoped, + ); + yield* Effect.tryPromise({ + try: (signal) => + (options.login ?? Cursor.auth.login)({ + openBrowser: false, + store: pendingStore, + signal, + apiKeyName: `T3 Code - ${options.displayName}`, + onLoginUrl: (authorizationUrl) => { + if (active === flow) Queue.offerUnsafe(urls, authorizationUrl); + }, + }), + catch: (cause) => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Cursor sign-in failed. Start sign-in again.", + cause, + }), + }); + // Commit and publish together; interrupting a store Promise cannot stop a late write. + yield* lock.withPermits(1)( + Effect.gen(function* () { + if (active !== flow) return; + yield* publish(flow, { + phase: "verifying", + authorizationUrl: null, + message: "Checking Cursor sign-in.", + }); + yield* Effect.tryPromise({ + try: async () => { + const credentials = await pendingStore.load(); + if (!credentials) throw new Error("Cursor login did not return credentials"); + await options.store.save(credentials); + }, + catch: (cause) => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Could not save the Cursor sign-in. Try again.", + cause, + }), + }); + const verified = yield* options.onChanged(true).pipe(Effect.exit); + if (Exit.isFailure(verified)) { + yield* Effect.tryPromise({ + try: () => options.store.clear(), + catch: (cause) => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Could not clear the rejected Cursor sign-in. Try signing out.", + cause, + }), + }); + return yield* Effect.failCause(verified.cause); + } + active = undefined; + operation = "idle"; + yield* publish(flow, { + phase: "succeeded", + authorizationUrl: null, + expiresAt: null, + message: "Signed in with Cursor.", + }); + }).pipe(Effect.uninterruptible), + ); + }).pipe( + Effect.scoped, + Effect.timeoutOrElse({ + duration: AUTH_TIMEOUT_MS, + orElse: () => + Effect.fail( + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Cursor sign-in expired. Start sign-in again.", + }), + ), + }), + Effect.exit, + Effect.flatMap((result) => + lock.withPermits(1)( + Effect.gen(function* () { + if (active !== flow) return; + active = undefined; + operation = "idle"; + yield* publish(flow, { + phase: Exit.isSuccess(result) ? "succeeded" : "failed", + authorizationUrl: null, + expiresAt: null, + message: Exit.isSuccess(result) + ? "Signed in with Cursor." + : "Cursor sign-in failed or expired. Start sign-in again.", + }); + }), + ), + ), + ); + + const requireFlow = (owner: string, id: string, name: string) => + Effect.gen(function* () { + if (!active || active.owner !== owner || active.id !== id) { + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: name, + detail: "This sign-in is no longer active in this client.", + }); + } + return active; + }); + + const controller: ProviderAuthController = { + credentialBinding: options.credentialBinding ?? { + owner: "t3", + key: `cursor:${options.instanceId}`, + }, + isChangingCredentials: Effect.sync(() => operation !== "idle"), + start: (owner, stopSessions = Effect.void) => + lock.withPermits(1)( + Effect.uninterruptible( + Effect.gen(function* () { + if (!options.enabled) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Enable Cursor before signing in.", + }); + if (configuredKey) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: + "Remove CURSOR_API_KEY from this provider's environment before using browser sign-in.", + }); + if (active?.owner === owner && operation === "login") return snapshot.value.state; + if (operation !== "idle") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Cursor setup is already in progress.", + }); + const id = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "start", + detail: "Could not start Cursor sign-in. Try again.", + cause, + }), + ), + ); + const expiresAt = DateTime.formatIso( + DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS), + ); + const flow: AuthFlow = { id, owner }; + active = flow; + operation = "login"; + const state: ProviderAuthState = { + ...emptyState, + phase: "starting", + flowId: id, + expiresAt, + message: "Starting Cursor sign-in.", + }; + yield* SubscriptionRef.set(snapshot, { owner, state }); + flow.fiber = yield* runLogin(flow, stopSessions).pipe( + Effect.interruptible, + Effect.forkIn(scope), + ); + return state; + }), + ), + ), + complete: () => + Effect.fail( + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "complete", + detail: "Finish signing in on the Cursor website. No redirect URL is needed.", + }), + ), + cancel: (owner, id) => + Effect.uninterruptible( + Effect.gen(function* () { + const flow = yield* lock.withPermits(1)( + Effect.gen(function* () { + const flow = yield* requireFlow(owner, id, "cancel"); + active = undefined; + operation = "stopping"; + yield* publish(flow, { + phase: "cancelled", + authorizationUrl: null, + expiresAt: null, + message: "Cursor sign-in was cancelled.", + }); + return flow; + }), + ); + if (flow.fiber) yield* Fiber.interrupt(flow.fiber); + operation = "idle"; + return snapshot.value.state; + }), + ), + logout: (stopSessions) => + Effect.uninterruptible( + Effect.gen(function* () { + const flow = yield* lock.withPermits(1)( + Effect.gen(function* () { + if (configuredKey) + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "logout", + detail: + "Remove CURSOR_API_KEY from this provider's environment to disconnect it.", + }); + if (operation !== "idle" && operation !== "login") + return yield* new ProviderSetupError({ + instanceId: options.instanceId, + operation: "logout", + detail: "Cursor setup is already stopping.", + }); + operation = "stopping"; + const flow = active; + active = undefined; + return flow; + }), + ); + const result = yield* Effect.gen(function* () { + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials)); + yield* Effect.tryPromise({ + try: () => options.store.clear(), + catch: (cause) => + new ProviderSetupError({ + instanceId: options.instanceId, + operation: "logout", + detail: "Could not clear the Cursor sign-in. Try again.", + cause, + }), + }); + yield* options.onChanged(false); + }).pipe(Effect.exit); + operation = "idle"; + const state: ProviderAuthState = { + ...emptyState, + phase: Exit.isSuccess(result) ? "idle" : "failed", + message: Exit.isSuccess(result) + ? "Signed out of Cursor." + : "Cursor sign-out failed. Try again.", + }; + yield* SubscriptionRef.set(snapshot, { owner: null, state }); + if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); + return state; + }), + ), + subscribe: (owner) => + SubscriptionRef.changes(snapshot).pipe( + Stream.map((current) => { + const state = { + ...current.state, + credentialOwner: "t3" as const, + methods: [ + { + id: "browser", + name: "Sign in with Cursor", + description: null, + type: "agent" as const, + }, + ], + interaction: + current.state.phase === "waiting" && + current.state.authorizationUrl && + current.state.flowId + ? { + type: "browser" as const, + id: current.state.flowId, + url: current.state.authorizationUrl, + requiresConsent: false, + } + : null, + }; + if (current.owner === null || current.owner === owner) return state; + return { + ...state, + interaction: null, + flowId: null, + authorizationUrl: null, + expiresAt: null, + message: active ? "Sign-in is in progress in another client." : current.state.message, + }; + }), + ), + isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", + }; + + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + operation = "closed"; + const flow = active; + active = undefined; + if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); + yield* stopSessionsWithCredentials; + }), + ); + + return { + controller, + readApiKey, + requireApiKey, + withAccess, + usesApiKey: configuredKey !== undefined, + } satisfies CursorAuth; +}); diff --git a/packages/provider-cursor/src/server/credentialStore.test.ts b/packages/provider-cursor/src/server/credentialStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5e176b661c46192360e489c1267e3beb7b931e74 --- /dev/null +++ b/packages/provider-cursor/src/server/credentialStore.test.ts @@ -0,0 +1,106 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Option from "effect/Option"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import * as Layer from "effect/Layer"; +import { makeCursorCredentialStore } from "./credentialStore.ts"; + +const layerHost = Layer.provideMerge(layerTestProviderHost(), NodeServices.layer); + +const legacyCredentials = { + version: 1 as const, + backendUrl: "https://api.cursor.com", + apiKey: "test-only-legacy-key", + createdAtMs: 100, +}; +// The SDK's FileCredentialStore writes pretty-printed JSON. +const legacyFileText = `{ + "version": 1, + "backendUrl": "https://api.cursor.com", + "apiKey": "test-only-legacy-key", + "createdAtMs": 100 +}`; + +it.effect.each([ + { + name: "imports a legacy sign-in", + legacy: legacyFileText, + stored: undefined, + expected: "test-only-legacy-key", + }, + { + name: "keeps a newer stored sign-in", + legacy: legacyFileText, + stored: legacyFileText.replace("test-only-legacy-key", "test-only-current-key"), + expected: "test-only-current-key", + }, + { + name: "replaces a damaged stored sign-in", + legacy: legacyFileText, + stored: "damaged", + expected: "test-only-legacy-key", + }, + { + name: "discards a damaged legacy file", + legacy: "damaged", + stored: undefined, + expected: undefined, + }, +])("$name and deletes the legacy file", ({ legacy, stored, expected }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const legacyFile = path.join(yield* fileSystem.makeTempDirectoryScoped(), "cursor.json"); + yield* fileSystem.writeFileString(legacyFile, legacy); + const host = yield* ProviderHost.ProviderHost; + const instanceId = ProviderInstanceId.make("personal"); + if (stored !== undefined) { + yield* (yield* host.credentials("cursor", instanceId)).set(new TextEncoder().encode(stored)); + } + const migrated = yield* makeCursorCredentialStore(instanceId, legacyFile); + assert.strictEqual((yield* Effect.tryPromise(() => migrated.store.load()))?.apiKey, expected); + assert.isFalse(yield* fileSystem.exists(legacyFile)); + }).pipe(Effect.scoped, Effect.provide(layerHost)), +); + +it.effect( + "restores SDK credentials in a new controller and keeps another account when signing out", + () => + Effect.gen(function* () { + const host = yield* ProviderHost.ProviderHost; + const makeStore = (id: string) => makeCursorCredentialStore(ProviderInstanceId.make(id)); + const personal = yield* makeStore("personal"); + const work = yield* makeStore("work"); + const credentials = { + version: 1 as const, + backendUrl: "https://api.cursor.com", + apiKey: "test-only-key", + createdAtMs: 100, + apiKeyExpiresAtMs: 1000, + email: "test@example.com", + }; + yield* Effect.tryPromise(() => personal.store.save(credentials)); + yield* Effect.tryPromise(() => + work.store.save({ ...credentials, apiKey: "test-only-work-key" }), + ); + const restored = yield* makeStore("personal"); + assert.deepEqual(yield* Effect.tryPromise(() => restored.store.load()), credentials); + yield* (yield* host.credentials("cursor", "personal")).set( + new TextEncoder().encode("damaged credential"), + ); + assert.isUndefined(yield* Effect.tryPromise(() => restored.store.load())); + yield* Effect.tryPromise(() => restored.store.save(credentials)); + yield* Effect.tryPromise(() => restored.store.clear()); + assert.isUndefined(yield* Effect.tryPromise(() => personal.store.load())); + assert.strictEqual( + (yield* Effect.tryPromise(() => work.store.load()))?.apiKey, + "test-only-work-key", + ); + }).pipe(Effect.provide(layerHost)), +); diff --git a/packages/provider-cursor/src/server/credentialStore.ts b/packages/provider-cursor/src/server/credentialStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..70277a54e216a6357f47b396db75e5c514cf43c4 --- /dev/null +++ b/packages/provider-cursor/src/server/credentialStore.ts @@ -0,0 +1,91 @@ +import type { SdkCredentialStore } from "@cursor/sdk"; +import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; + +const Credentials = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + backendUrl: Schema.String, + apiKey: Schema.String, + createdAtMs: Schema.Finite, + apiKeyExpiresAtMs: Schema.optionalKey(Schema.Finite), + email: Schema.optionalKey(Schema.String), + }), +); + +const decodeCredentials = Schema.decodeUnknownEffect(Credentials); +const encodeCredentials = Schema.encodeEffect(Credentials); + +/** + * The SDK owns the credential format; persistence uses the environment's secret store. + * `legacyFile` is the SDK file store earlier versions used; its sign-in moves into + * the secret store once and the file is deleted. + */ +export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")(function* ( + instanceId: ProviderInstanceId, + legacyFile?: string, +) { + const credentials = yield* (yield* ProviderHost.ProviderHost).credentials("cursor", instanceId); + if (legacyFile !== undefined) { + const fileSystem = yield* FileSystem.FileSystem; + yield* Effect.gen(function* () { + const legacy = yield* fileSystem.readFileString(legacyFile).pipe(Effect.option); + if (Option.isNone(legacy)) return; + // A stored sign-in wins unless it is damaged; the SDK would ignore it anyway. + const stored = yield* credentials.get; + const storedIsValid = + Option.isSome(stored) && + Option.isSome( + yield* decodeCredentials(new TextDecoder().decode(stored.value)).pipe(Effect.option), + ); + if ( + !storedIsValid && + Option.isSome(yield* decodeCredentials(legacy.value).pipe(Effect.option)) + ) { + yield* credentials.set(new TextEncoder().encode(legacy.value)); + } + yield* fileSystem.remove(legacyFile); + }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not migrate legacy Cursor credentials", error), + ), + ); + } + const store: SdkCredentialStore = { + load: () => + Effect.runPromise( + credentials.get.pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(undefined), + onSome: (bytes) => + decodeCredentials(new TextDecoder().decode(bytes)).pipe( + Effect.orElseSucceed(() => undefined), + ), + }), + ), + ), + ), + save: (value) => + Effect.runPromise( + encodeCredentials(value).pipe( + Effect.mapError( + () => + new ProviderSetupError({ + instanceId, + operation: "credentials", + detail: "Cursor returned an unsupported credential format.", + }), + ), + Effect.flatMap((encoded) => credentials.set(new TextEncoder().encode(encoded))), + ), + ), + clear: () => Effect.runPromise(credentials.remove), + }; + return { store, binding: credentials.binding }; +}); diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7320ce8fa984f6f57d8d5928996fa7f3413a2d4 --- /dev/null +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -0,0 +1,169 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { vi } from "vite-plus/test"; +import { HttpClient } from "effect/http"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; + +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import { CursorDriver } from "./driver.ts"; +import * as CursorAgentSdk from "./CursorAgentSdk.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import { Cursor } from "./sdk.ts"; + +const layerTest = layerTestProviderHost({ runBackgroundWork: false }).pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provideMerge(IdAllocator.layer), + Layer.provideMerge( + Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({ + open: () => Effect.die("Maintenance resolution must not open a Cursor session"), + }), + ), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Disabled Cursor must not make an HTTP request")), + ), + ), +); + +it.layer(layerTest)("CursorDriver", (it) => { + it.effect( + "persists browser credentials, uses them for chat, and closes the SDK session on logout", + () => + Effect.gen(function* () { + const login = vi.spyOn(Cursor.auth, "login").mockImplementation(async (options) => { + options?.onLoginUrl?.("https://cursor.com/loginDeepControl?challenge=test-only"); + await options?.store?.save({ + version: 1, + backendUrl: "https://api2.cursor.sh", + apiKey: "instance-browser-key", + createdAtMs: 0, + apiKeyExpiresAtMs: 4_000_000_000_000, + email: "cursor@example.com", + }); + return { apiKey: "instance-browser-key", apiKeyExpiresAtMs: 4_000_000_000_000 }; + }); + const me = vi.spyOn(Cursor, "me").mockResolvedValue({ + apiKeyName: "T3 Code", + createdAt: "2026-01-01T00:00:00.000Z", + userEmail: "cursor@example.com", + }); + const models = vi + .spyOn(Cursor.models, "list") + .mockResolvedValue([{ id: "auto", displayName: "Auto" }]); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + login.mockRestore(); + me.mockRestore(); + models.mockRestore(); + }), + ); + const input = { + instanceId: ProviderInstanceId.make("cursor-browser-persisted"), + displayName: "Personal Cursor", + enabled: true, + environment: [{ name: "CURSOR_API_KEY", value: "", sensitive: true }], + config: CursorDriver.defaultConfig(), + }; + const openedKeys: Array = []; + let closed = 0; + const instance = yield* CursorDriver.create(input).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + assertComplete: Effect.void, + open: (request) => + Effect.sync(() => { + openedKeys.push(request.options.apiKey); + return { + agentId: "browser-auth-agent", + listMessages: Effect.succeed([]), + send: () => Effect.die("This test only opens a session"), + close: Effect.sync(() => { + closed += 1; + }), + }; + }), + }), + ); + expect((yield* instance.snapshot.refresh).auth.status).toBe("unauthenticated"); + yield* instance.auth!.start("client"); + yield* instance.auth!.subscribe("client").pipe( + Stream.filter((state) => state.phase === "succeeded"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + expect(me).toHaveBeenCalledWith({ apiKey: "instance-browser-key" }); + expect(yield* instance.snapshot.getSnapshot).toMatchObject({ + status: "ready", + auth: { + status: "authenticated", + type: "browser", + canLogout: true, + email: "cursor@example.com", + }, + setup: { canAuthenticate: true, canInstall: false }, + }); + const threadId = ThreadId.make("cursor-browser-thread"); + const modelSelection = { instanceId: input.instanceId, model: "auto" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* instance.orchestrationAdapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-browser-session"), + modelSelection, + runtimePolicy, + }); + yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); + expect(openedKeys).toEqual(["instance-browser-key"]); + expect(closed).toBe(0); + const recreated = yield* CursorDriver.create(input); + expect((yield* recreated.snapshot.refresh).auth.status).toBe("authenticated"); + yield* instance.auth!.logout(Effect.void); + expect(closed).toBe(1); + expect((yield* instance.snapshot.getSnapshot).auth.status).toBe("unauthenticated"); + expect((yield* recreated.snapshot.refresh).auth.status).toBe("unauthenticated"); + expect( + (yield* Effect.exit(runtime.ensureThread({ threadId, modelSelection, runtimePolicy }))) + ._tag, + ).toBe("Failure"); + expect(openedKeys).toEqual(["instance-browser-key"]); + expect(closed).toBe(1); + }).pipe(Effect.scoped), + ); + + it.effect("keeps the bundled SDK manual-only without probing or updating cursor-agent", () => + Effect.gen(function* () { + const instance = yield* CursorDriver.create({ + instanceId: ProviderInstanceId.make("cursor-sdk"), + displayName: "Cursor test", + enabled: false, + environment: [], + config: CursorDriver.defaultConfig(), + }); + expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); + expect((yield* instance.snapshot.refresh).status).toBe("disabled"); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("SDK maintenance must not spawn a process")), + ), + Effect.scoped, + ), + ); +}); diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts new file mode 100644 index 0000000000000000000000000000000000000000..157b7dca5c1e12e0ad953b7bb29b33f8986f45e1 --- /dev/null +++ b/packages/provider-cursor/src/server/driver.ts @@ -0,0 +1,279 @@ +/** + * CursorDriver — `ProviderDriver` for the Cursor Agent SDK runtime. + * + * Provider status, model discovery, orchestration, and text generation use the + * official Cursor SDK with an instance browser login or CURSOR_API_KEY. + * + * @module provider/Drivers/CursorDriver + */ +import { ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; +import { CursorSettings } from "../settings.ts"; +import * as Effect from "effect/Effect"; +import * as Crypto from "effect/Crypto"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/http"; +import { readCursorUsageLimits } from "./usageLimits.ts"; + +import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { makeCursorTextGeneration } from "./textGeneration.ts"; +import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./adapter.ts"; +import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; +import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus } from "./status.ts"; +import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "@t3tools/provider-core/server/driver"; +import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "@t3tools/provider-core/server/snapshotSettings"; +import { probeCursorSkills } from "./skills.ts"; +import { makeCursorAuth } from "./auth.ts"; +import * as CursorCredentialStore from "./credentialStore.ts"; +import * as CursorAgentSdk from "./CursorAgentSdk.ts"; +const decodeCursorSettings = Schema.decodeSync(CursorSettings); +const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError); + +const DRIVER_KIND = ProviderDriverKind.make("cursor"); +const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +export type CursorDriverEnv = + | CursorAdapterV2DriverEnv + | Crypto.Crypto + | FileSystem.FileSystem + | Path.Path + | HttpClient.HttpClient + | ProviderHost.ProviderHost; + +export const CursorDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Cursor", + supportsMultipleInstances: true, + }, + configSchema: CursorSettings, + defaultConfig: (): CursorSettings => decodeCursorSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const host = yield* ProviderHost.ProviderHost; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + driverKind: DRIVER_KIND, + displayName: displayName ?? "Cursor", + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies CursorSettings; + const credentials = yield* CursorCredentialStore.makeCursorCredentialStore( + instanceId, + path.join( + host.paths.stateDir, + "provider-auth", + encodeURIComponent(instanceId), + "cursor.json", + ), + ).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Could not open the Cursor credential store.", + cause, + }), + ), + ); + const auth = yield* makeCursorAuth({ + instanceId, + displayName: displayName ?? "Cursor", + enabled, + ...(processEnv.CURSOR_API_KEY ? { apiKey: processEnv.CURSOR_API_KEY } : {}), + store: credentials.store, + credentialBinding: credentials.binding, + onChanged: (signedIn): Effect.Effect => + snapshot.refresh.pipe( + Effect.flatMap((provider) => + !signedIn || provider.auth.status === "authenticated" + ? Effect.void + : Effect.fail( + new ProviderSetupError({ + instanceId, + operation: "start", + detail: provider.message ?? "Could not verify the Cursor sign-in. Try again.", + }), + ), + ), + ), + }); + const stampSnapshot: typeof stampIdentity = (draft) => + stampIdentity({ + ...draft, + setup: { canAuthenticate: !auth.usesApiKey, canInstall: false }, + auth: { + ...draft.auth, + canLogout: !auth.usesApiKey, + }, + }); + + const orchestrationAdapter = yield* CursorAdapterV2Driver.create({ + instanceId, + displayName, + accentColor, + environment, + enabled, + config, + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + ...sdkRunner, + open: (input) => + auth.requireApiKey.pipe( + Effect.flatMap((apiKey) => + Effect.acquireRelease( + sdkRunner + .open({ ...input, options: { ...input.options, apiKey } }) + .pipe( + Effect.flatMap((session) => + Effect.cached(session.close).pipe( + Effect.map((close) => ({ ...session, close })), + ), + ), + ), + (session) => session.close.pipe(Effect.ignore), + ), + ), + auth.withAccess, + Effect.mapError((cause) => + isSdkRunnerError(cause) + ? cause + : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }), + ), + ), + }), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: "Failed to build Cursor orchestration adapter.", + cause, + }), + ), + ); + const textGeneration = yield* makeCursorTextGeneration( + effectiveConfig, + processEnv, + auth.requireApiKey, + auth.withAccess, + ); + + const checkProvider = auth.readApiKey.pipe( + Effect.orElseSucceed(() => undefined), + Effect.flatMap((apiKey) => + checkCursorProviderStatus( + effectiveConfig, + { + ...processEnv, + CURSOR_API_KEY: apiKey, + }, + auth.usesApiKey ? "api-key" : "browser", + ).pipe( + Effect.flatMap((snapshot) => + effectiveConfig.enabled && + snapshot.installed && + snapshot.auth.status === "authenticated" + ? host.settings.get.pipe( + Effect.flatMap((settings) => + readCursorUsageLimits( + effectiveConfig, + { ...processEnv, CURSOR_API_KEY: apiKey }, + settings.cursorKeychainUsageEnabled, + ), + ), + Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), + ) + : Effect.succeed(snapshot), + ), + ), + ), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.map(stampSnapshot), + Effect.provide(CursorSdkCatalog.layer), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); + const snapshot = yield* makeManagedServerProvider>({ + resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + buildInitialCursorProviderSnapshot(settings.provider).pipe(Effect.map(stampSnapshot)), + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + auth: auth.controller, + snapshot, + snapshotForCwd: (cwd) => + !effectiveConfig.enabled + ? snapshot.getSnapshot + : Effect.all([ + snapshot.getSnapshot, + probeCursorSkills(cwd, processEnv).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to discover Cursor skills for '${cwd}'`, + cause, + }), + ), + ), + ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), + orchestrationAdapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/packages/provider-cursor/src/server/keychainToken.test.ts b/packages/provider-cursor/src/server/keychainToken.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f279bb6089243954c98c8c10c42dd250e482686 --- /dev/null +++ b/packages/provider-cursor/src/server/keychainToken.test.ts @@ -0,0 +1,43 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { CursorKeychainTimeoutError, makeCachedCursorAccessTokenReader } from "./keychainToken.ts"; + +describe("Cursor Keychain reader", () => { + it("shares concurrent reads and rechecks after the cache expires", async () => { + let reads = 0; + let time = 0; + const read = makeCachedCursorAccessTokenReader( + async () => { + reads++; + return `token-${reads}`; + }, + () => time, + ); + assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]); + assert.strictEqual(await read(), "token-1"); + assert.strictEqual(reads, 1); + time = 5 * 60_000; + assert.strictEqual(await read(), "token-2"); + }); + + it("gives up on an unanswered prompt and reuses it on the next read", async () => { + let reads = 0; + let allow: (token: string) => void = () => {}; + const read = makeCachedCursorAccessTokenReader( + () => { + reads++; + return new Promise((resolve) => { + allow = resolve; + }); + }, + () => 0, + 1, + ); + const error = await read().catch((cause: unknown) => cause); + assert.instanceOf(error, CursorKeychainTimeoutError); + const retry = read(); + allow("token"); + assert.strictEqual(await retry, "token"); + assert.strictEqual(reads, 1); + }); +}); diff --git a/packages/provider-cursor/src/server/keychainToken.ts b/packages/provider-cursor/src/server/keychainToken.ts new file mode 100644 index 0000000000000000000000000000000000000000..2857683e4821c4c608632dbbb53591e1d1966e87 --- /dev/null +++ b/packages/provider-cursor/src/server/keychainToken.ts @@ -0,0 +1,55 @@ +import * as NodeModule from "node:module"; + +const CACHE_MS = 5 * 60_000; + +const requireForKeyring = NodeModule.createRequire(import.meta.url); + +/** Rejected when nobody answers the macOS Keychain prompt in time. */ +export class CursorKeychainTimeoutError extends Error { + constructor() { + super("Timed out waiting for Keychain access."); + } +} + +/** + * Share one Keychain request across usage history and limits in this server process. + * + * macOS shows the access prompt on the server's own screen, which a remote + * client cannot answer, so callers give up after `timeoutMs`. The read stays in + * flight: the next call reuses it instead of stacking a second prompt, and picks + * up the token once someone allows access. + */ +export function makeCachedCursorAccessTokenReader( + read: () => Promise, + now: () => number = Date.now, + timeoutMs = 30_000, +): () => Promise { + let cached: { token: string; until: number } | null = null; + let pending: Promise | null = null; + return () => { + if (cached && cached.until > now()) return Promise.resolve(cached.token); + pending ??= read() + .then((token) => { + cached = token ? { token, until: now() + CACHE_MS } : null; + return token; + }) + .finally(() => { + pending = null; + }); + const deadline = AbortSignal.timeout(timeoutMs); + return Promise.race([ + pending, + new Promise((_, reject) => { + deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), { + once: true, + }); + }), + ]); + }; +} + +/** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ +export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { + const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); + return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; +}); diff --git a/packages/provider-cursor/src/server/sdk.test.ts b/packages/provider-cursor/src/server/sdk.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..06a9b6229fc5e415431c2f2155deabcaf34db556 --- /dev/null +++ b/packages/provider-cursor/src/server/sdk.test.ts @@ -0,0 +1,193 @@ +// @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- The probe is a plain Node child; this deadline kills it if it hangs. +import * as NodeChildProcess from "node:child_process"; +import { describe, expect, it } from "vite-plus/test"; + +const cursorSdkUrl = new URL("./sdk.ts", import.meta.url).href; + +// Loads sdk.ts in a fresh process. @cursor/sdk is stubbed so the guard +// can be tested without the real package. The vitest worker already has its +// own unhandledRejection listener, which would hide the process-exit behavior. +const probeProgram = ` +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { registerHooks } from "node:module"; +import { pathToFileURL } from "node:url"; +import { spawn } from "node:child_process"; + +const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-")); +const stub = join(dir, "stub.cjs"); +writeFileSync( + stub, + "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };", +); +registerHooks({ + resolve(specifier, context, next) { + if (specifier === "@cursor/sdk") { + return { url: pathToFileURL(stub).href, shortCircuit: true }; + } + return next(specifier, context); + }, +}); + +const { isCursorShellSpawnFailure } = await import(${JSON.stringify(cursorSdkUrl)}); +const mode = process.argv[1]; +const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid); + +if (mode === "predicate") { + const cursorShell = Object.assign(new Error("spawn /bin/zsh ENOENT"), { + code: "ENOENT", + syscall: "spawn /bin/zsh", + path: "/bin/zsh", + spawnargs: ["-c", "dump_zsh_state >&4", "--", "true"], + }); + const bashShell = Object.assign(new Error("spawn bash ENOENT"), { + code: "ENOENT", + syscall: "spawn bash", + spawnargs: ["-c", "dump_bash_state >&4"], + }); + const sandboxRestore = Object.assign(new Error("spawn /bin/zsh ENOENT"), { + code: "ENOENT", + syscall: "spawn /bin/zsh", + spawnargs: ["-c", "builtin eval \\"\${__CURSOR_SANDBOX_ENV_RESTORE:-}\\""], + }); + const gitSpawn = Object.assign(new Error("spawn git ENOENT"), { + code: "ENOENT", + syscall: "spawn git", + path: "git", + spawnargs: ["status"], + }); + const plainZsh = Object.assign(new Error("spawn /bin/zsh ENOENT"), { + code: "ENOENT", + syscall: "spawn /bin/zsh", + path: "/bin/zsh", + spawnargs: ["-lc", "true"], + }); + console.log( + JSON.stringify({ + cursorShell: isCursorShellSpawnFailure(cursorShell), + bashShell: isCursorShellSpawnFailure(bashShell), + sandboxRestore: isCursorShellSpawnFailure(sandboxRestore), + gitSpawn: isCursorShellSpawnFailure(gitSpawn), + plainZsh: isCursorShellSpawnFailure(plainZsh), + open: isCursorShellSpawnFailure( + Object.assign(new Error("open failed"), { code: "ENOENT", syscall: "open" }), + ), + plain: isCursorShellSpawnFailure(new Error("boom")), + string: isCursorShellSpawnFailure("spawn ENOENT"), + }), + ); + process.exit(0); +} + +if (mode === "cursor-shell") { + const child = spawn("/bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"], { + cwd: missingCwd, + }); + child.on("error", (error) => { + Promise.reject(error); + }); + setTimeout(() => process.exit(0), 500); +} else if (mode === "other-spawn") { + Promise.reject( + Object.assign(new Error("spawn git ENOENT"), { + code: "ENOENT", + syscall: "spawn git", + path: "git", + spawnargs: ["status"], + }), + ); + setTimeout(() => process.exit(0), 500); +} else if (mode === "other-rejection") { + Promise.reject(new Error("boom")); + setTimeout(() => process.exit(0), 500); +} else if (mode === "spawn-without-listener") { + spawn(process.execPath, ["-e", "process.exit(0)"], { cwd: missingCwd }); + setTimeout(() => process.exit(0), 500); +} else { + console.error("Unknown cursor shell spawn guard probe: " + mode); + process.exit(2); +} +`; + +function runProbe(mode: string): Promise<{ code: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn( + process.execPath, + [ + "--experimental-strip-types", + "--disable-warning=ExperimentalWarning", + "--input-type=module", + "-e", + probeProgram, + mode, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`probe ${mode} timed out`)); + }, 5_000); + child.on("error", reject); + child.on("exit", (code) => { + clearTimeout(timer); + resolve({ code, stdout, stderr }); + }); + }); +} + +describe("isCursorShellSpawnFailure", () => { + it("matches only Cursor's shell wrapper", async () => { + const result = await runProbe("predicate"); + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + cursorShell: true, + bashShell: true, + sandboxRestore: true, + gitSpawn: false, + plainZsh: false, + open: false, + plain: false, + string: false, + }); + }); +}); + +describe("Cursor shell spawn guard", () => { + it("keeps the process alive when Cursor's shell spawn rejects", async () => { + const result = await runProbe("cursor-shell"); + expect(result.code).toBe(0); + expect(result.stderr).toContain("The server will keep running."); + expect(result.stderr).toContain("ENOENT"); + }); + + it("still exits when a different spawn failure is unhandled", async () => { + const result = await runProbe("other-spawn"); + expect(result.code).toBe(1); + expect(result.stderr).toContain("spawn git ENOENT"); + expect(result.stderr).not.toContain("The server will keep running."); + }); + + it("still exits on unrelated unhandled rejections", async () => { + const result = await runProbe("other-rejection"); + expect(result.code).toBe(1); + expect(result.stderr).toContain("boom"); + }); + + it("leaves a spawn with no error listener fatal", async () => { + const result = await runProbe("spawn-without-listener"); + expect(result.code).toBe(1); + expect(result.stderr).toContain("Unhandled"); + expect(result.stderr).not.toContain("The server will keep running."); + }); +}); diff --git a/packages/provider-cursor/src/server/sdk.ts b/packages/provider-cursor/src/server/sdk.ts new file mode 100644 index 0000000000000000000000000000000000000000..07381bcf3a6f3ca959322f9c66d6654ef745ad53 --- /dev/null +++ b/packages/provider-cursor/src/server/sdk.ts @@ -0,0 +1,77 @@ +// @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- Installed before the SDK loads, outside an Effect runtime. stderr must match Node's default unhandled-rejection print. +import * as NodeModule from "node:module"; + +/** + * The Cursor Agent SDK runs in this process and spawns a shell for tool + * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`, + * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory + * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error, + * then rejects an internal promise nothing awaits. Node exits on that + * unhandled rejection and takes the server with it. + * + * This listener ignores only that rejection. Every other unhandled rejection + * still prints and exits when this is the process's only listener, matching + * Node's default. `child_process.spawn` itself is left alone, so git, + * terminals, and other providers keep their own error handling. + */ +const CURSOR_SHELL_SPAWN_MARKERS = [ + "dump_zsh_state", + "dump_bash_state", + "__CURSOR_SANDBOX_ENV_RESTORE", +] as const; + +export function isCursorShellSpawnFailure(reason: unknown): boolean { + if (typeof reason !== "object" || reason === null) { + return false; + } + const syscall = Reflect.get(reason, "syscall"); + const code = Reflect.get(reason, "code"); + const spawnargs = Reflect.get(reason, "spawnargs"); + if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") { + return false; + } + if (!Array.isArray(spawnargs)) { + return false; + } + return spawnargs.some( + (arg) => + typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)), + ); +} + +let cursorShellSpawnGuardInstalled = false; + +function installCursorShellSpawnGuard(): void { + if (cursorShellSpawnGuardInstalled) { + return; + } + cursorShellSpawnGuardInstalled = true; + + process.on("unhandledRejection", (reason) => { + if (isCursorShellSpawnFailure(reason)) { + console.error("Cursor shell spawn failed. The server will keep running.", reason); + return; + } + // Another handler registered besides this one owns the decision. + if (process.listenerCount("unhandledRejection") > 1) { + return; + } + console.error(reason); + process.exit(1); + }); +} + +// Arm before the SDK loads. Its shell tools spawn from this process. +installCursorShellSpawnGuard(); + +// Cursor's Webpack chunks and local helpers must stay beside the SDK entry. +// createRequire also loads that disk-backed package from a Node SEA executable. +const requireCursorSdk = NodeModule.createRequire(import.meta.url); +export const { + Agent, + AuthenticationError, + createAgentPlatform, + Cursor, + CursorSdkError, + InMemoryCredentialStore, +} = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk"); diff --git a/packages/provider-cursor/src/server/sdkModel.ts b/packages/provider-cursor/src/server/sdkModel.ts new file mode 100644 index 0000000000000000000000000000000000000000..3332cdbf86714b8439e73fb052193340ae742891 --- /dev/null +++ b/packages/provider-cursor/src/server/sdkModel.ts @@ -0,0 +1,50 @@ +import type { ModelSelection as CursorSdkModelSelection, ModelParameterValue } from "@cursor/sdk"; +import type { ModelSelection } from "@t3tools/contracts"; + +const CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION: Readonly> = { + context: "contextWindow", + fast: "fastMode", +}; + +const PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER: Readonly> = { + contextWindow: "context", + fastMode: "fast", +}; + +export function cursorSdkProviderOptionId(parameterId: string): string { + return CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION[parameterId] ?? parameterId; +} + +function cursorSdkParameterId(providerOptionId: string): string { + return PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER[providerOptionId] ?? providerOptionId; +} + +export function cursorSdkParameterPriority(parameterId: string): number { + switch (parameterId) { + case "effort": + case "reasoning": + return 0; + case "context": + return 1; + case "fast": + return 2; + case "thinking": + return 3; + default: + return 4; + } +} + +export function cursorSdkModelSelection(modelSelection: ModelSelection): CursorSdkModelSelection { + return { + id: modelSelection.model === "auto" ? "default" : modelSelection.model, + ...(modelSelection.options === undefined || modelSelection.options.length === 0 + ? {} + : { + params: modelSelection.options.map((option): ModelParameterValue => ({ + id: cursorSdkParameterId(option.id), + value: String(option.value), + })), + }), + }; +} diff --git a/packages/provider-cursor/src/server/skills.test.ts b/packages/provider-cursor/src/server/skills.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7831e5909e0f8725ef1dc57dd39e99fdbef4c133 --- /dev/null +++ b/packages/provider-cursor/src/server/skills.test.ts @@ -0,0 +1,186 @@ +import * as NodeOS from "node:os"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "vite-plus/test"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as Scope from "effect/Scope"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; + +import { + discoverCursorSkills, + hasCursorSkillMention, + probeCursorSkills, + rewriteCursorSkillMentions, +} from "./skills.ts"; + +const runNode = ( + effect: Effect.Effect, +) => effect.pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise); + +describe("Cursor skills", () => { + it("discovers recursive project skills with project precedence", async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-home-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const workspace = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-workspace-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const writeSkill = Effect.fn("writeCursorSkill")(function* ( + root: string, + name: string, + contents: string, + ) { + const skillDirectory = path.join(root, name); + yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents); + }); + + yield* writeSkill( + path.join(userHome, ".cursor", "skills"), + "review", + "---\ndescription: user review\n---\n", + ); + yield* writeSkill( + path.join(workspace, ".agents", "skills", "nested"), + "review", + "---\nname: Review changes\ndescription: project review\n---\n", + ); + yield* writeSkill( + path.join(workspace, ".cursor", "skills"), + "internal", + "---\nuser-invocable: false\n---\n", + ); + yield* writeSkill( + path.join(workspace, ".cursor", "skills"), + "oversized", + "x".repeat(1_000_001), + ); + yield* fileSystem.makeDirectory(path.join(userHome, ".codex"), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(userHome, ".codex", "skills"), + "not a directory", + ); + + const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); + expect(skills).toEqual([ + { + name: "internal", + path: path.join(workspace, ".cursor", "skills", "internal", "SKILL.md"), + scope: "project", + enabled: true, + userInvocable: false, + }, + { + name: "oversized", + path: path.join(workspace, ".cursor", "skills", "oversized", "SKILL.md"), + scope: "project", + enabled: true, + }, + { + name: "review", + displayName: "Review changes", + description: "project review", + path: path.join(workspace, ".agents", "skills", "nested", "review", "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + expect( + (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, + ).toBe("Failure"); + }), + )); + + it.skipIf(!symlinksSupported)( + "treats a symlinked skill outside the root as a package boundary", + async () => + await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const userHome = yield* fileSystem.makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-home-", + }); + const workspace = yield* fileSystem + .makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-workspace-", + }) + .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); + const library = yield* fileSystem.makeTempDirectoryScoped({ + directory: NodeOS.tmpdir(), + prefix: "cursor-skills-library-", + }); + const writeSkill = Effect.fn("writeCursorSkill")(function* ( + directory: string, + contents: string, + ) { + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents); + }); + + // A skill package managed in a config repo and installed by symlink. + // Its own SKILL.md must be discovered under the link name, but nothing + // below the target may be walked. + yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n"); + yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n"); + const root = path.join(workspace, ".cursor", "skills"); + yield* fileSystem.makeDirectory(root, { recursive: true }); + yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review")); + + const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); + expect(skills).toEqual([ + { + name: "review", + description: "shared", + path: path.join(root, "review", "SKILL.md"), + scope: "project", + enabled: true, + }, + ]); + expect( + (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, + ).toBe("Success"); + }), + ), + ); + + it("rewrites only discovered skill mentions into Cursor slash invocations", () => { + expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true); + expect(hasCursorSkillMention("please $review this")).toBe(true); + expect( + rewriteCursorSkillMentions("use $review, keep $HOME and 5$review", new Set(["review"])), + ).toBe("use $review, keep $HOME and 5$review"); + expect(rewriteCursorSkillMentions("please $review this", new Set(["review"]))).toBe( + "please /review this", + ); + }); + it("detects and invokes digit-leading Cursor skills without rewriting money", () => { + const names = new Set(["2spec", "20k", "100M", "1e6"]); + expect(hasCursorSkillMention("use $2spec here")).toBe(true); + expect(hasCursorSkillMention("use $2spec here")).toBe(true); + expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here"); + expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here"); + for (const text of [ + "pay $20 tomorrow", + "budget $20k here", + "cost $100M total", + "limit $1e6 here", + ]) { + expect(hasCursorSkillMention(text)).toBe(false); + expect(rewriteCursorSkillMentions(text, names)).toBe(text); + } + }); +}); diff --git a/packages/provider-cursor/src/server/skills.ts b/packages/provider-cursor/src/server/skills.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8cd561dfea0970285bd1457961a378c8728f122 --- /dev/null +++ b/packages/provider-cursor/src/server/skills.ts @@ -0,0 +1,290 @@ +/** + * CursorSkills — workspace-aware discovery and native invocation for Cursor. + * + * Cursor discovers Agent Skills recursively from user and project roots but + * its ACP command catalog only appears after opening a real session. Scanning + * the same roots avoids starting an agent and its MCP servers just to populate + * a composer menu. + * + * @module provider/Drivers/CursorSkills + */ +import * as NodeOS from "node:os"; + +import type { ServerProviderSkill } from "@t3tools/contracts"; +import * as ByteSize from "effect/ByteSize"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import { parse as parseYamlDocument } from "yaml"; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const SKILL_MENTION_PATTERN = + /(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu; +const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u"); +const MAX_SKILL_DEPTH = 10; +const MAX_SKILL_BYTES = ByteSize.bytes(1_000_000); +const MAX_SKILL_SCAN_ENTRIES = 10_000; +const MAX_SKILL_SCAN_BYTES = ByteSize.bytes(8_000_000); + +interface CursorSkillFrontmatter { + readonly description?: string; + readonly displayName?: string; + readonly userInvocationOnly?: boolean; + readonly userInvocable?: boolean; + readonly cliVisible: boolean; +} + +interface CursorSkillScanBudget { + remainingEntries: number; + remainingBytes: bigint; + exhausted: boolean; + incomplete: boolean; +} + +class CursorSkillsProbeError extends Schema.TaggedError()( + "CursorSkillsProbeError", + { + reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]), + cwd: Schema.optional(Schema.String), + }, +) { + override get message(): string { + const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; + return `Cursor skill discovery${location} was incomplete (${this.reason}).`; + } +} + +const orUndefined = ( + effect: Effect.Effect, + budget?: CursorSkillScanBudget, +): Effect.Effect => + effect.pipe( + Effect.map((value): A | undefined => value), + Effect.catchTags({ + PlatformError: (error) => { + if (error.reason._tag !== "NotFound" && budget) budget.incomplete = true; + return Effect.void.pipe(Effect.as(undefined)); + }, + }), + ); + +function parseFrontmatterBoolean(value: unknown): boolean | undefined { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined; + if (typeof value !== "string") return undefined; + switch (value.trim().toLowerCase()) { + case "true": + case "yes": + case "on": + return true; + case "false": + case "no": + case "off": + return false; + default: + return undefined; + } +} + +function parseSkillFrontmatter(contents: string): CursorSkillFrontmatter | undefined { + const match = FRONTMATTER_PATTERN.exec(contents); + if (!match) return { cliVisible: true }; + + let parsed: unknown; + try { + parsed = parseYamlDocument(match[1] ?? ""); + } catch { + return undefined; + } + if (typeof parsed !== "object" || parsed === null) return undefined; + + const record = parsed as Record; + const metadata = + typeof record.metadata === "object" && record.metadata !== null + ? (record.metadata as Record) + : undefined; + const rawSurfaces = metadata?.surfaces; + const surfaces = Array.isArray(rawSurfaces) + ? rawSurfaces.filter((surface): surface is string => typeof surface === "string") + : typeof rawSurfaces === "string" + ? rawSurfaces.split(",") + : []; + const description = typeof record.description === "string" ? record.description.trim() : ""; + const displayName = typeof record.name === "string" ? record.name.trim() : ""; + return { + cliVisible: + surfaces.length === 0 || surfaces.some((surface) => surface.trim().toLowerCase() === "cli"), + ...(description ? { description } : {}), + ...(displayName ? { displayName } : {}), + ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true + ? { userInvocationOnly: true } + : {}), + ...(parseFrontmatterBoolean(record["user-invocable"]) === false + ? { userInvocable: false } + : {}), + }; +} + +const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (input: { + readonly directory: string; + readonly scope: "user" | "project"; + readonly budget: CursorSkillScanBudget; +}): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const skills: ServerProviderSkill[] = []; + if (input.budget.exhausted) return skills; + const rootDirectory = yield* orUndefined(fileSystem.realPath(input.directory), input.budget); + if (!rootDirectory) return skills; + const visitedDirectories = new Set(); + + const visit = Effect.fn("visitCursorSkillDirectory")(function* ( + directory: string, + depth: number, + ): Effect.fn.Return { + if (input.budget.exhausted) return; + const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget); + if (!resolvedDirectory) { + return; + } + if (visitedDirectories.has(resolvedDirectory)) { + return; + } + visitedDirectories.add(resolvedDirectory); + // A symlink whose target lives outside the root is a skill package + // boundary: read its own SKILL.md so linked skill libraries show up, but + // never walk the target tree. + const insideRoot = + resolvedDirectory === rootDirectory || + resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`); + + const skillPath = path.join(directory, "SKILL.md"); + const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); + if (skillInfo?.type === "File") { + let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true }; + if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) { + const contents = yield* orUndefined(fileSystem.readFileString(skillPath)); + if (contents !== undefined) { + input.budget.remainingBytes -= skillInfo.size; + frontmatter = parseSkillFrontmatter(contents); + } + } + const name = path.basename(directory).trim(); + if (frontmatter?.cliVisible && name) { + skills.push({ + name, + path: skillPath, + scope: input.scope, + enabled: true, + ...(frontmatter.displayName && frontmatter.displayName !== name + ? { displayName: frontmatter.displayName } + : {}), + ...(frontmatter.description ? { description: frontmatter.description } : {}), + ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}), + ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}), + }); + } + } + + if (!insideRoot) { + return; + } + const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget); + if (!entries) { + return; + } + for (const entry of [...entries].sort()) { + if (input.budget.remainingEntries === 0) { + input.budget.exhausted = true; + return; + } + input.budget.remainingEntries -= 1; + const child = path.join(directory, entry); + const info = yield* orUndefined(fileSystem.stat(child), input.budget); + if (info?.type !== "Directory") continue; + if (depth >= MAX_SKILL_DEPTH) { + input.budget.exhausted = true; + return; + } + yield* visit(child, depth + 1); + } + }); + + yield* visit(rootDirectory, 0); + return skills; +}); + +const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +) { + const path = yield* Path.Path; + const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); + const rootsBelow = (base: string, scope: "user" | "project") => [ + { directory: path.join(base, ".cursor", "skills"), scope }, + { directory: path.join(base, ".agents", "skills"), scope }, + { directory: path.join(base, ".codex", "skills"), scope }, + { directory: path.join(base, ".claude", "skills"), scope }, + ]; + const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; + + const skillsByName = new Map(); + const budget: CursorSkillScanBudget = { + remainingEntries: MAX_SKILL_SCAN_ENTRIES, + remainingBytes: MAX_SKILL_SCAN_BYTES, + exhausted: false, + incomplete: false, + }; + for (const root of roots) { + if (budget.exhausted) break; + const skills = yield* discoverSkillsInRoot({ ...root, budget }); + for (const skill of skills) { + if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill); + } + } + return { + skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)), + failureReason: budget.exhausted + ? ("scan-budget-exhausted" as const) + : budget.incomplete + ? ("filesystem-error" as const) + : undefined, + }; +}); + +export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +) { + return (yield* inspectCursorSkills(cwd, environment)).skills; +}); + +export const probeCursorSkills = Effect.fn("probeCursorSkills")(function* ( + cwd?: string, + environment: NodeJS.ProcessEnv = process.env, +) { + const inspection = yield* inspectCursorSkills(cwd, environment); + if (inspection.failureReason) { + return yield* new CursorSkillsProbeError({ + reason: inspection.failureReason, + ...(cwd ? { cwd } : {}), + }); + } + return inspection.skills; +}); + +/** Cursor invokes Agent Skills with `/name`; T3 composers insert `$name`. */ +export function hasCursorSkillMention(prompt: string): boolean { + return HAS_SKILL_MENTION_PATTERN.test(prompt); +} + +export function rewriteCursorSkillMentions( + prompt: string, + skillNames: ReadonlySet, +): string { + return prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) => + skillNames.has(name) ? `${prefix}/${name}` : match, + ); +} diff --git a/packages/provider-cursor/src/server/status.test.ts b/packages/provider-cursor/src/server/status.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..32eedf9e3a9cd03b1da4a33804c3d447334f99bb --- /dev/null +++ b/packages/provider-cursor/src/server/status.test.ts @@ -0,0 +1,252 @@ +import type { SDKModel } from "@cursor/sdk"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type { CursorSettings } from "../settings.ts"; +import { CursorSettings as CursorSettingsSchema } from "../settings.ts"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildCursorCapabilitiesFromSdkModel, + buildCursorDiscoveredModelsFromSdk, + buildCursorProviderSnapshot, + buildInitialCursorProviderSnapshot, + checkCursorProviderStatus, +} from "./status.ts"; +import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; + +const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema); + +function selectDescriptor( + id: string, + label: string, + options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, +) { + return { + id, + label, + type: "select" as const, + options: [...options], + ...(options.find((option) => option.isDefault)?.id + ? { currentValue: options.find((option) => option.isDefault)?.id } + : {}), + }; +} + +function booleanDescriptor(id: string, label: string, currentValue?: boolean) { + return { + id, + label, + type: "boolean" as const, + ...(typeof currentValue === "boolean" ? { currentValue } : {}), + }; +} + +const baseCursorSettings: CursorSettings = decodeCursorSettings({ + enabled: true, + customModels: [], +}); + +const sdkParameterizedModel = { + id: "claude-opus-4-8", + displayName: "Opus 4.8", + parameters: [ + { + id: "thinking", + displayName: "Thinking", + values: [{ value: "false" }, { value: "true" }], + }, + { + id: "context", + displayName: "Context", + values: [ + { value: "300k", displayName: "300K" }, + { value: "1m", displayName: "1M" }, + ], + }, + { + id: "effort", + displayName: "Effort", + values: [ + { value: "low", displayName: "Low" }, + { value: "high", displayName: "High" }, + ], + }, + { + id: "fast", + displayName: "Fast", + values: [{ value: "false" }, { value: "true", displayName: "Fast" }], + }, + ], + variants: [ + { + displayName: "Opus 4.8", + isDefault: true, + params: [ + { id: "thinking", value: "true" }, + { id: "context", value: "1m" }, + { id: "effort", value: "high" }, + { id: "fast", value: "false" }, + ], + }, + ], +} satisfies SDKModel; + +describe("buildInitialCursorProviderSnapshot", () => { + it.effect("uses SDK-specific pending status copy", () => + Effect.gen(function* () { + const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings); + + expect(provider).toMatchObject({ + status: "warning", + message: "Checking Cursor SDK availability...", + }); + }), + ); +}); + +describe("buildCursorProviderSnapshot", () => { + it("downgrades ready status to warning when SDK model discovery returns no models", () => { + expect( + buildCursorProviderSnapshot({ + checkedAt: "2026-01-01T00:00:00.000Z", + cursorSettings: baseCursorSettings, + parsed: { + version: null, + status: "ready", + auth: { status: "authenticated", type: "api-key", label: "Cursor API key" }, + }, + discoveryWarning: "Cursor SDK model discovery returned no built-in models.", + }), + ).toMatchObject({ + status: "warning", + message: "Cursor SDK model discovery returned no built-in models.", + models: [], + supportsConversationRollback: false, + }); + }); +}); + +describe("Cursor SDK model discovery", () => { + it("maps native SDK parameter ids and default variant values to model capabilities", () => { + expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual( + createModelCapabilities({ + optionDescriptors: [ + selectDescriptor("effort", "Effort", [ + { id: "low", label: "Low" }, + { id: "high", label: "High", isDefault: true }, + ]), + selectDescriptor("contextWindow", "Context", [ + { id: "300k", label: "300K" }, + { id: "1m", label: "1M", isDefault: true }, + ]), + booleanDescriptor("fastMode", "Fast", false), + booleanDescriptor("thinking", "Thinking", true), + ], + }), + ); + }); + + it("filters invalid and duplicate SDK model entries", () => { + expect( + buildCursorDiscoveredModelsFromSdk([ + sdkParameterizedModel, + { ...sdkParameterizedModel, displayName: "Duplicate" }, + { id: "", displayName: "Invalid" }, + ]), + ).toEqual([ + { + slug: "claude-opus-4-8", + name: "Opus 4.8", + isCustom: false, + capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel), + }, + ]); + }); +}); + +describe("checkCursorProviderStatus", () => { + it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () => + Effect.gen(function* () { + const provider = yield* checkCursorProviderStatus( + { + ...baseCursorSettings, + customModels: ["internal/cursor-model"], + }, + { CURSOR_API_KEY: "test-cursor-key" }, + ).pipe( + Effect.provide( + CursorSdkCatalog.layerTest((apiKey) => { + expect(apiKey).toBe("test-cursor-key"); + return Effect.succeed({ + user: { + apiKeyName: "test-key", + userEmail: "cursor@example.com", + createdAt: "2026-01-01T00:00:00.000Z", + }, + models: [sdkParameterizedModel], + }); + }), + ), + ); + + expect(provider).toMatchObject({ + status: "ready", + auth: { + status: "authenticated", + type: "api-key", + label: "Cursor API key (test-key)", + email: "cursor@example.com", + }, + models: [ + { slug: "claude-opus-4-8", isCustom: false }, + { slug: "internal/cursor-model", isCustom: true }, + ], + }); + }), + ); + + it.effect("surfaces SDK authentication failures", () => + Effect.gen(function* () { + const provider = yield* checkCursorProviderStatus(baseCursorSettings, { + CURSOR_API_KEY: "invalid-test-key", + }).pipe( + Effect.provide( + CursorSdkCatalog.layerTest(() => + Effect.fail( + new CursorSdkCatalog.CursorSdkCatalogError({ + authenticationFailure: true, + cause: new Error("unauthorized"), + }), + ), + ), + ), + ); + + expect(provider).toMatchObject({ + status: "error", + auth: { status: "unauthenticated" }, + message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.", + }); + }), + ); + + it.effect("requires a Cursor API key without probing any external Cursor binary", () => + Effect.gen(function* () { + const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe( + Effect.provide( + CursorSdkCatalog.layerTest(() => + Effect.die("SDK catalog must not be used without CURSOR_API_KEY"), + ), + ), + ); + + expect(provider).toMatchObject({ + installed: true, + status: "error", + auth: { status: "unauthenticated" }, + message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", + }); + }), + ); +}); diff --git a/packages/provider-cursor/src/server/status.ts b/packages/provider-cursor/src/server/status.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b00118688df2c08b8d8db255ec12ec4fe30cad0 --- /dev/null +++ b/packages/provider-cursor/src/server/status.ts @@ -0,0 +1,350 @@ +import type { SDKModel, SDKUser } from "@cursor/sdk"; +import type { + ModelCapabilities, + ProviderOptionDescriptor, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import type { CursorSettings } from "../settings.ts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; + +import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./sdkModel.ts"; +import { + buildBooleanOptionDescriptor, + buildSelectOptionDescriptor, + buildServerProvider, + providerModelsFromSettings, + type ServerProviderDraft, +} from "@t3tools/provider-core/server/snapshotProbe"; +import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; + +const CURSOR_PRESENTATION = { + displayName: "Cursor", + supportsConversationRollback: false, + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000; + +export function buildInitialCursorProviderSnapshot( + cursorSettings: CursorSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getCursorFallbackModels(cursorSettings); + + if (!cursorSettings.enabled) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cursor is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Cursor SDK availability...", + }, + }); + }); +} + +function getCursorFallbackModels( + cursorSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); +} + +function toTitleCaseWords(value: string): string { + const parts: Array = []; + for (const part of value.split(/[\s_-]+/g)) { + if (part.length > 0) { + parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()); + } + } + return parts.join(" "); +} + +function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined { + return model.variants + ?.find((variant) => variant.isDefault) + ?.params.find((parameter) => parameter.id === parameterId)?.value; +} + +export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities { + const seen = new Set(); + const optionDescriptors: Array = []; + const parameters = (model.parameters ?? []) + .map((parameter, index) => ({ parameter, index })) + .toSorted( + (left, right) => + cursorSdkParameterPriority(left.parameter.id) - + cursorSdkParameterPriority(right.parameter.id) || left.index - right.index, + ); + for (const { parameter } of parameters) { + const nativeId = parameter.id.trim(); + const id = cursorSdkProviderOptionId(nativeId); + if (!nativeId || !id || seen.has(id)) { + continue; + } + seen.add(id); + + const values = parameter.values.flatMap((entry) => { + const value = entry.value.trim(); + if (!value) { + return []; + } + return [ + { + value, + label: entry.displayName?.trim() || value, + }, + ]; + }); + if (values.length === 0) { + continue; + } + + const label = parameter.displayName?.trim() || toTitleCaseWords(id); + const defaultValue = cursorSdkDefaultParameterValue(model, nativeId); + const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase())); + if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) { + if (defaultValue === "true" || defaultValue === "false") { + optionDescriptors.push( + buildBooleanOptionDescriptor({ + id, + label, + currentValue: defaultValue === "true", + }), + ); + } else { + optionDescriptors.push(buildBooleanOptionDescriptor({ id, label })); + } + continue; + } + + optionDescriptors.push( + buildSelectOptionDescriptor({ + id, + label, + options: values.map((entry) => ({ + ...entry, + ...(entry.value === defaultValue ? { isDefault: true } : {}), + })), + }), + ); + } + + return createModelCapabilities({ optionDescriptors }); +} + +export function buildCursorDiscoveredModelsFromSdk( + models: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + return models.flatMap((model) => { + const slug = model.id.trim(); + const name = model.displayName.trim(); + if (!slug || !name || seen.has(slug)) { + return []; + } + seen.add(slug); + return [ + { + slug, + name, + isCustom: false, + capabilities: buildCursorCapabilitiesFromSdkModel(model), + } satisfies ServerProviderModel, + ]; + }); +} + +function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth { + const email = user.userEmail?.trim(); + const apiKeyName = user.apiKeyName.trim(); + return { + status: "authenticated", + type, + label: + type === "browser" + ? "Cursor account" + : apiKeyName + ? `Cursor API key (${apiKeyName})` + : "Cursor API key", + ...(email ? { email } : {}), + }; +} + +interface CursorProviderProbeResult { + readonly version: string | null; + readonly status: Exclude; + readonly auth: ServerProviderAuth; + readonly message?: string; +} + +function joinProviderMessages(...messages: ReadonlyArray): string | undefined { + const parts: Array = []; + for (const message of messages) { + const trimmed = message?.trim(); + if (trimmed) { + parts.push(trimmed); + } + } + return parts.length > 0 ? parts.join(" ") : undefined; +} + +export function buildCursorProviderSnapshot(input: { + readonly checkedAt: string; + readonly cursorSettings: CursorSettings; + readonly parsed: CursorProviderProbeResult; + readonly discoveredModels?: ReadonlyArray; + readonly discoveryWarning?: string; +}): ServerProviderDraft { + const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: input.cursorSettings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + input.discoveredModels ?? [], + input.cursorSettings.customModels, + EMPTY_CAPABILITIES, + ), + probe: { + installed: true, + version: input.parsed.version, + status: + input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, + auth: input.parsed.auth, + ...(message ? { message } : {}), + }, + }); +} + +export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( + cursorSettings: CursorSettings, + environment?: NodeJS.ProcessEnv, + authenticationType: "api-key" | "browser" = "api-key", +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = getCursorFallbackModels(cursorSettings); + + if (!cursorSettings.enabled) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cursor is disabled in T3 Code settings.", + }, + }); + } + + const sdkApiKey = environment?.CURSOR_API_KEY?.trim(); + if (!sdkApiKey) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unauthenticated" }, + message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", + }, + }); + } + + const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; + const catalogResult = yield* sdkCatalog + .read(sdkApiKey) + .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(catalogResult)) { + yield* Effect.logWarning("Cursor SDK catalog probe failed", { + cause: catalogResult.failure.cause, + }); + const authenticationFailure = catalogResult.failure.authenticationFailure; + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: authenticationFailure ? "unauthenticated" : "unknown" }, + message: authenticationFailure + ? authenticationType === "browser" + ? "Cursor sign-in expired or was rejected. Sign in again in provider settings." + : "Cursor SDK authentication failed. Check CURSOR_API_KEY." + : "Cursor SDK catalog request failed. Check server logs for details.", + }, + }); + } + + if (Option.isNone(catalogResult.success)) { + return buildServerProvider({ + presentation: CURSOR_PRESENTATION, + enabled: cursorSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`, + }, + }); + } + + const snapshot = catalogResult.success.value; + const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models); + return buildCursorProviderSnapshot({ + checkedAt, + cursorSettings, + parsed: { + version: null, + status: "ready", + auth: cursorSdkAuth(snapshot.user, authenticationType), + }, + discoveredModels, + ...(discoveredModels.length === 0 + ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." } + : {}), + }); +}); diff --git a/packages/provider-cursor/src/server/textGeneration.test.ts b/packages/provider-cursor/src/server/textGeneration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b007c9dbaf7c87a0bb605550dd7a777036437ab --- /dev/null +++ b/packages/provider-cursor/src/server/textGeneration.test.ts @@ -0,0 +1,416 @@ +import type { RunResult } from "@cursor/sdk"; +import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; +import { CursorSettings } from "../settings.ts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import * as Schema from "effect/Schema"; +import { createModelSelection } from "@t3tools/shared/model"; +import { beforeEach, vi } from "vite-plus/test"; + +import { makeCursorTextGeneration } from "./textGeneration.ts"; + +const cursorSdkMock = vi.hoisted(() => ({ + create: vi.fn<(options: unknown) => Promise>(), + send: vi.fn<(prompt: string) => Promise>(), + close: vi.fn(), + cancel: vi.fn(async () => {}), + prompt: vi.fn<(prompt: string, options: unknown) => Promise>(async () => ({ + id: "run-cursor-text-generation-test", + status: "finished", + result: + '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}', + })), +})); + +vi.mock("./sdk.ts", () => ({ Agent: { create: cursorSdkMock.create } })); + +let hasCustomPolicy = false; +const layerFs = FileSystem.layerNoop({ + exists: () => Effect.succeed(hasCustomPolicy), + makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"), +}); + +const decodeCursorSettings = Schema.decodeSync(CursorSettings); +const cursorSettings = decodeCursorSettings({ enabled: true }); + +beforeEach(() => { + hasCustomPolicy = false; + cursorSdkMock.create.mockReset(); + cursorSdkMock.send.mockReset(); + cursorSdkMock.create.mockImplementation(async (options) => { + cursorSdkMock.send.mockImplementation(async (prompt) => ({ + status: "running", + cancel: cursorSdkMock.cancel, + wait: () => cursorSdkMock.prompt(prompt, options), + })); + return { + close: cursorSdkMock.close, + [Symbol.asyncDispose]: async () => { + cursorSdkMock.close(); + }, + send: cursorSdkMock.send, + }; + }); + cursorSdkMock.close.mockClear(); + cursorSdkMock.cancel.mockClear(); + cursorSdkMock.prompt.mockReset(); + cursorSdkMock.prompt.mockResolvedValue({ + id: "run-cursor-text-generation-test", + status: "finished", + result: + '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}', + }); +}); + +describe("CursorTextGeneration", () => { + it.effect("resolves the browser credential for every request after an account change", () => + Effect.gen(function* () { + let apiKey = "first-browser-key"; + const generation = yield* makeCursorTextGeneration( + cursorSettings, + {}, + Effect.sync(() => apiKey), + ); + const input = { + cwd: process.cwd(), + branch: "feature/cursor", + stagedSummary: "M file.ts", + stagedPatch: "diff", + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "auto"), + }; + yield* generation.generateCommitMessage(input); + expect(cursorSdkMock.create).toHaveBeenLastCalledWith( + expect.objectContaining({ apiKey: "first-browser-key" }), + ); + apiKey = "second-browser-key"; + yield* generation.generateCommitMessage(input); + expect(cursorSdkMock.create).toHaveBeenLastCalledWith( + expect.objectContaining({ apiKey: "second-browser-key" }), + ); + }).pipe(Effect.provide(layerFs)), + ); + + it.effect("uses the Cursor SDK prompt API with model parameters and API key", () => + Effect.gen(function* () { + const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { + CURSOR_API_KEY: "test-cursor-key", + }); + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-text-generation", + stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", + stagedPatch: + "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ + { id: "thinking", value: "high" }, + { id: "contextWindow", value: "1m" }, + { id: "fastMode", value: true }, + ]), + }); + + expect(generated.subject).toBe("Add generated commit message"); + expect(generated.body).toBe("- verify cursor sdk text generation"); + + expect(cursorSdkMock.prompt).toHaveBeenCalledTimes(1); + const [prompt, options] = ( + cursorSdkMock.prompt.mock.calls as unknown as Array<[string, unknown]> + )[0]!; + expect(prompt).toContain("Staged patch:"); + expect(options).toEqual({ + apiKey: "test-cursor-key", + mode: "plan", + model: { + id: "gpt-5.4", + params: [ + { id: "thinking", value: "high" }, + { id: "context", value: "1m" }, + { id: "fast", value: "true" }, + ], + }, + local: { + cwd: "/isolated-text-generation", + autoReview: false, + sandboxOptions: { enabled: true }, + settingSources: [], + enableAgentRetries: true, + }, + }); + }).pipe(Effect.provide(layerFs)), + ); + + it.effect("continues in the temp directory when the SDK cannot sandbox", () => + Effect.gen(function* () { + cursorSdkMock.create.mockImplementationOnce(async () => { + throw new Error( + "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.", + ); + }); + const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { + CURSOR_API_KEY: "test-cursor-key", + }); + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-text-generation", + stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", + stagedPatch: "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts", + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), + }); + + expect(generated.subject).toBe("Add generated commit message"); + expect(cursorSdkMock.create).toHaveBeenCalledTimes(2); + expect(cursorSdkMock.create.mock.calls[0]?.[0]).toMatchObject({ + local: { sandboxOptions: { enabled: true } }, + }); + expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({ + local: { + cwd: "/isolated-text-generation", + autoReview: false, + sandboxOptions: { enabled: false }, + settingSources: [], + enableAgentRetries: true, + }, + }); + }).pipe(Effect.provide(layerFs)), + ); + + it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () => + Effect.gen(function* () { + cursorSdkMock.create.mockImplementationOnce(async () => { + throw new Error("Cursor SDK network down"); + }); + const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { + CURSOR_API_KEY: "test-cursor-key", + }); + + const error = yield* Effect.flip( + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-text-generation", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), + }), + ); + + expect(error.detail).toBe("Cursor SDK text generation failed."); + expect(cursorSdkMock.create).toHaveBeenCalledTimes(1); + }).pipe(Effect.provide(layerFs)), + ); + + it.effect("accepts json objects with extra assistant text around them", () => + Effect.gen(function* () { + cursorSdkMock.prompt.mockResolvedValueOnce({ + id: "run-cursor-text-generation-test", + status: "finished", + result: + 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', + }); + const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { + CURSOR_API_KEY: "test-cursor-key", + }); + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-noisy-json", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); + + expect(generated.subject).toBe("Update README dummy comment with attribution and date"); + expect(generated.body).toBe(""); + }).pipe(Effect.provide(layerFs)), + ); + + it.effect("generates thread titles through Cursor SDK text generation", () => + Effect.gen(function* () { + cursorSdkMock.prompt.mockResolvedValueOnce({ + id: "run-cursor-title-generation-test", + status: "finished", + result: '{"title":"\\"Trim reconnect spinner status after resume.\\""}', + }); + const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { + CURSOR_API_KEY: "test-cursor-key", + }); + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Fix the reconnect spinner after a resumed session.", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }); + + expect(generated.title).toBe("Trim reconnect spinner status after resume."); + }).pipe(Effect.provide(layerFs)), + ); + + it.effect.each(["error", "cancelled"] as const)( + "rejects a %s Cursor SDK run that includes valid title JSON", + (status) => + Effect.gen(function* () { + const promptResult = { + id: "run-cursor-partial-title-test", + status, + result: '{"title":"Partial title from a failed run."}', + } satisfies RunResult; + cursorSdkMock.prompt.mockResolvedValueOnce(promptResult); + const generation = yield* makeCursorTextGeneration(cursorSettings, { + CURSOR_API_KEY: "test-cursor-key", + }); + const failure = yield* Effect.flip( + generation.generateThreadTitle({ + cwd: process.cwd(), + message: "Fix the reconnect spinner after a resumed session.", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }), + ); + expect(failure).toBeInstanceOf(TextGenerationError); + expect(failure.operation).toBe("generateThreadTitle"); + expect(failure.detail).toBe( + status === "cancelled" + ? "Cursor SDK request was cancelled." + : "Cursor SDK request finished with an error.", + ); + expect(cursorSdkMock.close).toHaveBeenCalledOnce(); + }).pipe(Effect.provide(layerFs)), + ); + + it.effect("fails closed when ambient sandbox policy can expand write access", () => + Effect.gen(function* () { + hasCustomPolicy = true; + const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" }); + const failure = yield* Effect.flip( + generation.generateThreadTitle({ + cwd: "/real-workspace", + message: "Title", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, + }), + ); + expect(failure.detail).toContain("custom ~/.cursor/sandbox.json"); + expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layerFs)), + ); + + it.effect("cancels the native run when text generation times out", () => + Effect.gen(function* () { + let started!: () => void; + const called = new Promise((resolve) => { + started = resolve; + }); + cursorSdkMock.prompt.mockImplementationOnce(() => { + started(); + return new Promise(() => {}); + }); + const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" }); + const result = yield* generation + .generateThreadTitle({ + cwd: "/real-workspace", + message: "Title", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.promise(() => called); + yield* TestClock.adjust("180 seconds"); + expect((yield* Fiber.join(result)).detail).toContain("timed out"); + expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); + expect(cursorSdkMock.close).toHaveBeenCalledOnce(); + }).pipe(Effect.provide(layerFs), Effect.scoped), + ); + + it.effect.each(["create", "send"] as const)( + "times out pending %s and releases its late SDK resource", + (phase) => + Effect.gen(function* () { + let started!: () => void; + const called = new Promise((resolve) => { + started = resolve; + }); + let resolveLate!: (resource: unknown) => void; + const pending = new Promise((resolve) => { + resolveLate = resolve; + }); + const wait = vi.fn(); + const agent = { + close: cursorSdkMock.close, + [Symbol.asyncDispose]: async () => { + cursorSdkMock.close(); + }, + send: async () => { + started(); + return pending; + }, + }; + cursorSdkMock.create.mockImplementation(async () => { + if (phase === "create") { + started(); + return pending; + } + return agent; + }); + const generation = yield* makeCursorTextGeneration(cursorSettings, { + CURSOR_API_KEY: "key", + }); + const result = yield* generation + .generateThreadTitle({ + cwd: "/real-workspace", + message: "Title", + modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, + }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.promise(() => called); + yield* TestClock.adjust("180 seconds"); + expect((yield* Fiber.join(result)).detail).toContain("timed out"); + let cleanup!: () => void; + const cleaned = new Promise((resolve) => { + cleanup = resolve; + }); + if (phase === "create") cursorSdkMock.close.mockImplementationOnce(cleanup); + else + cursorSdkMock.cancel.mockImplementationOnce(async () => { + cleanup(); + }); + resolveLate( + phase === "create" ? agent : { status: "running", cancel: cursorSdkMock.cancel, wait }, + ); + yield* Effect.promise(() => cleaned); + expect(cursorSdkMock.close).toHaveBeenCalledOnce(); + expect(wait).not.toHaveBeenCalled(); + if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); + }).pipe(Effect.provide(layerFs), Effect.scoped), + ); + + it.effect("requires CURSOR_API_KEY before calling the SDK", () => + Effect.gen(function* () { + const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {}); + + const error = yield* Effect.flip( + textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-api-key", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + instanceId: ProviderInstanceId.make("cursor"), + model: "composer-2", + }, + }), + ); + + expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings."); + expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layerFs)), + ); +}); diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts new file mode 100644 index 0000000000000000000000000000000000000000..924d620bbc766385d71607b2c056f1ea1f182886 --- /dev/null +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -0,0 +1,201 @@ +import * as NodeOS from "node:os"; +import * as FileSystem from "effect/FileSystem"; + +import type { AgentOptions, RunResult } from "@cursor/sdk"; +import { Agent } from "./sdk.ts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import { type ProviderSetupError, TextGenerationError } from "@t3tools/contracts"; +import type { CursorSettings } from "../settings.ts"; + +import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; +import { cursorSdkModelSelection } from "./sdkModel.ts"; +import type { CursorAuth } from "./auth.ts"; + +const CURSOR_TIMEOUT_MS = 180_000; + +const isTextGenerationError = Schema.is(TextGenerationError); + +function cursorSdkResultDetail(result: RunResult): string { + switch (result.status) { + case "cancelled": + return "Cursor SDK request was cancelled."; + case "error": + return "Cursor SDK request finished with an error."; + case "finished": + return "Cursor SDK returned empty output."; + } +} + +/** + * The SDK throws this when `sandboxOptions.enabled` is set and local sandboxing + * is unavailable. That happens on hosts that cannot launch `cursorsandbox`, and + * also after an unsandboxed run caches "unsupported" for the process. + */ +function cursorSandboxUnsupported(cause: unknown): boolean { + const seen = new Set(); + let current = cause; + while (typeof current === "object" && current !== null && !seen.has(current)) { + seen.add(current); + if (current instanceof Error && current.message.includes("sandboxing is not supported")) { + return true; + } + current = Reflect.get(current, "cause"); + } + return false; +} + +/** + * Build a Cursor text-generation closure bound to a specific `CursorSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( + cursorSettings: CursorSettings, + environment?: NodeJS.ProcessEnv, + resolveApiKey?: Effect.Effect, + withAccess?: CursorAuth["withAccess"], +) { + const fs = yield* FileSystem.FileSystem; + const resolvedEnvironment = environment ?? process.env; + + const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) => + Effect.gen(function* () { + if (!cursorSettings.enabled) { + return yield* new TextGenerationError({ + operation, + detail: "Cursor is disabled in T3 Code settings.", + }); + } + + const apiKey = resolveApiKey + ? yield* resolveApiKey + : resolvedEnvironment.CURSOR_API_KEY?.trim(); + if (!apiKey) { + return yield* new TextGenerationError({ + operation, + detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", + }); + } + + return apiKey; + }); + + // Ignores `cwd`: the agent runs in an empty temp directory, away from the project. + const runCursorJson: TextGenerationOperations.Runner = (input) => { + const { operation, prompt, modelSelection } = input; + return Effect.gen(function* () { + const apiKey = yield* resolveCursorApiKey(operation); + // The SDK loads sandbox.json independently of settingSources and lets it + // expand the writable paths. Its public API cannot override that policy. + if (yield* fs.exists(`${NodeOS.homedir()}/.cursor/sandbox.json`)) { + return yield* new TextGenerationError({ + operation, + detail: + "Cursor text generation cannot enforce workspace isolation with a custom ~/.cursor/sandbox.json. Use another text-generation provider.", + }); + } + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" }); + const agentOptions = { + apiKey, + mode: "plan", + model: cursorSdkModelSelection(modelSelection), + local: { + cwd, + autoReview: false, + sandboxOptions: { enabled: true }, + settingSources: [], + enableAgentRetries: true, + }, + } satisfies AgentOptions; + const createCursorAgent = (sandboxEnabled: boolean) => + Effect.tryPromise((signal) => + Agent.create( + sandboxEnabled + ? agentOptions + : { + ...agentOptions, + local: { + ...agentOptions.local, + sandboxOptions: { enabled: false }, + }, + }, + ).then((agent) => { + if (signal.aborted) agent.close(); + return agent; + }), + ); + + const request = Effect.gen(function* () { + // Prefer the sandbox. When the SDK refuses it, the empty temp directory + // and empty setting sources still keep this run off the user's project. + const agent = yield* Effect.acquireRelease( + createCursorAgent(true).pipe( + Effect.catchIf(cursorSandboxUnsupported, () => createCursorAgent(false)), + ), + (agent) => + Effect.tryPromise(() => agent[Symbol.asyncDispose]()).pipe( + Effect.timeout("5 seconds"), + Effect.ignore({ log: true }), + ), + { interruptible: true }, + ); + const run = yield* Effect.tryPromise((signal) => + agent.send(prompt).then((run) => { + if (signal.aborted) void run.cancel().catch(() => undefined); + return run; + }), + ); + yield* Effect.addFinalizer(() => + run.status === "running" + ? Effect.tryPromise(() => run.cancel()).pipe( + Effect.timeout("5 seconds"), + Effect.ignore({ log: true }), + ) + : Effect.void, + ); + return yield* Effect.tryPromise(() => run.wait()); + }).pipe(Effect.scoped); + const promptResult = yield* request.pipe( + Effect.timeoutOption(CURSOR_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor SDK request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + ); + + const rawResult = promptResult.result?.trim() ?? ""; + if (promptResult.status !== "finished" || !rawResult) { + return yield* new TextGenerationError({ + operation, + detail: cursorSdkResultDetail(promptResult), + }); + } + + return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult); + }).pipe( + (effect) => (withAccess ? withAccess(effect) : effect), + Effect.scoped, + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : new TextGenerationError({ + operation, + detail: "Cursor SDK text generation failed.", + cause, + }), + ), + ); + }; + + return TextGenerationOperations.fromRunner("CursorTextGeneration", runCursorJson); +}); diff --git a/packages/provider-cursor/src/server/transportFailure.test.ts b/packages/provider-cursor/src/server/transportFailure.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..91d4fb9f17e17092fa94efc80c0504a974a27e3c --- /dev/null +++ b/packages/provider-cursor/src/server/transportFailure.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { CursorTransportFailure } from "./transportFailure.ts"; + +const diagnostic = "Error: RetriableError: WritableIterable is closed"; + +function failureFor(chunks: string[]) { + const reply = new CursorTransportFailure(); + for (const chunk of chunks) reply.push(chunk); + return reply.failure; +} + +describe("CursorTransportFailure", () => { + it.each([ + diagnostic, + "Error: ConnectError: [unavailable] transport closed", + "Error: ConnectError: [aborted] aborted", + "Error: ConnectError: [deadline_exceeded] timed out", + "Something went wrong communicating with the server. Please try again.", + ])("recognizes a terminal diagnostic: %s", (message) => { + expect(failureFor([message])).toBe(message); + expect(failureFor([...message])).toBe(message); + }); + + it("recognizes a standalone failure with a stack trace", () => { + expect(failureFor(["\n", diagnostic, "\n at send (cli.js:1:2)\n\n"])).toBe(diagnostic); + }); + + it.each([ + "The error was " + diagnostic, + "This is an example of a transport error:\n" + diagnostic, + "I inspected the files.\n" + diagnostic, + "> " + diagnostic, + " " + diagnostic, + "```text\n" + diagnostic + "\n```", + "~~~\n" + diagnostic + "\n~~~", + diagnostic + "\nThis is an example of a transport error.", + "Error: ConnectError: [unauthenticated] sign in", + "Error: ConnectError: [permission_denied] subscription required", + "Error: HTTP 500 from the application being debugged", + "Error: RetriableError: [internal] Failed to run step, exceeded max retries", + "Error: RetriableError: [internal] Failed to run step, exceeded max retries\n at step (cli.js:1:2)", + ])("preserves prose, code and non-transport errors: %s", (message) => { + expect(failureFor([...message])).toBeUndefined(); + }); + + it("tracks fences across long lines without retaining the answer", () => { + expect(failureFor(["```\n", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); + expect(failureFor(["```", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); + expect(failureFor(["x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); + }); + + it("does not retain a previous failure after new output", () => { + const reply = new CursorTransportFailure(); + reply.push(diagnostic); + expect(reply.failure).toBe(diagnostic); + reply.push("\nRecovered successfully."); + expect(reply.failure).toBeUndefined(); + }); +}); diff --git a/packages/provider-cursor/src/server/transportFailure.ts b/packages/provider-cursor/src/server/transportFailure.ts new file mode 100644 index 0000000000000000000000000000000000000000..e6cadb23dc0d1d1c9f9635c2d86c9a6081f9db2d --- /dev/null +++ b/packages/provider-cursor/src/server/transportFailure.ts @@ -0,0 +1,52 @@ +const maxLineLength = 4096; +// Cursor also uses RetriableError for agent-loop failures; preserve those diagnostics. +const transportError = + /^Error: (?:RetriableError: (?!\[internal\]).+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; +const serverError = "Something went wrong communicating with the server. Please try again."; + +interface ReplyState { + disqualified: boolean; + failure: string | undefined; +} + +function consumeLine(state: ReplyState, line: string) { + if (state.disqualified) return; + const text = line.trimEnd(); + if (transportError.test(text) || text === serverError) { + state.failure = text; + } else if (text.trim() !== "" && !(state.failure && /^\s+at\s/.test(text))) { + // An explanation or code sample can quote the same diagnostic. Only + // classify an assistant item consisting entirely of a transport dump. + state.disqualified = true; + state.failure = undefined; + } +} + +/** Tracks a standalone Cursor diagnostic without retaining an entire streamed answer. */ +export class CursorTransportFailure { + private state: ReplyState = { disqualified: false, failure: undefined }; + private line = ""; + + push(text: string) { + for (const [index, part] of text.split("\n").entries()) { + if (this.state.disqualified) return; + if (index > 0) { + consumeLine(this.state, this.line); + this.line = ""; + } + if (this.line.length + part.length > maxLineLength) { + this.state.disqualified = true; + this.state.failure = undefined; + this.line = ""; + return; + } + this.line += part; + } + } + + get failure() { + const state = { ...this.state }; + consumeLine(state, this.line); + return state.failure; + } +} diff --git a/packages/provider-cursor/src/server/usageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..634168447d9c95024b60a99e87bba8167d818345 --- /dev/null +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from "@effect/vitest"; +import { NodeServices } from "@effect/platform-node"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import { HttpClient, HttpClientResponse } from "effect/http"; +import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; + +const withNodeServices = (effect: Effect.Effect) => + effect.pipe(Effect.provide(NodeServices.layer)); + +describe("Cursor usage limits", () => { + const checkedAt = "2026-09-16T00:00:00.000Z"; + + it("uses the advertised percentages and billing-cycle reset", () => { + const limits = cursorUsageResponseToLimits( + { + billingCycleEnd: "1789876386000", + planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, + }, + checkedAt, + ); + expect(limits.windows).toEqual( + expect.arrayContaining([ + { + id: "totalPercentUsed", + kind: "monthly", + label: "Overall", + usedPercent: 72.4, + resetsAt: "2026-09-20T03:53:06.000Z", + }, + { + id: "autoPercentUsed", + kind: "monthly", + label: "Cursor Models", + usedPercent: 69.5, + resetsAt: "2026-09-20T03:53:06.000Z", + }, + { + id: "apiPercentUsed", + kind: "monthly", + label: "Other Models", + usedPercent: 100, + resetsAt: "2026-09-20T03:53:06.000Z", + }, + ]), + ); + }); + + it("does not invent unused allowance for absent buckets", () => { + expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( + "unsupported", + ); + expect( + cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); + expect( + cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, + ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); + }); + + it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => + Effect.gen(function* () { + yield* withNodeServices( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped(); + yield* fs.makeDirectory(path.join(directory, "cursor")); + yield* fs.writeFileString( + path.join(directory, "cursor", "auth.json"), + '{"accessToken":"instance-token"}', + ); + const client = HttpClient.make((request) => { + expect(request.url).toBe( + "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", + ); + expect(request.method).toBe("POST"); + expect(request.headers.authorization).toBe("Bearer instance-token"); + expect(request.headers["connect-protocol-version"]).toBe("1"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), + ), + ); + }); + yield* fs.makeDirectory(path.join(directory, ".cursor")); + yield* fs.writeFileString( + path.join(directory, ".cursor", "auth.json"), + '{"accessToken":"instance-token"}', + ); + for (const platform of ["linux", "darwin"] as const) { + const limits = yield* readCursorUsageLimits( + { apiEndpoint: "https://cursor.example/" }, + { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, + ).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService(HttpClient.HttpClient, client), + ); + expect(limits.windows[0]?.usedPercent).toBe(42); + } + }).pipe(Effect.scoped), + ); + }), + ); + + it.effect( + "never reads stale files for keychain or memory logins, but accepts an explicit auth token", + () => + Effect.gen(function* () { + for (const platform of ["linux", "darwin"] as const) { + for (const token of [undefined, "explicit-token"]) { + const limits = yield* withNodeServices( + readCursorUsageLimits( + { apiEndpoint: "" }, + { + AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", + ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), + }, + false, + async () => { + throw new Error("must not read Keychain before opt-in"); + }, + ).pipe( + Effect.provideService(HostProcessPlatform, platform), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("must not read an unrelated credential file"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(token).toBe("explicit-token"); + expect(request.headers.authorization).toBe("Bearer explicit-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ planUsage: { totalPercentUsed: 10 } }), + ), + ); + }), + ), + ), + ); + if (token) expect(limits.windows[0]?.usedPercent).toBe(10); + else expect(limits.unavailable?.reason).toBe("unsupported"); + } + } + }), + ); + + it.effect("reports failed requests without exposing credentials or response bodies", () => + Effect.gen(function* () { + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("private response", { status: 401 }), + ), + ), + ), + ), + ), + ); + expect(limits.unavailable).toEqual({ + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }); + }), + ); + + it.effect("does not use a stored login for an explicit API key", () => + Effect.gen(function* () { + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request usage")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("unsupported"); + }), + ); + + it.effect("reads the default macOS Cursor login from Keychain for limits", () => + Effect.gen(function* () { + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + FileSystem.FileSystem, + FileSystem.makeNoop({ + readFileString: () => Effect.die("must not read a stale credential file"), + }), + ), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + expect(request.headers.authorization).toBe("Bearer keychain-token"); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ planUsage: { totalPercentUsed: 42 } }), + ), + ); + }), + ), + ), + ); + expect(limits.windows[0]?.usedPercent).toBe(42); + }), + ); + + it.effect("reports a Keychain initialization failure without failing the provider refresh", () => + Effect.gen(function* () { + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { + throw new Error("Keychain initialization failed"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not request limits without a login")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("probeFailed"); + }), + ); + + it.effect("does not read Keychain or send its token to a custom endpoint", () => + Effect.gen(function* () { + for (const [apiEndpoint, environment] of [ + ["http://localhost:3000", {}], + ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], + ["https://cursor-proxy.example", {}], + ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], + ] as const) { + const limits = yield* withNodeServices( + readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { + throw new Error("must not read Keychain for a custom endpoint"); + }).pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), + ), + ), + ); + expect(limits.unavailable?.reason).toBe("unsupported"); + expect(limits.unavailable?.message).toContain("default Cursor endpoint"); + } + }), + ); +}); diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts new file mode 100644 index 0000000000000000000000000000000000000000..24c314fe7d7deb0615d631ba6d3e7e6a5934e913 --- /dev/null +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -0,0 +1,152 @@ +import * as NodeOS from "node:os"; +import type { ServerProviderUsageWindow } from "@t3tools/contracts"; +import type { CursorSettings } from "../settings.ts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { + clampPercent, + makeUnavailableUsageLimits, + makeUsageLimits, +} from "@t3tools/provider-core/server/usageLimits"; +import { readMacCursorAccessToken } from "./keychainToken.ts"; + +const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); +const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; +const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); +const CursorUsageResponse = Schema.Struct({ + billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + planUsage: Schema.optional( + Schema.Struct({ + totalPercentUsed: Schema.optional(Schema.Number), + autoPercentUsed: Schema.optional(Schema.Number), + apiPercentUsed: Schema.optional(Schema.Number), + }), + ), +}); + +/** Cursor's dashboard percentages include bonus usage; spend / limit does not. */ +export function cursorUsageResponseToLimits( + response: typeof CursorUsageResponse.Type, + checkedAt: string, +) { + const reset = DateTime.make(Number(response.billingCycleEnd)); + const resetsAt = + Number(response.billingCycleEnd) > 0 && Option.isSome(reset) + ? DateTime.formatIso(reset.value) + : undefined; + const windows: ServerProviderUsageWindow[] = []; + if (response.planUsage) { + for (const { id, label } of CURSOR_USAGE_WINDOWS) { + const usedPercent = response.planUsage[id]; + if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; + windows.push({ + id, + kind: "monthly", + label, + usedPercent: clampPercent(usedPercent), + ...(resetsAt ? { resetsAt } : {}), + }); + } + } + return windows.length > 0 + ? makeUsageLimits({ checkedAt, windows }) + : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); +} + +export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( + settings: Pick, + environment: NodeJS.ProcessEnv = process.env, + allowKeychain = false, + keychainToken: () => Promise = readMacCursorAccessToken, +) { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const endpoint = ( + settings.apiEndpoint?.trim() || + environment.CURSOR_API_ENDPOINT?.trim() || + DEFAULT_CURSOR_API_ENDPOINT + ).replace(/\/$/, ""); + let token = environment.CURSOR_AUTH_TOKEN?.trim(); + // An explicit API key can name a different account from the stored login. + if (!token && environment.CURSOR_API_KEY?.trim()) { + return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + } + const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; + if (!token && credentialStore === "memory") { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", + }); + } + if (!token && platform === "darwin" && credentialStore !== "file") { + if (!allowKeychain) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Enable Cursor account usage in T3 Code to read its Keychain login.", + }); + } + if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { + return makeUnavailableUsageLimits({ + checkedAt, + reason: "unsupported", + message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", + }); + } + token = (yield* Effect.tryPromise(keychainToken))?.trim(); + } else if (!token) { + const home = + (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); + const directory = + platform === "win32" + ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") + : platform === "darwin" + ? path.join(home, ".cursor") + : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); + const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + Effect.flatMap(decodeCredentials), + ); + token = credentials.accessToken?.trim(); + } + if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + const client = yield* HttpClient.HttpClient; + const response = yield* client.execute( + HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( + HttpClientRequest.bearerToken(token), + HttpClientRequest.setHeaders({ + "connect-protocol-version": "1", + "x-cursor-client-type": "cli", + }), + HttpClientRequest.bodyJsonUnsafe({}), + ), + ); + const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( + yield* HttpClientResponse.filterStatusOk(response), + ); + return cursorUsageResponseToLimits(body, checkedAt); + }).pipe( + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }), + ), + ); +}); diff --git a/packages/provider-cursor/src/settings.ts b/packages/provider-cursor/src/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4d5017d4f8991d75808756cb5b2bcb64f5021b5 --- /dev/null +++ b/packages/provider-cursor/src/settings.ts @@ -0,0 +1,35 @@ +/** + * Cursor instance settings. Shared by the server driver and the client + * settings form, so it holds only browser-safe schema code. + * + * @module provider-cursor/settings + */ +import { CustomModelSetting, makeProviderSettingsSchema, TrimmedString } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +export const CursorSettings = makeProviderSettingsSchema( + { + // Off by default like Grok and OpenCode. Users opt in from Settings. + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + // Keep V1's CLI configuration when V2 rewrites the shared settings file. + // V2's Cursor SDK does not use these fields. + binaryPath: Schema.optionalKey(TrimmedString).pipe( + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + apiEndpoint: Schema.optionalKey(TrimmedString).pipe( + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + customModels: Schema.Array(CustomModelSetting).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: [], + }, +); +export type CursorSettings = typeof CursorSettings.Type; diff --git a/packages/provider-cursor/src/testing.ts b/packages/provider-cursor/src/testing.ts new file mode 100644 index 0000000000000000000000000000000000000000..1357074782f554347303bf4c88c215aec23449f1 --- /dev/null +++ b/packages/provider-cursor/src/testing.ts @@ -0,0 +1,13 @@ +/** + * Cursor internals the server's replay testkit, fixture recorder, and + * orchestration tests use directly. + * + * @module provider-cursor/testing + */ +export { + CURSOR_DEFAULT_INSTANCE_ID, + CURSOR_DRIVER_KIND, + CursorProviderCapabilitiesV2, + cursorSdkModelSelection, + makeCursorAgentOptions, +} from "./server/adapter.ts"; diff --git a/packages/provider-cursor/tsconfig.json b/packages/provider-cursor/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48 --- /dev/null +++ b/packages/provider-cursor/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/packages/provider-testing/src/host.ts b/packages/provider-testing/src/host.ts index 6c9a9e11347275511c6590a3282973ce7b271fa8..eb1d07af6c8615b6a62c5831d0c629b3768cb16e 100644 --- a/packages/provider-testing/src/host.ts +++ b/packages/provider-testing/src/host.ts @@ -10,6 +10,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; @@ -39,6 +40,7 @@ export const layerTestProviderHost = ( yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie); } const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; + const credentials = new Map(); return ProviderHost.ProviderHost.of({ paths: { cwd: options.cwd ?? process.cwd(), baseDir, stateDir, providerStatusCacheDir }, settings: { @@ -49,6 +51,17 @@ export const layerTestProviderHost = ( shouldRunBackgroundWork: () => Effect.succeed(options.runBackgroundWork ?? true), // Tests store attachments flat under the attachments directory by id. resolveAttachmentPath: (attachment) => path.join(attachmentsDir, attachment.id), + // Credentials live in memory for the layer's lifetime. + credentials: (namespace, bindingId) => + Effect.sync(() => { + const key = `${namespace}:${bindingId}`; + return { + binding: { owner: "t3" as const, key }, + get: Effect.sync(() => Option.fromUndefinedOr(credentials.get(key))), + set: (value: Uint8Array) => Effect.sync(() => void credentials.set(key, value)), + remove: Effect.sync(() => void credentials.delete(key)), + }; + }), }); }), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39a350f843f440e0d337f6c32403ea318eaad1c0..85e33ad5cabe2b5820fe500be6c861ff18574586 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-cursor': + specifier: workspace:* + version: link:../../packages/provider-cursor '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse @@ -600,6 +603,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-cursor': + specifier: workspace:* + version: link:../../packages/provider-cursor '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse @@ -736,6 +742,9 @@ importers: '@t3tools/provider-core': specifier: workspace:* version: link:../../packages/provider-core + '@t3tools/provider-cursor': + specifier: workspace:* + version: link:../../packages/provider-cursor '@t3tools/provider-muse': specifier: workspace:* version: link:../../packages/provider-muse @@ -1108,6 +1117,46 @@ importers: specifier: 1.0.0 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-cursor: + dependencies: + '@cursor/sdk': + specifier: 1.0.35 + version: 1.0.35 + '@napi-rs/keyring': + specifier: ^1.3.0 + version: 1.3.0 + '@t3tools/contracts': + specifier: workspace:* + version: link:../contracts + '@t3tools/provider-core': + specifier: workspace:* + version: link:../provider-core + '@t3tools/shared': + specifier: workspace:* + version: link:../shared + effect: + specifier: 4.0.1 + version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + yaml: + specifier: ^2.9.0 + version: 2.9.0 + devDependencies: + '@effect/platform-node': + specifier: 4.0.1 + version: 4.0.1(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(redis@6.2.1)(utf-8-validate@6.0.6) + '@effect/vitest': + specifier: 4.0.1 + version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@t3tools/provider-testing': + specifier: workspace:* + version: link:../provider-testing + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 1.0.0 + version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/provider-muse: dependencies: '@muse-code/sdk': diff --git a/scripts/lib/cursor-sdk-packaging.test.ts b/scripts/lib/cursor-sdk-packaging.test.ts index 1eabc6664e1ab6586c0deeeeffaeb1e29fca9da2..efaaa96b49548eac2e9a6db8fb5d21f523942ded 100644 --- a/scripts/lib/cursor-sdk-packaging.test.ts +++ b/scripts/lib/cursor-sdk-packaging.test.ts @@ -79,7 +79,7 @@ it("loads packaged Cursor catalog chunks without credentials or checkout depende entry, ` import assert from 'node:assert/strict'; - import { Cursor } from ${JSON.stringify(NodePath.join(repoRoot, "apps/server/src/provider/cursorSdk.ts"))}; + import { Cursor } from ${JSON.stringify(NodePath.join(repoRoot, "packages/provider-cursor/src/server/sdk.ts"))}; for (const [operation, request] of [ ['Cursor.models.list', () => Cursor.models.list({ apiKey: '' })], ['Cursor.me', () => Cursor.me({ apiKey: '' })], diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 400fc17efab4e3c477be224049a5222f6eea3345..6c5a1707854f31218b41c55d6ce76f49c50f938e 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -31,6 +31,7 @@ const workspaceFiles = [ "packages/provider-core/package.json", "packages/provider-testing/package.json", "packages/provider-pi/package.json", + "packages/provider-cursor/package.json", "packages/provider-muse/package.json", "packages/provider-opencode/package.json", "packages/effect-acp/package.json",