| author | |
| committer | |
| log | b5e4f1e0b0c2c06f183ab6a89072bec21c8334c4 |
| tree | e4e9c75a7de0a530f0cc449d2ed9f0d62899bbf4 |
| parent | e62161bc5b53389a462d066836d47d25df0f4134 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>91 files changed, 9161 insertions(+), 9089 deletions(-)
apps/mobile/package.json+1| ... | ... | @@ -70,6 +70,7 @@ |
| 70 | 70 | "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", |
| 71 | 71 | "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", |
| 72 | 72 | "@t3tools/provider-core": "workspace:*", |
| 73 | "@t3tools/provider-cursor": "workspace:*", | |
| 73 | 74 | "@t3tools/provider-muse": "workspace:*", |
| 74 | 75 | "@t3tools/provider-opencode": "workspace:*", |
| 75 | 76 | "@t3tools/provider-pi": "workspace:*", |
apps/mobile/src/components/ProviderIcon.tsx-11| ... | ... | @@ -109,17 +109,6 @@ export function ProviderIcon(props: ProviderIconProps) { |
| 109 | 109 | ); |
| 110 | 110 | } |
| 111 | 111 | |
| 112 | if (props.provider === "cursor") { | |
| 113 | return ( | |
| 114 | <Svg width={size} height={size} viewBox="0 0 466.73 532.09" fill="none"> | |
| 115 | <Path | |
| 116 | fill={isDarkMode ? "#EDECEC" : "#26251E"} | |
| 117 | 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" | |
| 118 | /> | |
| 119 | </Svg> | |
| 120 | ); | |
| 121 | } | |
| 122 | ||
| 123 | 112 | const packageIcon = getProviderClient(props.provider)?.icon; |
| 124 | 113 | if (packageIcon) { |
| 125 | 114 | const themeFill = (fill: { readonly light: string; readonly dark: string }) => |
apps/mobile/src/lib/providerClients.ts+7-1| ... | ... | @@ -1,11 +1,17 @@ |
| 1 | 1 | import { isProviderDriverKind } from "@t3tools/contracts"; |
| 2 | 2 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 3 | import { cursorClient } from "@t3tools/provider-cursor/client"; | |
| 3 | 4 | import { museClient } from "@t3tools/provider-muse/client"; |
| 4 | 5 | import { openCodeClient } from "@t3tools/provider-opencode/client"; |
| 5 | 6 | import { piClient } from "@t3tools/provider-pi/client"; |
| 6 | 7 | |
| 7 | 8 | /** The provider client definitions this mobile build ships. */ |
| 8 | const providerClients = makeProviderClientRegistry([museClient, openCodeClient, piClient]); | |
| 9 | const providerClients = makeProviderClientRegistry([ | |
| 10 | cursorClient, | |
| 11 | museClient, | |
| 12 | openCodeClient, | |
| 13 | piClient, | |
| 14 | ]); | |
| 9 | 15 | |
| 10 | 16 | /** The client definition for a driver kind, or `undefined` for drivers drawn by hand. */ |
| 11 | 17 | export function getProviderClient(driver: string | null | undefined) { |
apps/server/package.json+1| ... | ... | @@ -39,6 +39,7 @@ |
| 39 | 39 | "@noble/hashes": "catalog:", |
| 40 | 40 | "@opencode-ai/sdk": "^1.3.15", |
| 41 | 41 | "@t3tools/provider-core": "workspace:*", |
| 42 | "@t3tools/provider-cursor": "workspace:*", | |
| 42 | 43 | "@t3tools/provider-muse": "workspace:*", |
| 43 | 44 | "@t3tools/provider-opencode": "workspace:*", |
| 44 | 45 | "@t3tools/provider-pi": "workspace:*", |
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.test.ts deleted-956| ... | ... | @@ -1,956 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import type { InteractionUpdate } from "@cursor/sdk"; | |
| 4 | import { | |
| 5 | CursorSettings, | |
| 6 | EnvironmentId, | |
| 7 | MessageId, | |
| 8 | NodeId, | |
| 9 | ProjectId, | |
| 10 | ProviderInstanceId, | |
| 11 | ProviderSessionId, | |
| 12 | RunAttemptId, | |
| 13 | RunId, | |
| 14 | ThreadId, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import * as DateTime from "effect/DateTime"; | |
| 17 | import * as Effect from "effect/Effect"; | |
| 18 | import * as FileSystem from "effect/FileSystem"; | |
| 19 | import * as Layer from "effect/Layer"; | |
| 20 | import * as Option from "effect/Option"; | |
| 21 | import * as Path from "effect/Path"; | |
| 22 | import * as Schema from "effect/Schema"; | |
| 23 | import * as Stream from "effect/Stream"; | |
| 24 | ||
| 25 | import * as ServerConfig from "../../config.ts"; | |
| 26 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 27 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 28 | import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 29 | import { | |
| 30 | cursorMcpServers, | |
| 31 | cursorRuntimeAgentPolicy, | |
| 32 | cursorSdkModelSelection, | |
| 33 | makeCursorAgentOptions, | |
| 34 | makeCursorAdapterV2, | |
| 35 | nestedToolCallFromEnvelope, | |
| 36 | } from "./CursorAdapterV2.ts"; | |
| 37 | import { isCursorCancellationError, loggedCursorAgentOptions } from "./CursorAgentSdk.ts"; | |
| 38 | ||
| 39 | const decodeCursorSettings = Schema.decodeEffect(CursorSettings); | |
| 40 | ||
| 41 | describe("CursorAdapterV2", () => { | |
| 42 | it.effect.each([ | |
| 43 | { status: "finished", model: undefined, lateModel: undefined }, | |
| 44 | { status: "cancelled", model: "claude-opus-4-6", lateModel: undefined }, | |
| 45 | { status: "error", model: "custom-fable", lateModel: undefined }, | |
| 46 | { status: "finished", model: undefined, lateModel: "gpt-6-sol" }, | |
| 47 | { status: "finished", model: "gpt-6-sol", lateModel: null }, | |
| 48 | ] as const)( | |
| 49 | "projects Cursor tasks: $status, late model $lateModel", | |
| 50 | ({ status, model, lateModel }) => | |
| 51 | Effect.gen(function* () { | |
| 52 | const fileSystem = yield* FileSystem.FileSystem; | |
| 53 | const path = yield* Path.Path; | |
| 54 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ | |
| 55 | prefix: "cursor-v2-lifecycle-", | |
| 56 | }); | |
| 57 | const instanceId = ProviderInstanceId.make("cursor"); | |
| 58 | const threadId = ThreadId.make("cursor-lifecycle-thread"); | |
| 59 | const modelSelection = { instanceId, model: "composer-2.5" }; | |
| 60 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 61 | runtimeMode: "full-access", | |
| 62 | interactionMode: "default", | |
| 63 | cwd: workspace, | |
| 64 | }); | |
| 65 | const adapter = makeCursorAdapterV2({ | |
| 66 | instanceId, | |
| 67 | settings: yield* decodeCursorSettings({}), | |
| 68 | environment: { HOME: workspace }, | |
| 69 | fileSystem, | |
| 70 | path, | |
| 71 | idAllocator: yield* IdAllocator.IdAllocatorV2, | |
| 72 | serverConfig: yield* ServerConfig.ServerConfig.pipe( | |
| 73 | Effect.provide( | |
| 74 | ServerConfig.layerTest(workspace, { prefix: "cursor-v2-lifecycle-config-" }), | |
| 75 | ), | |
| 76 | ), | |
| 77 | runner: { | |
| 78 | assertComplete: Effect.void, | |
| 79 | open: () => | |
| 80 | Effect.succeed({ | |
| 81 | agentId: "native-cursor-lifecycle", | |
| 82 | listMessages: Effect.succeed([]), | |
| 83 | close: Effect.void, | |
| 84 | send: (input) => | |
| 85 | Effect.gen(function* () { | |
| 86 | // Recorded Cursor task calls (see fixtures/subagent) stream a | |
| 87 | // partial-tool-call before tool-call-started with the same args. | |
| 88 | // The run then ends without tool-call-completed, which a live | |
| 89 | // run cannot produce on demand. | |
| 90 | const taskToolCall = { | |
| 91 | type: "task" as const, | |
| 92 | args: { | |
| 93 | description: "Review", | |
| 94 | prompt: "Review the code.", | |
| 95 | subagentType: { kind: "unspecified" }, | |
| 96 | ...(model === undefined ? {} : { model }), | |
| 97 | agentId: "cursor-task-agent", | |
| 98 | mode: "unspecified" as const, | |
| 99 | }, | |
| 100 | }; | |
| 101 | const updates = | |
| 102 | lateModel !== undefined | |
| 103 | ? (["tool-call-started", "tool-call-completed"] as const) | |
| 104 | : (["partial-tool-call", "tool-call-started"] as const); | |
| 105 | for (const type of updates) { | |
| 106 | yield* input.onDelta!({ | |
| 107 | type, | |
| 108 | modelCallId: "model-call", | |
| 109 | callId: "task-call", | |
| 110 | toolCall: { | |
| 111 | ...taskToolCall, | |
| 112 | args: { | |
| 113 | ...taskToolCall.args, | |
| 114 | ...(type === "tool-call-completed" | |
| 115 | ? { model: lateModel ?? undefined } | |
| 116 | : {}), | |
| 117 | }, | |
| 118 | }, | |
| 119 | }).pipe(Effect.orDie); | |
| 120 | } | |
| 121 | return { | |
| 122 | agentId: "native-cursor-lifecycle", | |
| 123 | runId: "native-cursor-run", | |
| 124 | wait: Effect.succeed({ | |
| 125 | id: "native-cursor-run", | |
| 126 | requestId: "native-request", | |
| 127 | status, | |
| 128 | model: { id: "composer-2.5" }, | |
| 129 | durationMs: 1, | |
| 130 | }), | |
| 131 | cancel: Effect.void, | |
| 132 | }; | |
| 133 | }), | |
| 134 | }), | |
| 135 | }, | |
| 136 | }); | |
| 137 | const runtime = yield* adapter.openSession({ | |
| 138 | threadId, | |
| 139 | providerSessionId: ProviderSessionId.make("cursor-lifecycle-session"), | |
| 140 | modelSelection, | |
| 141 | runtimePolicy, | |
| 142 | }); | |
| 143 | const providerThread = yield* runtime.ensureThread({ | |
| 144 | threadId, | |
| 145 | modelSelection, | |
| 146 | runtimePolicy, | |
| 147 | }); | |
| 148 | const now = yield* DateTime.now; | |
| 149 | yield* runtime.startTurn({ | |
| 150 | threadId, | |
| 151 | providerThread, | |
| 152 | modelSelection, | |
| 153 | runtimePolicy, | |
| 154 | runId: RunId.make("cursor-lifecycle-run"), | |
| 155 | runOrdinal: 1, | |
| 156 | providerTurnOrdinal: 1, | |
| 157 | attemptId: RunAttemptId.make("cursor-lifecycle-attempt"), | |
| 158 | rootNodeId: NodeId.make("cursor-lifecycle-root"), | |
| 159 | appThread: { | |
| 160 | id: threadId, | |
| 161 | projectId: ProjectId.make("cursor-lifecycle-project"), | |
| 162 | createdBy: "user", | |
| 163 | creationSource: "web", | |
| 164 | title: "Cursor lifecycle", | |
| 165 | providerInstanceId: instanceId, | |
| 166 | modelSelection, | |
| 167 | runtimeMode: "full-access", | |
| 168 | interactionMode: "default", | |
| 169 | branch: null, | |
| 170 | worktreePath: null, | |
| 171 | activeProviderThreadId: providerThread.id, | |
| 172 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 173 | forkedFrom: null, | |
| 174 | createdAt: now, | |
| 175 | updatedAt: now, | |
| 176 | archivedAt: null, | |
| 177 | settledOverride: null, | |
| 178 | settledAt: null, | |
| 179 | lastVisitedAt: null, | |
| 180 | deletedAt: null, | |
| 181 | }, | |
| 182 | message: { | |
| 183 | messageId: MessageId.make("cursor-lifecycle-message"), | |
| 184 | createdBy: "user", | |
| 185 | creationSource: "web", | |
| 186 | text: "Review the code.", | |
| 187 | attachments: [], | |
| 188 | }, | |
| 189 | }); | |
| 190 | const events = yield* runtime.events.pipe( | |
| 191 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 192 | Stream.runCollect, | |
| 193 | ); | |
| 194 | const rows = events.filter((event) => event.type === "subagent.updated"); | |
| 195 | assert.equal(rows[0]?.subagent.status, "running"); | |
| 196 | assert.equal(rows[0]?.subagent.model, model ?? null); | |
| 197 | assert.equal(rows.at(-1)?.subagent.model, lateModel ?? model ?? null); | |
| 198 | assert.equal( | |
| 199 | rows.at(-1)?.subagent.status, | |
| 200 | lateModel !== undefined | |
| 201 | ? "completed" | |
| 202 | : status === "finished" | |
| 203 | ? "idle" | |
| 204 | : status === "cancelled" | |
| 205 | ? "cancelled" | |
| 206 | : "failed", | |
| 207 | ); | |
| 208 | assert.isNotNull(rows.at(-1)?.subagent.completedAt); | |
| 209 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), | |
| 210 | ); | |
| 211 | ||
| 212 | it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => | |
| 213 | Effect.gen(function* () { | |
| 214 | const fileSystem = yield* FileSystem.FileSystem; | |
| 215 | const path = yield* Path.Path; | |
| 216 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-errors-" }); | |
| 217 | const sentMessages: Array<string> = []; | |
| 218 | let sendCalls = 0; | |
| 219 | const instanceId = ProviderInstanceId.make("cursor"); | |
| 220 | const threadId = ThreadId.make("cursor-transport-error-thread"); | |
| 221 | const modelSelection = { instanceId, model: "composer-2.5" }; | |
| 222 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 223 | runtimeMode: "full-access", | |
| 224 | interactionMode: "default", | |
| 225 | cwd: workspace, | |
| 226 | }); | |
| 227 | const adapter = makeCursorAdapterV2({ | |
| 228 | instanceId, | |
| 229 | settings: yield* decodeCursorSettings({}), | |
| 230 | environment: { HOME: workspace }, | |
| 231 | fileSystem, | |
| 232 | path, | |
| 233 | idAllocator: yield* IdAllocator.IdAllocatorV2, | |
| 234 | serverConfig: yield* ServerConfig.ServerConfig.pipe( | |
| 235 | Effect.provide(ServerConfig.layerTest(workspace, { prefix: "cursor-v2-error-config-" })), | |
| 236 | ), | |
| 237 | runner: { | |
| 238 | assertComplete: Effect.void, | |
| 239 | open: () => | |
| 240 | Effect.succeed({ | |
| 241 | agentId: "native-cursor-error", | |
| 242 | listMessages: Effect.succeed([]), | |
| 243 | close: Effect.void, | |
| 244 | send: (input) => | |
| 245 | Effect.sync(() => { | |
| 246 | sendCalls += 1; | |
| 247 | sentMessages.push( | |
| 248 | typeof input.message === "string" ? input.message : input.message.text, | |
| 249 | ); | |
| 250 | const runId = `native-cursor-run-${sendCalls}`; | |
| 251 | return { | |
| 252 | agentId: "native-cursor-error", | |
| 253 | runId, | |
| 254 | wait: Effect.succeed({ | |
| 255 | id: runId, | |
| 256 | requestId: `native-request-${sendCalls}`, | |
| 257 | status: "finished" as const, | |
| 258 | model: { id: "composer-2.5" }, | |
| 259 | durationMs: 1, | |
| 260 | ...(sendCalls === 1 | |
| 261 | ? { result: "Error: RetriableError: WritableIterable is closed" } | |
| 262 | : {}), | |
| 263 | }), | |
| 264 | cancel: Effect.void, | |
| 265 | }; | |
| 266 | }), | |
| 267 | }), | |
| 268 | }, | |
| 269 | }); | |
| 270 | const runtime = yield* adapter.openSession({ | |
| 271 | threadId, | |
| 272 | providerSessionId: ProviderSessionId.make("cursor-error-session"), | |
| 273 | modelSelection, | |
| 274 | runtimePolicy, | |
| 275 | }); | |
| 276 | const providerThread = yield* runtime.ensureThread({ | |
| 277 | threadId, | |
| 278 | modelSelection, | |
| 279 | runtimePolicy, | |
| 280 | }); | |
| 281 | const now = yield* DateTime.now; | |
| 282 | const appThread = { | |
| 283 | id: threadId, | |
| 284 | projectId: ProjectId.make("cursor-error-project"), | |
| 285 | createdBy: "user" as const, | |
| 286 | creationSource: "web" as const, | |
| 287 | title: "Cursor transport failure", | |
| 288 | providerInstanceId: instanceId, | |
| 289 | modelSelection, | |
| 290 | runtimeMode: "full-access" as const, | |
| 291 | interactionMode: "default" as const, | |
| 292 | branch: null, | |
| 293 | worktreePath: null, | |
| 294 | activeProviderThreadId: providerThread.id, | |
| 295 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 296 | forkedFrom: null, | |
| 297 | createdAt: now, | |
| 298 | updatedAt: now, | |
| 299 | archivedAt: null, | |
| 300 | settledOverride: null, | |
| 301 | settledAt: null, | |
| 302 | lastVisitedAt: null, | |
| 303 | deletedAt: null, | |
| 304 | }; | |
| 305 | yield* runtime.startTurn({ | |
| 306 | threadId, | |
| 307 | providerThread, | |
| 308 | modelSelection, | |
| 309 | runtimePolicy, | |
| 310 | runId: RunId.make("cursor-error-run"), | |
| 311 | runOrdinal: 1, | |
| 312 | providerTurnOrdinal: 1, | |
| 313 | attemptId: RunAttemptId.make("cursor-error-attempt"), | |
| 314 | rootNodeId: NodeId.make("cursor-error-root"), | |
| 315 | appThread, | |
| 316 | message: { | |
| 317 | messageId: MessageId.make("cursor-error-message"), | |
| 318 | createdBy: "user", | |
| 319 | creationSource: "web", | |
| 320 | text: "continue", | |
| 321 | attachments: [], | |
| 322 | }, | |
| 323 | }); | |
| 324 | const first = yield* runtime.events.pipe( | |
| 325 | Stream.filter((event) => event.type === "turn.terminal"), | |
| 326 | Stream.runHead, | |
| 327 | ); | |
| 328 | assert.isTrue(Option.isSome(first)); | |
| 329 | if (Option.isSome(first)) { | |
| 330 | assert.equal(first.value.status, "failed"); | |
| 331 | assert.equal(first.value.failure?.class, "transport_error"); | |
| 332 | } | |
| 333 | ||
| 334 | assert.isDefined(runtime.compactThread); | |
| 335 | yield* runtime.compactThread!({ | |
| 336 | threadId, | |
| 337 | providerThread, | |
| 338 | modelSelection, | |
| 339 | runtimePolicy, | |
| 340 | runId: RunId.make("cursor-compact-run"), | |
| 341 | runOrdinal: 2, | |
| 342 | providerTurnOrdinal: 2, | |
| 343 | attemptId: RunAttemptId.make("cursor-compact-attempt"), | |
| 344 | rootNodeId: NodeId.make("cursor-compact-root"), | |
| 345 | appThread, | |
| 346 | message: { | |
| 347 | messageId: MessageId.make("cursor-compact-message"), | |
| 348 | createdBy: "user", | |
| 349 | creationSource: "web", | |
| 350 | text: "ignored by compactThread", | |
| 351 | attachments: [], | |
| 352 | }, | |
| 353 | }); | |
| 354 | yield* runtime.events.pipe( | |
| 355 | Stream.filter((event) => event.type === "turn.terminal"), | |
| 356 | Stream.runHead, | |
| 357 | ); | |
| 358 | assert.equal(sentMessages[1], "/compress"); | |
| 359 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), | |
| 360 | ); | |
| 361 | ||
| 362 | it.effect("projects Cursor directory trees and lint diagnostics as file search results", () => | |
| 363 | Effect.gen(function* () { | |
| 364 | const fileSystem = yield* FileSystem.FileSystem; | |
| 365 | const path = yield* Path.Path; | |
| 366 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-search-" }); | |
| 367 | const instanceId = ProviderInstanceId.make("cursor"); | |
| 368 | const threadId = ThreadId.make("cursor-search-thread"); | |
| 369 | const modelSelection = { instanceId, model: "composer-2.5" }; | |
| 370 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 371 | runtimeMode: "full-access", | |
| 372 | interactionMode: "default", | |
| 373 | cwd: workspace, | |
| 374 | }); | |
| 375 | const emptyLsNode = { | |
| 376 | childrenDirs: [], | |
| 377 | childrenFiles: [], | |
| 378 | childrenWereProcessed: true, | |
| 379 | fullSubtreeExtensionCounts: {}, | |
| 380 | numFiles: 0, | |
| 381 | }; | |
| 382 | const updates: ReadonlyArray<InteractionUpdate> = [ | |
| 383 | ...(["tool-call-started", "tool-call-completed"] as const).map((type) => ({ | |
| 384 | type, | |
| 385 | modelCallId: "native-model-call", | |
| 386 | callId: "mcp-weather", | |
| 387 | toolCall: { | |
| 388 | type: "mcp" as const, | |
| 389 | args: { | |
| 390 | providerIdentifier: "weather", | |
| 391 | toolName: "get_weather", | |
| 392 | args: { city: "Berlin" }, | |
| 393 | }, | |
| 394 | ...(type === "tool-call-completed" | |
| 395 | ? { result: { status: "success" as const, value: { content: [], isError: false } } } | |
| 396 | : {}), | |
| 397 | }, | |
| 398 | })), | |
| 399 | { | |
| 400 | type: "tool-call-completed", | |
| 401 | modelCallId: "native-model-call", | |
| 402 | callId: "read-file", | |
| 403 | toolCall: { | |
| 404 | type: "read", | |
| 405 | args: { path: "src/env.ts" }, | |
| 406 | result: { | |
| 407 | status: "success", | |
| 408 | value: { fileSize: 12, content: "---\nfile body", totalLines: 2 }, | |
| 409 | }, | |
| 410 | }, | |
| 411 | }, | |
| 412 | { | |
| 413 | type: "tool-call-completed", | |
| 414 | modelCallId: "native-model-call", | |
| 415 | callId: "ls-nested", | |
| 416 | toolCall: { | |
| 417 | type: "ls", | |
| 418 | args: { path: workspace }, | |
| 419 | result: { | |
| 420 | status: "success", | |
| 421 | value: { | |
| 422 | directoryTreeRoot: { | |
| 423 | ...emptyLsNode, | |
| 424 | absPath: workspace, | |
| 425 | childrenFiles: [{ name: "README.md" }], | |
| 426 | childrenDirs: [ | |
| 427 | { | |
| 428 | ...emptyLsNode, | |
| 429 | absPath: path.join(workspace, "src"), | |
| 430 | childrenFiles: [{ name: "index.ts" }], | |
| 431 | childrenDirs: [ | |
| 432 | { | |
| 433 | ...emptyLsNode, | |
| 434 | absPath: path.join(workspace, "src", "nested"), | |
| 435 | childrenFiles: [{ name: "util.ts" }], | |
| 436 | }, | |
| 437 | ], | |
| 438 | }, | |
| 439 | ], | |
| 440 | }, | |
| 441 | }, | |
| 442 | }, | |
| 443 | }, | |
| 444 | }, | |
| 445 | { | |
| 446 | type: "tool-call-completed", | |
| 447 | modelCallId: "native-model-call", | |
| 448 | callId: "ls-empty", | |
| 449 | toolCall: { | |
| 450 | type: "ls", | |
| 451 | args: { path: path.join(workspace, "empty") }, | |
| 452 | result: { | |
| 453 | status: "success", | |
| 454 | value: { | |
| 455 | directoryTreeRoot: { ...emptyLsNode, absPath: path.join(workspace, "empty") }, | |
| 456 | }, | |
| 457 | }, | |
| 458 | }, | |
| 459 | }, | |
| 460 | { | |
| 461 | type: "tool-call-completed", | |
| 462 | modelCallId: "native-model-call", | |
| 463 | callId: "ls-failed", | |
| 464 | toolCall: { | |
| 465 | type: "ls", | |
| 466 | args: { path: path.join(workspace, "missing") }, | |
| 467 | result: { status: "error", error: "ENOENT" }, | |
| 468 | }, | |
| 469 | }, | |
| 470 | { | |
| 471 | type: "tool-call-completed", | |
| 472 | modelCallId: "native-model-call", | |
| 473 | callId: "grep-failed", | |
| 474 | toolCall: { | |
| 475 | type: "grep", | |
| 476 | args: { pattern: "TODO", path: "src" }, | |
| 477 | result: { status: "error", error: "search failed" }, | |
| 478 | }, | |
| 479 | }, | |
| 480 | { | |
| 481 | type: "tool-call-completed", | |
| 482 | modelCallId: "native-model-call", | |
| 483 | callId: "glob-failed", | |
| 484 | toolCall: { | |
| 485 | type: "glob", | |
| 486 | args: { globPattern: "*.ts" }, | |
| 487 | result: { status: "error", error: "search failed" }, | |
| 488 | }, | |
| 489 | }, | |
| 490 | { | |
| 491 | type: "tool-call-completed", | |
| 492 | modelCallId: "native-model-call", | |
| 493 | callId: "lints", | |
| 494 | toolCall: { | |
| 495 | type: "readLints", | |
| 496 | args: { paths: ["src/a.ts", "src/b.ts"] }, | |
| 497 | result: { | |
| 498 | status: "success", | |
| 499 | value: { | |
| 500 | totalFiles: 2, | |
| 501 | totalDiagnostics: 3, | |
| 502 | fileDiagnostics: [ | |
| 503 | { | |
| 504 | path: "src/a.ts", | |
| 505 | diagnosticsCount: 2, | |
| 506 | diagnostics: [ | |
| 507 | { | |
| 508 | message: "Unused variable", | |
| 509 | code: "TS6133", | |
| 510 | source: "ts", | |
| 511 | severity: "warning", | |
| 512 | range: { start: { line: 0, character: 4 } }, | |
| 513 | }, | |
| 514 | { | |
| 515 | message: "Cannot find name", | |
| 516 | code: "TS2304", | |
| 517 | source: "ts", | |
| 518 | severity: "error", | |
| 519 | range: { start: { line: 11 } }, | |
| 520 | }, | |
| 521 | ], | |
| 522 | }, | |
| 523 | { | |
| 524 | path: "src/b.ts", | |
| 525 | diagnosticsCount: 1, | |
| 526 | diagnostics: [ | |
| 527 | { | |
| 528 | message: "File-level diagnostic", | |
| 529 | code: "TS0", | |
| 530 | source: "ts", | |
| 531 | severity: "information", | |
| 532 | }, | |
| 533 | ], | |
| 534 | }, | |
| 535 | ], | |
| 536 | }, | |
| 537 | }, | |
| 538 | }, | |
| 539 | }, | |
| 540 | { | |
| 541 | type: "tool-call-completed", | |
| 542 | modelCallId: "native-model-call", | |
| 543 | callId: "lints-empty", | |
| 544 | toolCall: { | |
| 545 | type: "readLints", | |
| 546 | args: { paths: ["src/clean.ts"] }, | |
| 547 | result: { | |
| 548 | status: "success", | |
| 549 | value: { | |
| 550 | totalFiles: 1, | |
| 551 | totalDiagnostics: 0, | |
| 552 | fileDiagnostics: [{ path: "src/clean.ts", diagnosticsCount: 0, diagnostics: [] }], | |
| 553 | }, | |
| 554 | }, | |
| 555 | }, | |
| 556 | }, | |
| 557 | { | |
| 558 | type: "tool-call-completed", | |
| 559 | modelCallId: "native-model-call", | |
| 560 | callId: "lints-failed", | |
| 561 | toolCall: { | |
| 562 | type: "readLints", | |
| 563 | args: { paths: ["src/a.ts"] }, | |
| 564 | result: { status: "error", error: "lint failed" }, | |
| 565 | }, | |
| 566 | }, | |
| 567 | ]; | |
| 568 | const adapter = makeCursorAdapterV2({ | |
| 569 | instanceId, | |
| 570 | settings: yield* decodeCursorSettings({}), | |
| 571 | environment: { HOME: workspace }, | |
| 572 | fileSystem, | |
| 573 | path, | |
| 574 | idAllocator: yield* IdAllocator.IdAllocatorV2, | |
| 575 | serverConfig: yield* ServerConfig.ServerConfig.pipe( | |
| 576 | Effect.provide(ServerConfig.layerTest(workspace, { prefix: "cursor-v2-search-config-" })), | |
| 577 | ), | |
| 578 | runner: { | |
| 579 | assertComplete: Effect.void, | |
| 580 | open: () => | |
| 581 | Effect.succeed({ | |
| 582 | agentId: "native-cursor-search", | |
| 583 | listMessages: Effect.succeed([]), | |
| 584 | close: Effect.void, | |
| 585 | send: (input) => | |
| 586 | Effect.gen(function* () { | |
| 587 | for (const update of updates) { | |
| 588 | if (input.onDelta !== undefined) { | |
| 589 | yield* input.onDelta(update).pipe(Effect.orDie); | |
| 590 | } | |
| 591 | } | |
| 592 | return { | |
| 593 | agentId: "native-cursor-search", | |
| 594 | runId: "native-cursor-run", | |
| 595 | wait: Effect.succeed({ | |
| 596 | id: "native-cursor-run", | |
| 597 | requestId: "native-request", | |
| 598 | status: "finished" as const, | |
| 599 | model: { id: "composer-2.5" }, | |
| 600 | durationMs: 1, | |
| 601 | }), | |
| 602 | cancel: Effect.void, | |
| 603 | }; | |
| 604 | }), | |
| 605 | }), | |
| 606 | }, | |
| 607 | }); | |
| 608 | const runtime = yield* adapter.openSession({ | |
| 609 | threadId, | |
| 610 | providerSessionId: ProviderSessionId.make("cursor-search-session"), | |
| 611 | modelSelection, | |
| 612 | runtimePolicy, | |
| 613 | }); | |
| 614 | const providerThread = yield* runtime.ensureThread({ | |
| 615 | threadId, | |
| 616 | modelSelection, | |
| 617 | runtimePolicy, | |
| 618 | }); | |
| 619 | const now = yield* DateTime.now; | |
| 620 | yield* runtime.startTurn({ | |
| 621 | threadId, | |
| 622 | providerThread, | |
| 623 | modelSelection, | |
| 624 | runtimePolicy, | |
| 625 | runId: RunId.make("cursor-search-run"), | |
| 626 | runOrdinal: 1, | |
| 627 | providerTurnOrdinal: 1, | |
| 628 | attemptId: RunAttemptId.make("cursor-search-attempt"), | |
| 629 | rootNodeId: NodeId.make("cursor-search-root"), | |
| 630 | appThread: { | |
| 631 | id: threadId, | |
| 632 | projectId: ProjectId.make("cursor-search-project"), | |
| 633 | createdBy: "user", | |
| 634 | creationSource: "web", | |
| 635 | title: "Cursor search results", | |
| 636 | providerInstanceId: instanceId, | |
| 637 | modelSelection, | |
| 638 | runtimeMode: "full-access", | |
| 639 | interactionMode: "default", | |
| 640 | branch: null, | |
| 641 | worktreePath: null, | |
| 642 | activeProviderThreadId: providerThread.id, | |
| 643 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 644 | forkedFrom: null, | |
| 645 | createdAt: now, | |
| 646 | updatedAt: now, | |
| 647 | archivedAt: null, | |
| 648 | settledOverride: null, | |
| 649 | settledAt: null, | |
| 650 | lastVisitedAt: null, | |
| 651 | deletedAt: null, | |
| 652 | }, | |
| 653 | message: { | |
| 654 | messageId: MessageId.make("cursor-search-message"), | |
| 655 | createdBy: "user", | |
| 656 | creationSource: "web", | |
| 657 | text: "inspect the workspace", | |
| 658 | attachments: [], | |
| 659 | }, | |
| 660 | }); | |
| 661 | const events = yield* runtime.events.pipe( | |
| 662 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 663 | Stream.runCollect, | |
| 664 | ); | |
| 665 | const mcpItems = events.flatMap((event) => | |
| 666 | event.type === "turn_item.updated" && | |
| 667 | event.turnItem.type === "dynamic_tool" && | |
| 668 | event.turnItem.toolName === "mcp__weather__get_weather" | |
| 669 | ? [event.turnItem] | |
| 670 | : [], | |
| 671 | ); | |
| 672 | assert.deepEqual( | |
| 673 | mcpItems.map((item) => item.status), | |
| 674 | ["running", "completed"], | |
| 675 | ); | |
| 676 | for (const item of mcpItems) { | |
| 677 | assert.equal(item.title, "get weather"); | |
| 678 | assert.deepEqual(item.toolSource, { | |
| 679 | key: "mcp:weather", | |
| 680 | name: "weather", | |
| 681 | kind: "integration", | |
| 682 | }); | |
| 683 | assert.deepEqual(item.input, { | |
| 684 | providerIdentifier: "weather", | |
| 685 | toolName: "get_weather", | |
| 686 | args: { city: "Berlin" }, | |
| 687 | }); | |
| 688 | } | |
| 689 | const fileSearchItems = events.flatMap((event) => | |
| 690 | event.type === "turn_item.updated" && | |
| 691 | event.turnItem.type === "file_search" && | |
| 692 | event.turnItem.status !== "running" | |
| 693 | ? [event.turnItem] | |
| 694 | : [], | |
| 695 | ); | |
| 696 | const readItems = events.flatMap((event) => | |
| 697 | event.type === "turn_item.updated" && | |
| 698 | event.turnItem.type === "dynamic_tool" && | |
| 699 | event.turnItem.toolName === "Read" && | |
| 700 | event.turnItem.status === "completed" | |
| 701 | ? [event.turnItem] | |
| 702 | : [], | |
| 703 | ); | |
| 704 | assert.deepEqual( | |
| 705 | readItems.map((item) => ({ title: item.title, input: item.input })), | |
| 706 | [{ title: "Read src/env.ts", input: { path: "src/env.ts" } }], | |
| 707 | ); | |
| 708 | assert.deepEqual( | |
| 709 | fileSearchItems.map((item) => ({ | |
| 710 | pattern: item.pattern, | |
| 711 | status: item.status, | |
| 712 | results: item.results, | |
| 713 | })), | |
| 714 | [ | |
| 715 | { | |
| 716 | pattern: workspace, | |
| 717 | status: "completed", | |
| 718 | results: [ | |
| 719 | { fileName: path.join(workspace, "README.md") }, | |
| 720 | { fileName: path.join(workspace, "src", "index.ts") }, | |
| 721 | { fileName: path.join(workspace, "src", "nested", "util.ts") }, | |
| 722 | ], | |
| 723 | }, | |
| 724 | { | |
| 725 | pattern: path.join(workspace, "empty"), | |
| 726 | status: "completed", | |
| 727 | results: undefined, | |
| 728 | }, | |
| 729 | { | |
| 730 | pattern: path.join(workspace, "missing"), | |
| 731 | status: "failed", | |
| 732 | results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }], | |
| 733 | }, | |
| 734 | { | |
| 735 | pattern: "TODO", | |
| 736 | status: "failed", | |
| 737 | results: [{ fileName: "src", preview: "search failed" }], | |
| 738 | }, | |
| 739 | { | |
| 740 | pattern: "*.ts", | |
| 741 | status: "failed", | |
| 742 | results: [{ fileName: ".", preview: "search failed" }], | |
| 743 | }, | |
| 744 | { | |
| 745 | pattern: "src/a.ts, src/b.ts", | |
| 746 | status: "completed", | |
| 747 | results: [ | |
| 748 | { fileName: "src/a.ts", line: 1, column: 5, preview: "Unused variable" }, | |
| 749 | { fileName: "src/a.ts", line: 12, preview: "Cannot find name" }, | |
| 750 | { fileName: "src/b.ts", preview: "File-level diagnostic" }, | |
| 751 | ], | |
| 752 | }, | |
| 753 | { | |
| 754 | pattern: "src/clean.ts", | |
| 755 | status: "completed", | |
| 756 | results: undefined, | |
| 757 | }, | |
| 758 | { | |
| 759 | pattern: "src/a.ts", | |
| 760 | status: "failed", | |
| 761 | results: [{ fileName: "src/a.ts", preview: "lint failed" }], | |
| 762 | }, | |
| 763 | ], | |
| 764 | ); | |
| 765 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), | |
| 766 | ); | |
| 767 | ||
| 768 | it("maps Cursor auto and model parameters to SDK selections", () => { | |
| 769 | assert.deepEqual( | |
| 770 | cursorSdkModelSelection({ | |
| 771 | instanceId: ProviderInstanceId.make("cursor"), | |
| 772 | model: "auto", | |
| 773 | options: [ | |
| 774 | { id: "thinking", value: "high" }, | |
| 775 | { id: "contextWindow", value: "1m" }, | |
| 776 | { id: "fastMode", value: true }, | |
| 777 | ], | |
| 778 | }), | |
| 779 | { | |
| 780 | id: "default", | |
| 781 | params: [ | |
| 782 | { id: "thinking", value: "high" }, | |
| 783 | { id: "context", value: "1m" }, | |
| 784 | { id: "fast", value: "true" }, | |
| 785 | ], | |
| 786 | }, | |
| 787 | ); | |
| 788 | }); | |
| 789 | ||
| 790 | it("maps runtime modes to the SDK sandbox and auto-review controls", () => { | |
| 791 | const base = { | |
| 792 | interactionMode: "default" as const, | |
| 793 | cwd: "/tmp/cursor-adapter", | |
| 794 | }; | |
| 795 | assert.deepEqual( | |
| 796 | cursorRuntimeAgentPolicy({ | |
| 797 | ...base, | |
| 798 | runtimeMode: "full-access", | |
| 799 | }), | |
| 800 | { | |
| 801 | autoReview: false, | |
| 802 | sandboxEnabled: false, | |
| 803 | }, | |
| 804 | ); | |
| 805 | assert.deepEqual( | |
| 806 | cursorRuntimeAgentPolicy({ | |
| 807 | ...base, | |
| 808 | runtimeMode: "auto-accept-edits", | |
| 809 | }), | |
| 810 | { | |
| 811 | autoReview: false, | |
| 812 | sandboxEnabled: true, | |
| 813 | }, | |
| 814 | ); | |
| 815 | assert.deepEqual( | |
| 816 | cursorRuntimeAgentPolicy({ | |
| 817 | ...base, | |
| 818 | runtimeMode: "approval-required", | |
| 819 | }), | |
| 820 | { | |
| 821 | autoReview: true, | |
| 822 | sandboxEnabled: true, | |
| 823 | }, | |
| 824 | ); | |
| 825 | assert.deepEqual( | |
| 826 | cursorRuntimeAgentPolicy({ | |
| 827 | ...base, | |
| 828 | runtimeMode: "full-access", | |
| 829 | approvalPolicy: "never", | |
| 830 | sandboxPolicy: { type: "readOnly" }, | |
| 831 | }), | |
| 832 | { | |
| 833 | autoReview: false, | |
| 834 | sandboxEnabled: true, | |
| 835 | }, | |
| 836 | ); | |
| 837 | assert.deepEqual( | |
| 838 | cursorRuntimeAgentPolicy({ | |
| 839 | ...base, | |
| 840 | runtimeMode: "approval-required", | |
| 841 | approvalPolicy: "never", | |
| 842 | sandboxPolicy: { type: "dangerFullAccess" }, | |
| 843 | }), | |
| 844 | { | |
| 845 | autoReview: false, | |
| 846 | sandboxEnabled: false, | |
| 847 | }, | |
| 848 | ); | |
| 849 | }); | |
| 850 | ||
| 851 | it("loads the user's Cursor settings layers in every runtime mode", () => { | |
| 852 | // The SDK loads no rules, skills, hooks, or MCP config from disk unless | |
| 853 | // settingSources names them, so an omitted list silently drops AGENTS.md. | |
| 854 | for (const runtimeMode of ["full-access", "auto-accept-edits", "approval-required"] as const) { | |
| 855 | const options = makeCursorAgentOptions({ | |
| 856 | modelSelection: { | |
| 857 | instanceId: ProviderInstanceId.make("cursor"), | |
| 858 | model: "composer-2.5", | |
| 859 | }, | |
| 860 | runtimePolicy: { runtimeMode, interactionMode: "default", cwd: "/workspace" }, | |
| 861 | threadId: ThreadId.make("thread-cursor-setting-sources"), | |
| 862 | }); | |
| 863 | assert.deepEqual(options.local?.settingSources, [ | |
| 864 | "project", | |
| 865 | "user", | |
| 866 | "team", | |
| 867 | "mdm", | |
| 868 | "plugins", | |
| 869 | ]); | |
| 870 | } | |
| 871 | }); | |
| 872 | ||
| 873 | it("injects thread-scoped MCP credentials without logging them", () => { | |
| 874 | const threadId = ThreadId.make("thread-cursor-mcp"); | |
| 875 | McpProviderSession.setMcpProviderSession({ | |
| 876 | environmentId: EnvironmentId.make("environment-cursor-mcp"), | |
| 877 | threadId, | |
| 878 | providerSessionId: "mcp-session-cursor", | |
| 879 | providerInstanceId: ProviderInstanceId.make("cursor"), | |
| 880 | endpoint: "http://127.0.0.1:43123/mcp", | |
| 881 | authorizationHeader: "Bearer secret-cursor-mcp-token", | |
| 882 | browserToolsAvailable: true, | |
| 883 | }); | |
| 884 | ||
| 885 | try { | |
| 886 | assert.deepEqual(cursorMcpServers(threadId), { | |
| 887 | "t3-code": { | |
| 888 | type: "http", | |
| 889 | url: "http://127.0.0.1:43123/mcp", | |
| 890 | headers: { | |
| 891 | Authorization: "Bearer secret-cursor-mcp-token", | |
| 892 | }, | |
| 893 | }, | |
| 894 | }); | |
| 895 | ||
| 896 | const options = makeCursorAgentOptions({ | |
| 897 | apiKey: "secret-cursor-api-key", | |
| 898 | modelSelection: { | |
| 899 | instanceId: ProviderInstanceId.make("cursor"), | |
| 900 | model: "composer-2.5", | |
| 901 | }, | |
| 902 | runtimePolicy: { | |
| 903 | runtimeMode: "full-access", | |
| 904 | interactionMode: "default", | |
| 905 | cwd: "/workspace", | |
| 906 | }, | |
| 907 | threadId, | |
| 908 | }); | |
| 909 | assert.deepEqual(options.mcpServers, cursorMcpServers(threadId)); | |
| 910 | ||
| 911 | const logged = JSON.stringify(loggedCursorAgentOptions(options)); | |
| 912 | assert.notInclude(logged, "secret-cursor-api-key"); | |
| 913 | assert.notInclude(logged, "secret-cursor-mcp-token"); | |
| 914 | } finally { | |
| 915 | McpProviderSession.clearMcpProviderSession(threadId); | |
| 916 | } | |
| 917 | }); | |
| 918 | ||
| 919 | it("recognizes direct and SDK-wrapped abort failures as cancellation", () => { | |
| 920 | assert.isTrue(isCursorCancellationError({ name: "AbortError" })); | |
| 921 | assert.isTrue( | |
| 922 | isCursorCancellationError({ | |
| 923 | name: "ConnectError", | |
| 924 | cause: { | |
| 925 | name: "ConnectError", | |
| 926 | cause: { name: "AbortError" }, | |
| 927 | }, | |
| 928 | }), | |
| 929 | ); | |
| 930 | assert.isFalse(isCursorCancellationError(new Error("request failed"))); | |
| 931 | assert.isFalse(isCursorCancellationError(null)); | |
| 932 | }); | |
| 933 | ||
| 934 | it("preserves failed nested read calls when Cursor omits their path", () => { | |
| 935 | assert.deepEqual( | |
| 936 | nestedToolCallFromEnvelope({ | |
| 937 | toolCallId: "tool:failed-read", | |
| 938 | readToolCall: { | |
| 939 | args: {}, | |
| 940 | result: { error: "File path was not provided." }, | |
| 941 | }, | |
| 942 | }), | |
| 943 | { | |
| 944 | callId: "tool:failed-read", | |
| 945 | toolCall: { | |
| 946 | type: "read", | |
| 947 | args: { path: "<unknown path>" }, | |
| 948 | result: { | |
| 949 | status: "error", | |
| 950 | error: "File path was not provided.", | |
| 951 | }, | |
| 952 | }, | |
| 953 | }, | |
| 954 | ); | |
| 955 | }); | |
| 956 | }); |
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts+5-98| ... | ... | @@ -1,6 +1,4 @@ |
| 1 | 1 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 2 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; | |
| 3 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; | |
| 4 | 2 | import type { InteractionUpdate, RunResult } from "@cursor/sdk"; |
| 5 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 6 | 4 | import { |
| ... | ... | @@ -15,23 +13,21 @@ import * as Effect from "effect/Effect"; |
| 15 | 13 | import * as Fiber from "effect/Fiber"; |
| 16 | 14 | import * as FileSystem from "effect/FileSystem"; |
| 17 | 15 | import * as Layer from "effect/Layer"; |
| 18 | import * as Path from "effect/Path"; | |
| 19 | import * as PlatformError from "effect/PlatformError"; | |
| 20 | 16 | import * as Schema from "effect/Schema"; |
| 21 | 17 | |
| 22 | import * as ServerConfig from "../../config.ts"; | |
| 18 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 23 | 19 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 24 | 20 | import { ProviderAdapterDriverCreateError } from "@t3tools/provider-core/server/adapterDriver"; |
| 25 | 21 | import * as ProviderAdapterRegistry from "../ProviderAdapterRegistry.ts"; |
| 26 | 22 | import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; |
| 27 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | |
| 23 | import { CursorAdapterV2Driver } from "@t3tools/provider-cursor/server"; | |
| 24 | import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; | |
| 28 | 25 | import { |
| 29 | 26 | CURSOR_DEFAULT_INSTANCE_ID, |
| 30 | 27 | CURSOR_DRIVER_KIND, |
| 31 | CursorAdapterV2Driver, | |
| 32 | 28 | cursorSdkModelSelection, |
| 33 | 29 | makeCursorAgentOptions, |
| 34 | } from "./CursorAdapterV2.ts"; | |
| 30 | } from "@t3tools/provider-cursor/testing"; | |
| 35 | 31 | import type { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; |
| 36 | 32 | import type { RuntimePolicyV2Override } from "../RuntimePolicy.ts"; |
| 37 | 33 | |
| ... | ... | @@ -513,91 +509,6 @@ function layerCursorAgentSdkReplay( |
| 513 | 509 | ); |
| 514 | 510 | } |
| 515 | 511 | |
| 516 | function makeReplayServerConfig( | |
| 517 | scenario: string, | |
| 518 | ): Effect.Effect< | |
| 519 | ServerConfig.ServerConfig["Service"], | |
| 520 | PlatformError.PlatformError, | |
| 521 | FileSystem.FileSystem | Path.Path | |
| 522 | > { | |
| 523 | return Effect.gen(function* () { | |
| 524 | const fs = yield* FileSystem.FileSystem; | |
| 525 | const path = yield* Path.Path; | |
| 526 | const baseDir = yield* fs.makeTempDirectory({ | |
| 527 | prefix: `t3-orchestration-v2-cursor-${scenario}-`, | |
| 528 | }); | |
| 529 | const stateDir = path.join(baseDir, "userdata"); | |
| 530 | const logsDir = path.join(stateDir, "logs"); | |
| 531 | const providerLogsDir = path.join(logsDir, "provider"); | |
| 532 | const terminalLogsDir = path.join(logsDir, "terminals"); | |
| 533 | const attachmentsDir = path.join(stateDir, "attachments"); | |
| 534 | const environmentThemesDir = path.join(stateDir, "themes"); | |
| 535 | const worktreesDir = path.join(baseDir, "worktrees"); | |
| 536 | const providerStatusCacheDir = path.join(baseDir, "caches"); | |
| 537 | for (const directory of [ | |
| 538 | stateDir, | |
| 539 | logsDir, | |
| 540 | providerLogsDir, | |
| 541 | terminalLogsDir, | |
| 542 | attachmentsDir, | |
| 543 | environmentThemesDir, | |
| 544 | worktreesDir, | |
| 545 | providerStatusCacheDir, | |
| 546 | ]) { | |
| 547 | yield* fs.makeDirectory(directory, { recursive: true }); | |
| 548 | } | |
| 549 | return { | |
| 550 | logLevel: "Error", | |
| 551 | traceMinLevel: "Info", | |
| 552 | traceTimingEnabled: true, | |
| 553 | traceBatchWindowMs: 200, | |
| 554 | traceMaxBytes: 10 * 1024 * 1024, | |
| 555 | traceMaxFiles: 10, | |
| 556 | otelEnvironment: OtelEnvironment.none, | |
| 557 | otlpTracesUrl: undefined, | |
| 558 | otlpMetricsUrl: undefined, | |
| 559 | otlpLogsUrl: undefined, | |
| 560 | otlpTracesExport: DEFAULT_SIGNAL_EXPORT, | |
| 561 | otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, | |
| 562 | otlpLogsExport: DEFAULT_SIGNAL_EXPORT, | |
| 563 | mode: "web", | |
| 564 | port: 0, | |
| 565 | host: undefined, | |
| 566 | cwd: process.cwd(), | |
| 567 | baseDir, | |
| 568 | staticDir: undefined, | |
| 569 | devUrl: undefined, | |
| 570 | devAllowedOrigins: [], | |
| 571 | noBrowser: false, | |
| 572 | startupPresentation: "browser", | |
| 573 | tailscaleServeEnabled: false, | |
| 574 | tailscaleServePort: 443, | |
| 575 | desktopBootstrapToken: undefined, | |
| 576 | autoBootstrapProjectFromCwd: false, | |
| 577 | logWebSocketEvents: false, | |
| 578 | stateDir, | |
| 579 | dbPath: path.join(stateDir, "state.sqlite"), | |
| 580 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | |
| 581 | settingsPath: path.join(stateDir, "settings.json"), | |
| 582 | providerStatusCacheDir, | |
| 583 | worktreesDir, | |
| 584 | attachmentsDir, | |
| 585 | browserArtifactsDir: path.join(stateDir, "browser-artifacts"), | |
| 586 | environmentThemesDir, | |
| 587 | logsDir, | |
| 588 | serverLogPath: path.join(logsDir, "server.log"), | |
| 589 | serverTracePath: path.join(logsDir, "server.trace.ndjson"), | |
| 590 | providerLogsDir, | |
| 591 | providerEventLogPath: path.join(providerLogsDir, "events.log"), | |
| 592 | terminalLogsDir, | |
| 593 | anonymousIdPath: path.join(stateDir, "anonymous-id"), | |
| 594 | environmentIdPath: path.join(stateDir, "environment-id"), | |
| 595 | serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"), | |
| 596 | secretsDir: path.join(stateDir, "secrets"), | |
| 597 | }; | |
| 598 | }); | |
| 599 | } | |
| 600 | ||
| 601 | 512 | export function layer( |
| 602 | 513 | transcript: CursorAgentSdkReplayTranscript, |
| 603 | 514 | options?: { |
| ... | ... | @@ -605,10 +516,6 @@ export function layer( |
| 605 | 516 | readonly assertCompleteOnFinalize?: boolean; |
| 606 | 517 | }, |
| 607 | 518 | ) { |
| 608 | const layerServerConfig = Layer.effect( | |
| 609 | ServerConfig.ServerConfig, | |
| 610 | makeReplayServerConfig(transcript.scenario).pipe(Effect.orDie), | |
| 611 | ).pipe(Layer.provide(NodeServices.layer)); | |
| 612 | 519 | // Skill discovery also scans user roots under HOME; an empty HOME keeps |
| 613 | 520 | // replays from picking up the host's own skills. |
| 614 | 521 | const layerHostEnvironment = Layer.effect( |
| ... | ... | @@ -630,7 +537,7 @@ export function layer( |
| 630 | 537 | Layer.provide( |
| 631 | 538 | Layer.mergeAll( |
| 632 | 539 | layerCursorAgentSdkReplay(transcript, options), |
| 633 | layerServerConfig, | |
| 540 | layerTestProviderHost().pipe(Layer.provide(NodeServices.layer)), | |
| 634 | 541 | layerHostEnvironment, |
| 635 | 542 | NodeServices.layer, |
| 636 | 543 | IdAllocator.layer, |
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts deleted-2685| ... | ... | @@ -1,2685 +0,0 @@ |
| 1 | import type { | |
| 2 | AgentMessage, | |
| 3 | AgentOptions, | |
| 4 | InteractionUpdate, | |
| 5 | McpServerConfig, | |
| 6 | RunResult, | |
| 7 | SDKUserMessage, | |
| 8 | SettingSource, | |
| 9 | ToolCall, | |
| 10 | } from "@cursor/sdk"; | |
| 11 | import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; | |
| 12 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 13 | import { | |
| 14 | CursorSettings, | |
| 15 | isOrchestrationV2WorkActive, | |
| 16 | defaultInstanceIdForDriver, | |
| 17 | type ChatAttachment, | |
| 18 | type ModelSelection, | |
| 19 | type OrchestrationV2ConversationMessage, | |
| 20 | type OrchestrationV2ExecutionNode, | |
| 21 | type OrchestrationV2PlanArtifact, | |
| 22 | type OrchestrationV2PlanStep, | |
| 23 | type OrchestrationV2ProviderCapabilities, | |
| 24 | type OrchestrationV2ProviderFailure, | |
| 25 | type OrchestrationV2ProviderSession, | |
| 26 | type OrchestrationV2ProviderThread, | |
| 27 | type OrchestrationV2ProviderTurn, | |
| 28 | type OrchestrationV2Subagent, | |
| 29 | type OrchestrationV2TurnItem, | |
| 30 | type ProviderInstanceId, | |
| 31 | type ThreadId, | |
| 32 | } from "@t3tools/contracts"; | |
| 33 | import * as DateTime from "effect/DateTime"; | |
| 34 | import * as Deferred from "effect/Deferred"; | |
| 35 | import * as Effect from "effect/Effect"; | |
| 36 | import * as FileSystem from "effect/FileSystem"; | |
| 37 | import * as Layer from "effect/Layer"; | |
| 38 | import * as Option from "effect/Option"; | |
| 39 | import * as Path from "effect/Path"; | |
| 40 | import * as Queue from "effect/Queue"; | |
| 41 | import * as Ref from "effect/Ref"; | |
| 42 | import * as Schema from "effect/Schema"; | |
| 43 | import * as Stream from "effect/Stream"; | |
| 44 | ||
| 45 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 46 | import * as ServerConfig from "../../config.ts"; | |
| 47 | import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; | |
| 48 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 49 | import { CursorTransportFailure } from "../../provider/acp/CursorTransportFailure.ts"; | |
| 50 | import { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; | |
| 51 | import { | |
| 52 | discoverCursorSkills, | |
| 53 | hasCursorSkillMention, | |
| 54 | rewriteCursorSkillMentions, | |
| 55 | } from "../../provider/Drivers/CursorSkills.ts"; | |
| 56 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 57 | import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions"; | |
| 58 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | |
| 59 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 60 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | |
| 61 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | |
| 62 | import { | |
| 63 | isProviderNativeImageAttachment, | |
| 64 | providerMessageTextWithAttachmentPaths, | |
| 65 | } from "@t3tools/provider-core/server/attachmentPrompt"; | |
| 66 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 67 | import { | |
| 68 | ProviderAdapterDriverCreateError, | |
| 69 | type ProviderAdapterDriver, | |
| 70 | type ProviderAdapterDriverCreateInput, | |
| 71 | } from "@t3tools/provider-core/server/adapterDriver"; | |
| 72 | import { | |
| 73 | makeSubagentChildThread, | |
| 74 | makeSubagentConversationArtifacts, | |
| 75 | subagentThreadTitle, | |
| 76 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 77 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | |
| 78 | export { cursorSdkModelSelection } from "../../provider/cursorSdkModel.ts"; | |
| 79 | ||
| 80 | export const CURSOR_DRIVER_KIND = CursorAgentSdk.CURSOR_PROVIDER; | |
| 81 | export const CURSOR_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CURSOR_DRIVER_KIND); | |
| 82 | const DEFAULT_CURSOR_SETTINGS = Schema.decodeSync(CursorSettings)({}); | |
| 83 | ||
| 84 | export const CursorProviderCapabilitiesV2 = { | |
| 85 | sessions: { | |
| 86 | supportsMultipleProviderThreadsPerSession: false, | |
| 87 | supportsModelSwitchInSession: true, | |
| 88 | supportsProviderSwitchingViaHandoff: true, | |
| 89 | supportsRuntimeModeSwitchInSession: false, | |
| 90 | pendingRequestsSurviveRestart: false, | |
| 91 | }, | |
| 92 | threads: { | |
| 93 | canCreateEmptyThread: true, | |
| 94 | canReadThreadSnapshot: true, | |
| 95 | canRollbackThread: false, | |
| 96 | canForkThread: false, | |
| 97 | canForkFromTurn: false, | |
| 98 | canForkFromSubagentThread: false, | |
| 99 | exposesNativeThreadId: true, | |
| 100 | }, | |
| 101 | turns: { | |
| 102 | exposesNativeTurnId: true, | |
| 103 | emitsTurnStarted: true, | |
| 104 | emitsTurnCompleted: true, | |
| 105 | supportsInterrupt: true, | |
| 106 | supportsActiveSteering: false, | |
| 107 | supportsSteeringByInterruptRestart: true, | |
| 108 | supportsQueuedMessages: true, | |
| 109 | terminalStatusQuality: "strong", | |
| 110 | }, | |
| 111 | streaming: { | |
| 112 | streamsAssistantText: true, | |
| 113 | streamsReasoning: true, | |
| 114 | streamsToolOutput: true, | |
| 115 | streamsPlanText: true, | |
| 116 | emitsMessageCompleted: true, | |
| 117 | }, | |
| 118 | tools: { | |
| 119 | exposesToolItemIds: true, | |
| 120 | emitsToolStarted: true, | |
| 121 | emitsToolCompleted: true, | |
| 122 | emitsToolOutput: true, | |
| 123 | supportsMcpTools: true, | |
| 124 | supportsDynamicToolCallbacks: false, | |
| 125 | }, | |
| 126 | approvals: { | |
| 127 | supportsCommandApproval: false, | |
| 128 | supportsFileReadApproval: false, | |
| 129 | supportsFileChangeApproval: false, | |
| 130 | supportsApplyPatchApproval: false, | |
| 131 | approvalsHaveNativeRequestIds: false, | |
| 132 | approvalCallbacksAreLiveOnly: false, | |
| 133 | approvalsCanOriginateFromSubagents: false, | |
| 134 | }, | |
| 135 | planning: { | |
| 136 | emitsPlanUpdated: true, | |
| 137 | emitsTodoList: true, | |
| 138 | emitsProposedPlan: true, | |
| 139 | supportsStructuredQuestions: false, | |
| 140 | planDeltasHaveItemIds: true, | |
| 141 | }, | |
| 142 | subagents: { | |
| 143 | supportsSubagents: true, | |
| 144 | exposesSubagentThreadIds: false, | |
| 145 | emitsSubagentLifecycle: true, | |
| 146 | canWaitForSubagents: true, | |
| 147 | canCloseSubagents: false, | |
| 148 | canForkSubagentThread: false, | |
| 149 | }, | |
| 150 | context: { | |
| 151 | acceptsSystemContext: false, | |
| 152 | acceptsDeveloperContext: false, | |
| 153 | acceptsSyntheticUserContext: true, | |
| 154 | canGenerateSummaries: true, | |
| 155 | canConsumeHandoffSummaries: true, | |
| 156 | supportsDeltaHandoff: true, | |
| 157 | supportsFullThreadHandoff: true, | |
| 158 | maxRecommendedHandoffChars: null, | |
| 159 | }, | |
| 160 | checkpointing: { | |
| 161 | appCanCheckpointFilesystem: true, | |
| 162 | supportsNestedCheckpointScopes: true, | |
| 163 | providerCanRollbackConversation: false, | |
| 164 | providerRollbackReturnsSnapshot: false, | |
| 165 | providerCanReadConversationSnapshot: true, | |
| 166 | }, | |
| 167 | identity: { | |
| 168 | nativeThreadIds: "strong", | |
| 169 | nativeTurnIds: "strong", | |
| 170 | nativeItemIds: "weak", | |
| 171 | nativeRequestIds: "none", | |
| 172 | }, | |
| 173 | runtimePolicy: { | |
| 174 | enforcement: "native", | |
| 175 | }, | |
| 176 | } satisfies OrchestrationV2ProviderCapabilities; | |
| 177 | ||
| 178 | export interface CursorRuntimeAgentPolicy { | |
| 179 | readonly autoReview: boolean; | |
| 180 | readonly sandboxEnabled: boolean; | |
| 181 | } | |
| 182 | ||
| 183 | export function cursorRuntimeAgentPolicy( | |
| 184 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | |
| 185 | ): CursorRuntimeAgentPolicy { | |
| 186 | const sandboxPolicyType = | |
| 187 | typeof runtimePolicy.sandboxPolicy === "object" && | |
| 188 | runtimePolicy.sandboxPolicy !== null && | |
| 189 | "type" in runtimePolicy.sandboxPolicy && | |
| 190 | typeof runtimePolicy.sandboxPolicy.type === "string" | |
| 191 | ? runtimePolicy.sandboxPolicy.type | |
| 192 | : undefined; | |
| 193 | return { | |
| 194 | autoReview: | |
| 195 | runtimePolicy.approvalPolicy === undefined | |
| 196 | ? runtimePolicy.runtimeMode === "approval-required" | |
| 197 | : runtimePolicy.approvalPolicy !== "never", | |
| 198 | sandboxEnabled: | |
| 199 | runtimePolicy.sandboxPolicy === undefined | |
| 200 | ? runtimePolicy.runtimeMode !== "full-access" | |
| 201 | : sandboxPolicyType !== "dangerFullAccess", | |
| 202 | }; | |
| 203 | } | |
| 204 | ||
| 205 | export function cursorMcpServers(threadId: ThreadId): Record<string, McpServerConfig> | undefined { | |
| 206 | const session = McpProviderSession.readMcpProviderSession(threadId); | |
| 207 | if (session === undefined) { | |
| 208 | return undefined; | |
| 209 | } | |
| 210 | return { | |
| 211 | "t3-code": { | |
| 212 | type: "http", | |
| 213 | url: session.endpoint, | |
| 214 | headers: { | |
| 215 | Authorization: session.authorizationHeader, | |
| 216 | }, | |
| 217 | }, | |
| 218 | }; | |
| 219 | } | |
| 220 | ||
| 221 | function providerSession(input: { | |
| 222 | readonly providerSessionId: OrchestrationV2ProviderSession["id"]; | |
| 223 | readonly providerInstanceId: ProviderInstanceId; | |
| 224 | readonly cwd: string | null; | |
| 225 | readonly model: string; | |
| 226 | readonly now: DateTime.Utc; | |
| 227 | }): OrchestrationV2ProviderSession { | |
| 228 | return { | |
| 229 | id: input.providerSessionId, | |
| 230 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 231 | providerInstanceId: input.providerInstanceId, | |
| 232 | status: "ready", | |
| 233 | cwd: input.cwd ?? process.cwd(), | |
| 234 | model: input.model, | |
| 235 | capabilities: CursorProviderCapabilitiesV2, | |
| 236 | createdAt: input.now, | |
| 237 | updatedAt: input.now, | |
| 238 | lastError: null, | |
| 239 | }; | |
| 240 | } | |
| 241 | ||
| 242 | function makeProviderThread(input: { | |
| 243 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | |
| 244 | readonly providerInstanceId: ProviderInstanceId; | |
| 245 | readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; | |
| 246 | readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; | |
| 247 | readonly nativeThreadId: string; | |
| 248 | readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; | |
| 249 | readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; | |
| 250 | readonly now: DateTime.Utc; | |
| 251 | }): OrchestrationV2ProviderThread { | |
| 252 | return { | |
| 253 | id: input.idAllocator.derive.providerThread({ | |
| 254 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 255 | nativeThreadId: input.nativeThreadId, | |
| 256 | }), | |
| 257 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 258 | providerInstanceId: input.providerInstanceId, | |
| 259 | providerSessionId: input.providerSessionId, | |
| 260 | appThreadId: input.appThreadId, | |
| 261 | ownerNodeId: input.ownerNodeId ?? null, | |
| 262 | nativeThreadRef: { | |
| 263 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 264 | nativeId: input.nativeThreadId, | |
| 265 | strength: "strong", | |
| 266 | }, | |
| 267 | nativeConversationHeadRef: null, | |
| 268 | status: "idle", | |
| 269 | firstRunOrdinal: null, | |
| 270 | lastRunOrdinal: null, | |
| 271 | handoffIds: [], | |
| 272 | forkedFrom: input.forkedFrom ?? null, | |
| 273 | createdAt: input.now, | |
| 274 | updatedAt: input.now, | |
| 275 | }; | |
| 276 | } | |
| 277 | ||
| 278 | function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { | |
| 279 | const id = providerThread.nativeThreadRef?.nativeId; | |
| 280 | if (id === null || id === undefined) { | |
| 281 | throw new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 282 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 283 | detail: `Provider thread ${providerThread.id} is missing its Cursor agent id.`, | |
| 284 | }); | |
| 285 | } | |
| 286 | return id; | |
| 287 | } | |
| 288 | ||
| 289 | /** | |
| 290 | * Every Cursor settings layer the Cursor CLI loads: project and user rules, | |
| 291 | * skills, hooks, and MCP servers, team and MDM admin policy, and account | |
| 292 | * plugins. The SDK loads none of them when `settingSources` is omitted. | |
| 293 | * Sandbox policy files are read either way, and hooks can only deny or ask | |
| 294 | * (which local SDK runs reject), so these layers do not loosen the sandbox or | |
| 295 | * approval mode T3 sets. | |
| 296 | */ | |
| 297 | const CURSOR_AGENT_SETTING_SOURCES = [ | |
| 298 | "project", | |
| 299 | "user", | |
| 300 | "team", | |
| 301 | "mdm", | |
| 302 | "plugins", | |
| 303 | ] as const satisfies ReadonlyArray<SettingSource>; | |
| 304 | ||
| 305 | export function makeCursorAgentOptions(input: { | |
| 306 | readonly apiKey?: string; | |
| 307 | readonly modelSelection: ModelSelection; | |
| 308 | readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; | |
| 309 | readonly threadId: ThreadId; | |
| 310 | }): AgentOptions { | |
| 311 | const policy = cursorRuntimeAgentPolicy(input.runtimePolicy); | |
| 312 | const mcpServers = cursorMcpServers(input.threadId); | |
| 313 | return { | |
| 314 | model: cursorSdkModelSelection(input.modelSelection), | |
| 315 | name: `T3 Code ${input.threadId}`, | |
| 316 | mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", | |
| 317 | ...(input.apiKey === undefined ? {} : { apiKey: input.apiKey }), | |
| 318 | local: { | |
| 319 | ...(input.runtimePolicy.cwd === null ? {} : { cwd: input.runtimePolicy.cwd }), | |
| 320 | autoReview: policy.autoReview, | |
| 321 | settingSources: [...CURSOR_AGENT_SETTING_SOURCES], | |
| 322 | sandboxOptions: { | |
| 323 | enabled: policy.sandboxEnabled, | |
| 324 | }, | |
| 325 | enableAgentRetries: true, | |
| 326 | }, | |
| 327 | ...(mcpServers === undefined ? {} : { mcpServers }), | |
| 328 | }; | |
| 329 | } | |
| 330 | ||
| 331 | function stableJson(value: unknown): string { | |
| 332 | try { | |
| 333 | return JSON.stringify(value, null, 2); | |
| 334 | } catch { | |
| 335 | return String(value); | |
| 336 | } | |
| 337 | } | |
| 338 | ||
| 339 | function unknownRecord(value: unknown): Record<string, unknown> | undefined { | |
| 340 | return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 341 | ? (value as Record<string, unknown>) | |
| 342 | : undefined; | |
| 343 | } | |
| 344 | ||
| 345 | function nestedString(value: unknown, keys: ReadonlyArray<string>): string | undefined { | |
| 346 | let current: unknown = value; | |
| 347 | for (const key of keys) { | |
| 348 | current = unknownRecord(current)?.[key]; | |
| 349 | } | |
| 350 | return typeof current === "string" ? current : undefined; | |
| 351 | } | |
| 352 | ||
| 353 | function cursorToolFailed(toolCall: ToolCall): boolean { | |
| 354 | if (toolCall.result?.status === "error") { | |
| 355 | return true; | |
| 356 | } | |
| 357 | return toolCall.type === "mcp" && toolCall.result?.status === "success" | |
| 358 | ? toolCall.result.value.isError | |
| 359 | : false; | |
| 360 | } | |
| 361 | ||
| 362 | function cursorToolOutput(toolCall: ToolCall): unknown { | |
| 363 | const result = toolCall.result; | |
| 364 | if (result === undefined) { | |
| 365 | return undefined; | |
| 366 | } | |
| 367 | return result.status === "success" ? result.value : result.error; | |
| 368 | } | |
| 369 | ||
| 370 | function cursorToolOutputText(toolCall: ToolCall): string { | |
| 371 | if (toolCall.type === "shell" && toolCall.result?.status === "success") { | |
| 372 | return [toolCall.result.value.stdout, toolCall.result.value.stderr] | |
| 373 | .filter((part) => part.length > 0) | |
| 374 | .join("\n"); | |
| 375 | } | |
| 376 | if (toolCall.type === "mcp" && toolCall.result?.status === "success") { | |
| 377 | return toolCall.result.value.content | |
| 378 | .flatMap((part) => (part.text === undefined ? [] : [part.text.text])) | |
| 379 | .join("\n"); | |
| 380 | } | |
| 381 | const output = cursorToolOutput(toolCall); | |
| 382 | if (output === undefined) { | |
| 383 | return ""; | |
| 384 | } | |
| 385 | return typeof output === "string" ? output : stableJson(output); | |
| 386 | } | |
| 387 | ||
| 388 | function cursorToolName(toolCall: ToolCall): string { | |
| 389 | if (toolCall.type !== "mcp") { | |
| 390 | return toolCall.type; | |
| 391 | } | |
| 392 | const provider = toolCall.args.providerIdentifier ?? "mcp"; | |
| 393 | const tool = toolCall.args.toolName ?? "unknown"; | |
| 394 | return `mcp__${provider}__${tool}`; | |
| 395 | } | |
| 396 | ||
| 397 | function cursorToolFileName(toolCall: ToolCall): string { | |
| 398 | switch (toolCall.type) { | |
| 399 | case "write": | |
| 400 | case "delete": | |
| 401 | case "read": | |
| 402 | case "edit": | |
| 403 | case "ls": | |
| 404 | return toolCall.args.path; | |
| 405 | case "generateImage": | |
| 406 | return toolCall.args.filePath ?? "generated-image"; | |
| 407 | default: | |
| 408 | return cursorToolName(toolCall); | |
| 409 | } | |
| 410 | } | |
| 411 | ||
| 412 | function cursorToolSearchPattern(toolCall: ToolCall): string | undefined { | |
| 413 | switch (toolCall.type) { | |
| 414 | case "glob": | |
| 415 | return toolCall.args.globPattern; | |
| 416 | case "grep": | |
| 417 | return toolCall.args.pattern; | |
| 418 | case "semSearch": | |
| 419 | return toolCall.args.query; | |
| 420 | case "ls": | |
| 421 | return toolCall.args.path; | |
| 422 | case "readLints": | |
| 423 | return toolCall.args.paths.join(", "); | |
| 424 | default: | |
| 425 | return undefined; | |
| 426 | } | |
| 427 | } | |
| 428 | ||
| 429 | type CursorLsDirectoryNode = Extract< | |
| 430 | Extract<ToolCall, { readonly type: "ls" }>["result"], | |
| 431 | { readonly status: "success" } | |
| 432 | >["value"]["directoryTreeRoot"]; | |
| 433 | ||
| 434 | function cursorLsSearchResults( | |
| 435 | node: CursorLsDirectoryNode, | |
| 436 | path: Path.Path, | |
| 437 | ): ReadonlyArray<{ readonly fileName: string }> { | |
| 438 | return [ | |
| 439 | ...node.childrenFiles.map((file) => ({ fileName: path.join(node.absPath, file.name) })), | |
| 440 | ...node.childrenDirs.flatMap((child) => cursorLsSearchResults(child, path)), | |
| 441 | ]; | |
| 442 | } | |
| 443 | ||
| 444 | function cursorToolSearchResults( | |
| 445 | toolCall: ToolCall, | |
| 446 | path: Path.Path, | |
| 447 | ): ReadonlyArray<{ | |
| 448 | readonly fileName: string; | |
| 449 | readonly line?: number; | |
| 450 | readonly column?: number; | |
| 451 | readonly preview?: string; | |
| 452 | }> { | |
| 453 | if (toolCall.result?.status !== "success") { | |
| 454 | return []; | |
| 455 | } | |
| 456 | switch (toolCall.type) { | |
| 457 | case "glob": | |
| 458 | return toolCall.result.value.files.map((fileName) => ({ fileName })); | |
| 459 | case "grep": | |
| 460 | return Object.values(toolCall.result.value.workspaceResults ?? {}).flatMap((result) => { | |
| 461 | if (result.type === "files") { | |
| 462 | return result.output.files.map((fileName) => ({ fileName })); | |
| 463 | } | |
| 464 | if (result.type === "count") { | |
| 465 | return result.output.counts.map((entry) => ({ | |
| 466 | fileName: entry.file, | |
| 467 | preview: `${entry.count} matches`, | |
| 468 | })); | |
| 469 | } | |
| 470 | return result.output.matches.map((entry) => ({ | |
| 471 | fileName: entry.file, | |
| 472 | ...(entry.lineNumber === undefined ? {} : { line: entry.lineNumber }), | |
| 473 | preview: entry.line, | |
| 474 | })); | |
| 475 | }); | |
| 476 | case "ls": | |
| 477 | return cursorLsSearchResults(toolCall.result.value.directoryTreeRoot, path); | |
| 478 | case "readLints": | |
| 479 | return toolCall.result.value.fileDiagnostics.flatMap((file) => | |
| 480 | file.diagnostics.map((diagnostic) => { | |
| 481 | const line = diagnostic.range?.start?.line; | |
| 482 | const character = diagnostic.range?.start?.character; | |
| 483 | const hasLine = typeof line === "number" && Number.isInteger(line) && line >= 0; | |
| 484 | return { | |
| 485 | fileName: file.path, | |
| 486 | ...(hasLine ? { line: line + 1 } : {}), | |
| 487 | ...(hasLine && | |
| 488 | typeof character === "number" && | |
| 489 | Number.isInteger(character) && | |
| 490 | character >= 0 | |
| 491 | ? { column: character + 1 } | |
| 492 | : {}), | |
| 493 | preview: diagnostic.message, | |
| 494 | }; | |
| 495 | }), | |
| 496 | ); | |
| 497 | case "semSearch": | |
| 498 | return [ | |
| 499 | { | |
| 500 | fileName: "semantic-search", | |
| 501 | preview: toolCall.result.value.results, | |
| 502 | }, | |
| 503 | ]; | |
| 504 | default: | |
| 505 | return []; | |
| 506 | } | |
| 507 | } | |
| 508 | ||
| 509 | function cursorTodoSteps( | |
| 510 | toolCall: Extract<ToolCall, { readonly type: "updateTodos" }>, | |
| 511 | ): ReadonlyArray<OrchestrationV2PlanStep> { | |
| 512 | const todos = | |
| 513 | toolCall.result?.status === "success" ? toolCall.result.value.todos : toolCall.args.todos; | |
| 514 | return todos | |
| 515 | .filter((todo) => todo.status !== "cancelled" && todo.content.trim().length > 0) | |
| 516 | .map((todo, index) => ({ | |
| 517 | id: `todo-${index + 1}`, | |
| 518 | text: todo.content, | |
| 519 | status: | |
| 520 | todo.status === "inProgress" | |
| 521 | ? "running" | |
| 522 | : todo.status === "completed" | |
| 523 | ? "completed" | |
| 524 | : "pending", | |
| 525 | })); | |
| 526 | } | |
| 527 | ||
| 528 | function assistantTextsFromConversationSteps(steps: ReadonlyArray<unknown>): ReadonlyArray<string> { | |
| 529 | return steps.flatMap((step) => { | |
| 530 | const record = unknownRecord(step); | |
| 531 | if (record?.type === "assistantMessage") { | |
| 532 | const text = nestedString(record, ["message", "text"]); | |
| 533 | return text === undefined || text.length === 0 ? [] : [text]; | |
| 534 | } | |
| 535 | const text = nestedString(record, ["assistantMessage", "text"]); | |
| 536 | return text === undefined || text.length === 0 ? [] : [text]; | |
| 537 | }); | |
| 538 | } | |
| 539 | ||
| 540 | function nestedToolResult<Value = Record<string, unknown>>( | |
| 541 | rawResult: unknown, | |
| 542 | mapSuccess: (success: Record<string, unknown>) => Value = (success) => success as Value, | |
| 543 | ): | |
| 544 | | { readonly status: "success"; readonly value: Value } | |
| 545 | | { readonly status: "error"; readonly error: unknown } | |
| 546 | | undefined { | |
| 547 | const result = unknownRecord(rawResult); | |
| 548 | if (result === undefined) { | |
| 549 | return undefined; | |
| 550 | } | |
| 551 | const success = unknownRecord(result.success); | |
| 552 | if (success !== undefined) { | |
| 553 | return { | |
| 554 | status: "success", | |
| 555 | value: mapSuccess(success), | |
| 556 | }; | |
| 557 | } | |
| 558 | return { | |
| 559 | status: "error", | |
| 560 | error: result.error ?? result.permissionDenied ?? result, | |
| 561 | }; | |
| 562 | } | |
| 563 | ||
| 564 | function nestedGrepWorkspaceResults(success: Record<string, unknown>): Record<string, unknown> { | |
| 565 | const workspaces = unknownRecord(success.workspaceResults); | |
| 566 | if (workspaces === undefined) { | |
| 567 | return {}; | |
| 568 | } | |
| 569 | return Object.fromEntries( | |
| 570 | Object.entries(workspaces).map(([workspace, rawWorkspaceResult]) => { | |
| 571 | const workspaceResult = unknownRecord(rawWorkspaceResult); | |
| 572 | const content = unknownRecord(workspaceResult?.content); | |
| 573 | const rawMatches = Array.isArray(content?.matches) ? content.matches : []; | |
| 574 | const matches = rawMatches.flatMap((rawFileMatch) => { | |
| 575 | const fileMatch = unknownRecord(rawFileMatch); | |
| 576 | const file = typeof fileMatch?.file === "string" ? fileMatch.file : ""; | |
| 577 | const lineMatches = Array.isArray(fileMatch?.matches) ? fileMatch.matches : []; | |
| 578 | return lineMatches.flatMap((rawLineMatch) => { | |
| 579 | const lineMatch = unknownRecord(rawLineMatch); | |
| 580 | if (lineMatch === undefined) { | |
| 581 | return []; | |
| 582 | } | |
| 583 | const line = typeof lineMatch.content === "string" ? lineMatch.content : undefined; | |
| 584 | if (file.length === 0 || line === undefined) { | |
| 585 | return []; | |
| 586 | } | |
| 587 | return [ | |
| 588 | { | |
| 589 | file, | |
| 590 | line, | |
| 591 | ...(typeof lineMatch.lineNumber === "number" | |
| 592 | ? { lineNumber: lineMatch.lineNumber } | |
| 593 | : {}), | |
| 594 | }, | |
| 595 | ]; | |
| 596 | }); | |
| 597 | }); | |
| 598 | return [ | |
| 599 | workspace, | |
| 600 | { | |
| 601 | type: "content", | |
| 602 | output: { | |
| 603 | matches, | |
| 604 | totalMatches: | |
| 605 | typeof content?.totalMatchedLines === "number" | |
| 606 | ? content.totalMatchedLines | |
| 607 | : matches.length, | |
| 608 | }, | |
| 609 | }, | |
| 610 | ]; | |
| 611 | }), | |
| 612 | ); | |
| 613 | } | |
| 614 | ||
| 615 | export function nestedToolCallFromEnvelope( | |
| 616 | envelope: Record<string, unknown>, | |
| 617 | ): { readonly callId: string; readonly toolCall: ToolCall } | undefined { | |
| 618 | const callId = typeof envelope.toolCallId === "string" ? envelope.toolCallId : undefined; | |
| 619 | const wrapperEntry = Object.entries(envelope).find(([key]) => key.endsWith("ToolCall")); | |
| 620 | if (wrapperEntry === undefined) { | |
| 621 | return undefined; | |
| 622 | } | |
| 623 | const [wrapperName, rawCall] = wrapperEntry; | |
| 624 | const call = unknownRecord(rawCall); | |
| 625 | if (call === undefined) { | |
| 626 | return undefined; | |
| 627 | } | |
| 628 | const args = unknownRecord(call.args) ?? {}; | |
| 629 | const fallbackCallId = `nested-${wrapperName}`; | |
| 630 | const nestedCallId = callId ?? fallbackCallId; | |
| 631 | ||
| 632 | switch (wrapperName) { | |
| 633 | case "readToolCall": { | |
| 634 | const success = unknownRecord(unknownRecord(call.result)?.success); | |
| 635 | const path = | |
| 636 | typeof args.path === "string" | |
| 637 | ? args.path | |
| 638 | : typeof success?.path === "string" | |
| 639 | ? success.path | |
| 640 | : "<unknown path>"; | |
| 641 | return { | |
| 642 | callId: nestedCallId, | |
| 643 | toolCall: { | |
| 644 | type: "read", | |
| 645 | args: { path }, | |
| 646 | result: nestedToolResult(call.result, (value) => ({ | |
| 647 | content: typeof value.content === "string" ? value.content : "", | |
| 648 | totalLines: typeof value.totalLines === "number" ? value.totalLines : 0, | |
| 649 | fileSize: typeof value.fileSize === "number" ? value.fileSize : 0, | |
| 650 | })), | |
| 651 | } as unknown as ToolCall, | |
| 652 | }; | |
| 653 | } | |
| 654 | case "globToolCall": | |
| 655 | return { | |
| 656 | callId: nestedCallId, | |
| 657 | toolCall: { | |
| 658 | type: "glob", | |
| 659 | args: { | |
| 660 | globPattern: typeof args.globPattern === "string" ? args.globPattern : "**/*", | |
| 661 | ...(typeof args.targetDirectory === "string" | |
| 662 | ? { targetDirectory: args.targetDirectory } | |
| 663 | : {}), | |
| 664 | }, | |
| 665 | result: nestedToolResult(call.result, (value) => ({ | |
| 666 | files: Array.isArray(value.files) | |
| 667 | ? value.files.filter((file): file is string => typeof file === "string") | |
| 668 | : [], | |
| 669 | totalFiles: typeof value.totalFiles === "number" ? value.totalFiles : 0, | |
| 670 | clientTruncated: false, | |
| 671 | ripgrepTruncated: false, | |
| 672 | })), | |
| 673 | } as unknown as ToolCall, | |
| 674 | }; | |
| 675 | case "grepToolCall": | |
| 676 | return { | |
| 677 | callId: nestedCallId, | |
| 678 | toolCall: { | |
| 679 | type: "grep", | |
| 680 | args: { | |
| 681 | pattern: typeof args.pattern === "string" ? args.pattern : "", | |
| 682 | ...(typeof args.path === "string" ? { path: args.path } : {}), | |
| 683 | ...(typeof args.glob === "string" ? { glob: args.glob } : {}), | |
| 684 | ...(typeof args.outputMode === "string" ? { outputMode: args.outputMode } : {}), | |
| 685 | ...(typeof args.caseInsensitive === "boolean" | |
| 686 | ? { caseInsensitive: args.caseInsensitive } | |
| 687 | : {}), | |
| 688 | ...(typeof args.offset === "number" ? { offset: args.offset } : {}), | |
| 689 | ...(typeof args.multiline === "boolean" ? { multiline: args.multiline } : {}), | |
| 690 | }, | |
| 691 | result: nestedToolResult(call.result, (value) => ({ | |
| 692 | workspaceResults: nestedGrepWorkspaceResults(value), | |
| 693 | })), | |
| 694 | } as unknown as ToolCall, | |
| 695 | }; | |
| 696 | case "shellToolCall": { | |
| 697 | const rawResult = unknownRecord(call.result); | |
| 698 | const permissionDenied = unknownRecord(rawResult?.permissionDenied); | |
| 699 | const command = | |
| 700 | typeof args.command === "string" | |
| 701 | ? args.command | |
| 702 | : typeof permissionDenied?.command === "string" | |
| 703 | ? permissionDenied.command | |
| 704 | : "<unknown command>"; | |
| 705 | return { | |
| 706 | callId: nestedCallId, | |
| 707 | toolCall: { | |
| 708 | type: "shell", | |
| 709 | args: { command }, | |
| 710 | result: nestedToolResult(call.result, (value) => ({ | |
| 711 | stdout: typeof value.stdout === "string" ? value.stdout : "", | |
| 712 | stderr: typeof value.stderr === "string" ? value.stderr : "", | |
| 713 | exitCode: typeof value.exitCode === "number" ? value.exitCode : 0, | |
| 714 | signal: typeof value.signal === "string" ? value.signal : "", | |
| 715 | executionTime: typeof value.executionTime === "number" ? value.executionTime : 0, | |
| 716 | })), | |
| 717 | } as unknown as ToolCall, | |
| 718 | }; | |
| 719 | } | |
| 720 | default: { | |
| 721 | const type = wrapperName.slice(0, -"ToolCall".length); | |
| 722 | return { | |
| 723 | callId: nestedCallId, | |
| 724 | toolCall: { | |
| 725 | type, | |
| 726 | args, | |
| 727 | result: nestedToolResult(call.result), | |
| 728 | } as unknown as ToolCall, | |
| 729 | }; | |
| 730 | } | |
| 731 | } | |
| 732 | } | |
| 733 | ||
| 734 | function toolCallsFromConversationSteps( | |
| 735 | steps: ReadonlyArray<unknown>, | |
| 736 | ): ReadonlyArray<{ readonly callId: string; readonly toolCall: ToolCall }> { | |
| 737 | return steps.flatMap((step) => { | |
| 738 | const record = unknownRecord(step); | |
| 739 | if (record?.type === "toolCall") { | |
| 740 | const message = unknownRecord(record.message); | |
| 741 | return typeof message?.type === "string" | |
| 742 | ? [ | |
| 743 | { | |
| 744 | callId: typeof record.callId === "string" ? record.callId : `nested-${message.type}`, | |
| 745 | toolCall: message as ToolCall, | |
| 746 | }, | |
| 747 | ] | |
| 748 | : []; | |
| 749 | } | |
| 750 | const envelope = unknownRecord(record?.toolCall); | |
| 751 | const nested = envelope === undefined ? undefined : nestedToolCallFromEnvelope(envelope); | |
| 752 | return nested === undefined ? [] : [nested]; | |
| 753 | }); | |
| 754 | } | |
| 755 | ||
| 756 | function textFromAgentMessage(message: AgentMessage): string { | |
| 757 | const value = message.message; | |
| 758 | if (typeof value === "string") { | |
| 759 | return value; | |
| 760 | } | |
| 761 | const direct = | |
| 762 | nestedString(value, ["text"]) ?? | |
| 763 | nestedString(value, ["message", "text"]) ?? | |
| 764 | nestedString(value, ["userMessage", "text"]) ?? | |
| 765 | nestedString(value, ["assistantMessage", "text"]); | |
| 766 | if (direct !== undefined) { | |
| 767 | return direct; | |
| 768 | } | |
| 769 | const content = unknownRecord(value)?.content; | |
| 770 | if (!Array.isArray(content)) { | |
| 771 | return ""; | |
| 772 | } | |
| 773 | return content | |
| 774 | .flatMap((part) => { | |
| 775 | const text = unknownRecord(part)?.text; | |
| 776 | return typeof text === "string" ? [text] : []; | |
| 777 | }) | |
| 778 | .join(""); | |
| 779 | } | |
| 780 | ||
| 781 | interface CursorProjectionTarget { | |
| 782 | readonly threadId: ThreadId; | |
| 783 | readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; | |
| 784 | readonly rootNodeId: OrchestrationV2ExecutionNode["rootNodeId"]; | |
| 785 | readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; | |
| 786 | readonly providerThreadId: OrchestrationV2ProviderThread["id"] | null; | |
| 787 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"] | null; | |
| 788 | } | |
| 789 | ||
| 790 | interface ActiveCursorToolCall { | |
| 791 | readonly callId: string; | |
| 792 | toolCall: ToolCall; | |
| 793 | readonly target: CursorProjectionTarget; | |
| 794 | readonly ordinal: number; | |
| 795 | readonly startedAt: DateTime.Utc; | |
| 796 | streamedOutput: string; | |
| 797 | } | |
| 798 | ||
| 799 | interface ActiveCursorSubagent { | |
| 800 | task: OrchestrationV2Subagent; | |
| 801 | toolCall: Extract<ToolCall, { readonly type: "task" }>; | |
| 802 | readonly callId: string; | |
| 803 | readonly childThreadId: ThreadId; | |
| 804 | readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; | |
| 805 | readonly turnItemId: OrchestrationV2TurnItem["id"]; | |
| 806 | readonly turnItemOrdinal: number; | |
| 807 | nextChildOrdinal: number; | |
| 808 | resultProjected: boolean; | |
| 809 | } | |
| 810 | ||
| 811 | interface ActiveCursorTextSegment { | |
| 812 | readonly nativeItemId: string; | |
| 813 | readonly startedAt: DateTime.Utc; | |
| 814 | text: string; | |
| 815 | } | |
| 816 | ||
| 817 | interface ActiveCursorTextStream { | |
| 818 | current: ActiveCursorTextSegment | null; | |
| 819 | nextSegment: number; | |
| 820 | } | |
| 821 | ||
| 822 | interface ActiveCursorTurn { | |
| 823 | readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; | |
| 824 | readonly run: CursorAgentSdk.CursorAgentSdkRun; | |
| 825 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 826 | readonly startedAt: DateTime.Utc; | |
| 827 | readonly completed: Deferred.Deferred<void, never>; | |
| 828 | // Item ordinals allocated in this turn. No later turn looks items up here. | |
| 829 | readonly itemOrdinals: Map<string, number>; | |
| 830 | readonly tools: Map<string, ActiveCursorToolCall>; | |
| 831 | readonly subagents: Map<string, ActiveCursorSubagent>; | |
| 832 | readonly assistant: ActiveCursorTextStream; | |
| 833 | readonly assistantReply: CursorTransportFailure; | |
| 834 | readonly reasoning: ActiveCursorTextStream; | |
| 835 | interrupted: boolean; | |
| 836 | finalized: boolean; | |
| 837 | } | |
| 838 | ||
| 839 | interface CursorLiveAgent { | |
| 840 | readonly nativeThreadId: string; | |
| 841 | readonly session: CursorAgentSdk.CursorAgentSdkSession; | |
| 842 | } | |
| 843 | ||
| 844 | export interface CursorAdapterV2Options { | |
| 845 | readonly instanceId: ProviderInstanceId; | |
| 846 | readonly settings: CursorSettings; | |
| 847 | readonly environment: NodeJS.ProcessEnv; | |
| 848 | readonly fileSystem: FileSystem.FileSystem; | |
| 849 | readonly path: Path.Path; | |
| 850 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | |
| 851 | readonly runner: CursorAgentSdk.CursorAgentSdkRunnerShape; | |
| 852 | readonly serverConfig: ServerConfig.ServerConfig["Service"]; | |
| 853 | } | |
| 854 | ||
| 855 | export function makeCursorAdapterV2( | |
| 856 | adapterOptions: CursorAdapterV2Options, | |
| 857 | ): ProviderAdapter.ProviderAdapterV2Shape { | |
| 858 | const { fileSystem, path, idAllocator, runner, serverConfig } = adapterOptions; | |
| 859 | const apiKey = adapterOptions.environment.CURSOR_API_KEY?.trim() || undefined; | |
| 860 | ||
| 861 | return ProviderAdapter.ProviderAdapterV2.of({ | |
| 862 | instanceId: adapterOptions.instanceId, | |
| 863 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 864 | getCapabilities: () => Effect.succeed(CursorProviderCapabilitiesV2), | |
| 865 | planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), | |
| 866 | openSession: Effect.fn("CursorAdapterV2.openSession")( | |
| 867 | function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { | |
| 868 | const sessionScope = yield* Effect.scope; | |
| 869 | const createdAt = yield* DateTime.now; | |
| 870 | const session = providerSession({ | |
| 871 | providerSessionId: input.providerSessionId, | |
| 872 | providerInstanceId: adapterOptions.instanceId, | |
| 873 | cwd: input.runtimePolicy.cwd, | |
| 874 | model: input.modelSelection.model, | |
| 875 | now: createdAt, | |
| 876 | }); | |
| 877 | const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>(); | |
| 878 | const liveAgent = yield* Ref.make<CursorLiveAgent | null>(null); | |
| 879 | const activeTurn = yield* Ref.make<ActiveCursorTurn | null>(null); | |
| 880 | const planIds = yield* Ref.make(new Map<string, OrchestrationV2PlanArtifact["id"]>()); | |
| 881 | ||
| 882 | const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => | |
| 883 | Queue.offer(events, event).pipe(Effect.asVoid); | |
| 884 | ||
| 885 | const resolveItemOrdinal = (context: ActiveCursorTurn, nativeItemId: string) => | |
| 886 | Effect.sync(() => { | |
| 887 | const existing = context.itemOrdinals.get(nativeItemId); | |
| 888 | if (existing !== undefined) { | |
| 889 | return existing; | |
| 890 | } | |
| 891 | const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1; | |
| 892 | context.itemOrdinals.set(nativeItemId, ordinal); | |
| 893 | return ordinal; | |
| 894 | }); | |
| 895 | ||
| 896 | const resolvePlanId = Effect.fnUntraced(function* ( | |
| 897 | context: ActiveCursorTurn, | |
| 898 | nativeItemId: string, | |
| 899 | ) { | |
| 900 | const existing = (yield* Ref.get(planIds)).get(nativeItemId); | |
| 901 | if (existing !== undefined) { | |
| 902 | return existing; | |
| 903 | } | |
| 904 | const planId = yield* idAllocator.allocate.plan({ | |
| 905 | threadId: context.input.threadId, | |
| 906 | runId: context.input.runId, | |
| 907 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 908 | }); | |
| 909 | yield* Ref.update(planIds, (current) => { | |
| 910 | const updated = new Map(current); | |
| 911 | updated.set(nativeItemId, planId); | |
| 912 | return updated; | |
| 913 | }); | |
| 914 | return planId; | |
| 915 | }); | |
| 916 | ||
| 917 | const parentTarget = (context: ActiveCursorTurn): CursorProjectionTarget => ({ | |
| 918 | threadId: context.input.threadId, | |
| 919 | runId: context.input.runId, | |
| 920 | rootNodeId: context.input.rootNodeId, | |
| 921 | parentNodeId: context.input.rootNodeId, | |
| 922 | providerThreadId: context.input.providerThread.id, | |
| 923 | providerTurnId: context.providerTurnId, | |
| 924 | }); | |
| 925 | ||
| 926 | const emitAssistant = Effect.fnUntraced(function* ( | |
| 927 | context: ActiveCursorTurn, | |
| 928 | completed: boolean, | |
| 929 | ) { | |
| 930 | const segment = context.assistant.current; | |
| 931 | if (segment === null || segment.text.length === 0) { | |
| 932 | return; | |
| 933 | } | |
| 934 | const now = yield* DateTime.now; | |
| 935 | const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); | |
| 936 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 937 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 938 | nativeItemId: segment.nativeItemId, | |
| 939 | }); | |
| 940 | const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 941 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 942 | nativeItemId: segment.nativeItemId, | |
| 943 | }); | |
| 944 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 945 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 946 | nativeItemId: segment.nativeItemId, | |
| 947 | }); | |
| 948 | const nativeItemRef = { | |
| 949 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 950 | nativeId: segment.nativeItemId, | |
| 951 | strength: "weak" as const, | |
| 952 | }; | |
| 953 | yield* emitProviderEvent({ | |
| 954 | type: "node.updated", | |
| 955 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 956 | node: { | |
| 957 | id: nodeId, | |
| 958 | threadId: context.input.threadId, | |
| 959 | runId: context.input.runId, | |
| 960 | parentNodeId: context.input.rootNodeId, | |
| 961 | rootNodeId: context.input.rootNodeId, | |
| 962 | kind: "assistant_message", | |
| 963 | status: completed ? "completed" : "running", | |
| 964 | countsForRun: false, | |
| 965 | providerThreadId: context.input.providerThread.id, | |
| 966 | providerTurnId: context.providerTurnId, | |
| 967 | nativeItemRef, | |
| 968 | runtimeRequestId: null, | |
| 969 | checkpointScopeId: null, | |
| 970 | startedAt: segment.startedAt, | |
| 971 | completedAt: completed ? now : null, | |
| 972 | }, | |
| 973 | }); | |
| 974 | yield* emitProviderEvent({ | |
| 975 | type: "message.updated", | |
| 976 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 977 | message: { | |
| 978 | createdBy: "agent", | |
| 979 | creationSource: "provider", | |
| 980 | id: messageId, | |
| 981 | threadId: context.input.threadId, | |
| 982 | runId: context.input.runId, | |
| 983 | nodeId, | |
| 984 | role: "assistant", | |
| 985 | text: segment.text, | |
| 986 | attachments: [], | |
| 987 | streaming: !completed, | |
| 988 | createdAt: segment.startedAt, | |
| 989 | updatedAt: now, | |
| 990 | }, | |
| 991 | }); | |
| 992 | yield* emitProviderEvent({ | |
| 993 | type: "turn_item.updated", | |
| 994 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 995 | turnItem: { | |
| 996 | id: turnItemId, | |
| 997 | threadId: context.input.threadId, | |
| 998 | runId: context.input.runId, | |
| 999 | nodeId, | |
| 1000 | providerThreadId: context.input.providerThread.id, | |
| 1001 | providerTurnId: context.providerTurnId, | |
| 1002 | nativeItemRef, | |
| 1003 | parentItemId: null, | |
| 1004 | ordinal, | |
| 1005 | status: completed ? "completed" : "running", | |
| 1006 | title: null, | |
| 1007 | startedAt: segment.startedAt, | |
| 1008 | completedAt: completed ? now : null, | |
| 1009 | updatedAt: now, | |
| 1010 | type: "assistant_message", | |
| 1011 | messageId, | |
| 1012 | text: segment.text, | |
| 1013 | streaming: !completed, | |
| 1014 | }, | |
| 1015 | }); | |
| 1016 | }); | |
| 1017 | ||
| 1018 | const emitReasoning = Effect.fnUntraced(function* ( | |
| 1019 | context: ActiveCursorTurn, | |
| 1020 | completed: boolean, | |
| 1021 | ) { | |
| 1022 | const segment = context.reasoning.current; | |
| 1023 | if (segment === null || segment.text.length === 0) { | |
| 1024 | return; | |
| 1025 | } | |
| 1026 | const now = yield* DateTime.now; | |
| 1027 | const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); | |
| 1028 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1029 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1030 | nativeItemId: segment.nativeItemId, | |
| 1031 | }); | |
| 1032 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1033 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1034 | nativeItemId: segment.nativeItemId, | |
| 1035 | }); | |
| 1036 | const nativeItemRef = { | |
| 1037 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1038 | nativeId: segment.nativeItemId, | |
| 1039 | strength: "weak" as const, | |
| 1040 | }; | |
| 1041 | yield* emitProviderEvent({ | |
| 1042 | type: "node.updated", | |
| 1043 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1044 | node: { | |
| 1045 | id: nodeId, | |
| 1046 | threadId: context.input.threadId, | |
| 1047 | runId: context.input.runId, | |
| 1048 | parentNodeId: context.input.rootNodeId, | |
| 1049 | rootNodeId: context.input.rootNodeId, | |
| 1050 | kind: "reasoning", | |
| 1051 | status: completed ? "completed" : "running", | |
| 1052 | countsForRun: false, | |
| 1053 | providerThreadId: context.input.providerThread.id, | |
| 1054 | providerTurnId: context.providerTurnId, | |
| 1055 | nativeItemRef, | |
| 1056 | runtimeRequestId: null, | |
| 1057 | checkpointScopeId: null, | |
| 1058 | startedAt: segment.startedAt, | |
| 1059 | completedAt: completed ? now : null, | |
| 1060 | }, | |
| 1061 | }); | |
| 1062 | yield* emitProviderEvent({ | |
| 1063 | type: "turn_item.updated", | |
| 1064 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1065 | turnItem: { | |
| 1066 | id: turnItemId, | |
| 1067 | threadId: context.input.threadId, | |
| 1068 | runId: context.input.runId, | |
| 1069 | nodeId, | |
| 1070 | providerThreadId: context.input.providerThread.id, | |
| 1071 | providerTurnId: context.providerTurnId, | |
| 1072 | nativeItemRef, | |
| 1073 | parentItemId: null, | |
| 1074 | ordinal, | |
| 1075 | status: completed ? "completed" : "running", | |
| 1076 | title: null, | |
| 1077 | startedAt: segment.startedAt, | |
| 1078 | completedAt: completed ? now : null, | |
| 1079 | updatedAt: now, | |
| 1080 | type: "reasoning", | |
| 1081 | text: segment.text, | |
| 1082 | streaming: !completed, | |
| 1083 | }, | |
| 1084 | }); | |
| 1085 | }); | |
| 1086 | ||
| 1087 | const appendTextSegment = Effect.fnUntraced(function* (input: { | |
| 1088 | readonly context: ActiveCursorTurn; | |
| 1089 | readonly stream: ActiveCursorTextStream; | |
| 1090 | readonly kind: "assistant" | "reasoning"; | |
| 1091 | readonly text: string; | |
| 1092 | }) { | |
| 1093 | if (input.stream.current === null) { | |
| 1094 | input.stream.nextSegment += 1; | |
| 1095 | input.stream.current = { | |
| 1096 | nativeItemId: `${input.kind}:${input.context.run.runId}:${input.stream.nextSegment}`, | |
| 1097 | startedAt: yield* DateTime.now, | |
| 1098 | text: "", | |
| 1099 | }; | |
| 1100 | } | |
| 1101 | input.stream.current.text += input.text; | |
| 1102 | }); | |
| 1103 | ||
| 1104 | const completeAssistant = Effect.fnUntraced(function* (context: ActiveCursorTurn) { | |
| 1105 | if (context.assistant.current === null) { | |
| 1106 | return; | |
| 1107 | } | |
| 1108 | yield* emitAssistant(context, true); | |
| 1109 | context.assistant.current = null; | |
| 1110 | }); | |
| 1111 | ||
| 1112 | const completeReasoning = Effect.fnUntraced(function* (context: ActiveCursorTurn) { | |
| 1113 | if (context.reasoning.current === null) { | |
| 1114 | return; | |
| 1115 | } | |
| 1116 | yield* emitReasoning(context, true); | |
| 1117 | context.reasoning.current = null; | |
| 1118 | }); | |
| 1119 | ||
| 1120 | const emitToolArtifacts = Effect.fnUntraced(function* (input: { | |
| 1121 | readonly active: ActiveCursorToolCall; | |
| 1122 | readonly completed: boolean; | |
| 1123 | /** Terminal status for a tool the turn ended before Cursor completed it. */ | |
| 1124 | readonly unfinishedStatus?: "interrupted" | "failed" | "cancelled"; | |
| 1125 | }) { | |
| 1126 | const { active } = input; | |
| 1127 | const toolCall = active.toolCall; | |
| 1128 | const now = yield* DateTime.now; | |
| 1129 | const failed = input.completed && cursorToolFailed(toolCall); | |
| 1130 | const status = !input.completed | |
| 1131 | ? "running" | |
| 1132 | : (input.unfinishedStatus ?? (failed ? "failed" : "completed")); | |
| 1133 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1134 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1135 | nativeItemId: active.callId, | |
| 1136 | }); | |
| 1137 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1138 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1139 | nativeItemId: active.callId, | |
| 1140 | }); | |
| 1141 | const nativeItemRef = { | |
| 1142 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1143 | nativeId: active.callId, | |
| 1144 | strength: "strong" as const, | |
| 1145 | }; | |
| 1146 | const node: OrchestrationV2ExecutionNode = { | |
| 1147 | id: nodeId, | |
| 1148 | threadId: active.target.threadId, | |
| 1149 | runId: active.target.runId, | |
| 1150 | parentNodeId: active.target.parentNodeId, | |
| 1151 | rootNodeId: active.target.rootNodeId, | |
| 1152 | kind: "tool_call", | |
| 1153 | status, | |
| 1154 | countsForRun: false, | |
| 1155 | providerThreadId: active.target.providerThreadId, | |
| 1156 | providerTurnId: active.target.providerTurnId, | |
| 1157 | nativeItemRef, | |
| 1158 | runtimeRequestId: null, | |
| 1159 | checkpointScopeId: null, | |
| 1160 | startedAt: active.startedAt, | |
| 1161 | completedAt: input.completed ? now : null, | |
| 1162 | }; | |
| 1163 | const base = { | |
| 1164 | id: turnItemId, | |
| 1165 | threadId: active.target.threadId, | |
| 1166 | runId: active.target.runId, | |
| 1167 | nodeId, | |
| 1168 | providerThreadId: active.target.providerThreadId, | |
| 1169 | providerTurnId: active.target.providerTurnId, | |
| 1170 | nativeItemRef, | |
| 1171 | parentItemId: null, | |
| 1172 | ordinal: active.ordinal, | |
| 1173 | status, | |
| 1174 | title: null, | |
| 1175 | startedAt: active.startedAt, | |
| 1176 | completedAt: input.completed ? now : null, | |
| 1177 | updatedAt: now, | |
| 1178 | } satisfies Pick< | |
| 1179 | OrchestrationV2TurnItem, | |
| 1180 | | "id" | |
| 1181 | | "threadId" | |
| 1182 | | "runId" | |
| 1183 | | "nodeId" | |
| 1184 | | "providerThreadId" | |
| 1185 | | "providerTurnId" | |
| 1186 | | "nativeItemRef" | |
| 1187 | | "parentItemId" | |
| 1188 | | "ordinal" | |
| 1189 | | "status" | |
| 1190 | | "title" | |
| 1191 | | "startedAt" | |
| 1192 | | "completedAt" | |
| 1193 | | "updatedAt" | |
| 1194 | >; | |
| 1195 | const outputText = cursorToolOutputText(toolCall) || active.streamedOutput; | |
| 1196 | let turnItem: OrchestrationV2TurnItem; | |
| 1197 | switch (toolCall.type) { | |
| 1198 | case "shell": | |
| 1199 | turnItem = { | |
| 1200 | ...base, | |
| 1201 | type: "command_execution", | |
| 1202 | input: toolCall.args.command, | |
| 1203 | ...(outputText.length === 0 ? {} : { output: outputText }), | |
| 1204 | ...(toolCall.result?.status === "success" | |
| 1205 | ? { exitCode: toolCall.result.value.exitCode } | |
| 1206 | : {}), | |
| 1207 | }; | |
| 1208 | break; | |
| 1209 | case "write": | |
| 1210 | case "delete": | |
| 1211 | case "edit": | |
| 1212 | case "generateImage": | |
| 1213 | turnItem = { | |
| 1214 | ...base, | |
| 1215 | type: "file_change", | |
| 1216 | fileName: cursorToolFileName(toolCall), | |
| 1217 | ...(toolCall.type === "edit" && | |
| 1218 | toolCall.result?.status === "success" && | |
| 1219 | toolCall.result.value.linesAdded !== undefined | |
| 1220 | ? { additions: toolCall.result.value.linesAdded } | |
| 1221 | : {}), | |
| 1222 | ...(toolCall.type === "edit" && | |
| 1223 | toolCall.result?.status === "success" && | |
| 1224 | toolCall.result.value.linesRemoved !== undefined | |
| 1225 | ? { deletions: toolCall.result.value.linesRemoved } | |
| 1226 | : {}), | |
| 1227 | ...(toolCall.type === "edit" && | |
| 1228 | toolCall.result?.status === "success" && | |
| 1229 | toolCall.result.value.diffString !== undefined | |
| 1230 | ? { diffStr: toolCall.result.value.diffString } | |
| 1231 | : // A failed change keeps its error where the diff would be. | |
| 1232 | toolCall.result?.status === "error" && outputText.trim().length > 0 | |
| 1233 | ? { diffStr: outputText } | |
| 1234 | : {}), | |
| 1235 | ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}), | |
| 1236 | }; | |
| 1237 | break; | |
| 1238 | case "read": | |
| 1239 | turnItem = { | |
| 1240 | ...base, | |
| 1241 | title: formatReadToolLabel(toolCall.args.path), | |
| 1242 | type: "dynamic_tool", | |
| 1243 | toolName: "Read", | |
| 1244 | input: toolCall.args, | |
| 1245 | ...(cursorToolOutput(toolCall) === undefined | |
| 1246 | ? {} | |
| 1247 | : { output: cursorToolOutput(toolCall) }), | |
| 1248 | }; | |
| 1249 | break; | |
| 1250 | case "glob": | |
| 1251 | case "grep": | |
| 1252 | case "ls": | |
| 1253 | case "readLints": | |
| 1254 | case "semSearch": { | |
| 1255 | const pattern = cursorToolSearchPattern(toolCall); | |
| 1256 | const searchPath = | |
| 1257 | toolCall.type === "grep" | |
| 1258 | ? toolCall.args.path | |
| 1259 | : toolCall.type === "glob" | |
| 1260 | ? toolCall.args.targetDirectory | |
| 1261 | : toolCall.type === "semSearch" | |
| 1262 | ? toolCall.args.targetDirectories?.join(", ") | |
| 1263 | : pattern; | |
| 1264 | // A failed search keeps its error as one row under the searched path. | |
| 1265 | const results = | |
| 1266 | toolCall.result?.status === "error" && outputText.trim().length > 0 | |
| 1267 | ? [{ fileName: searchPath?.trim() || ".", preview: outputText }] | |
| 1268 | : cursorToolSearchResults(toolCall, path); | |
| 1269 | turnItem = { | |
| 1270 | ...base, | |
| 1271 | title: | |
| 1272 | formatSearchToolLabel({ | |
| 1273 | input: toolCall.args, | |
| 1274 | ...(pattern === undefined ? {} : { pattern }), | |
| 1275 | }) ?? null, | |
| 1276 | type: "file_search", | |
| 1277 | ...(pattern === undefined ? {} : { pattern }), | |
| 1278 | ...(results.length === 0 ? {} : { results: [...results] }), | |
| 1279 | }; | |
| 1280 | break; | |
| 1281 | } | |
| 1282 | default: | |
| 1283 | turnItem = { | |
| 1284 | ...base, | |
| 1285 | type: "dynamic_tool", | |
| 1286 | ...(toolCall.type === "mcp" | |
| 1287 | ? mcpToolPresentation({ | |
| 1288 | serverName: toolCall.args.providerIdentifier, | |
| 1289 | toolName: toolCall.args.toolName, | |
| 1290 | }) | |
| 1291 | : {}), | |
| 1292 | toolName: cursorToolName(toolCall), | |
| 1293 | input: toolCall.args, | |
| 1294 | ...(cursorToolOutput(toolCall) === undefined | |
| 1295 | ? {} | |
| 1296 | : { output: cursorToolOutput(toolCall) }), | |
| 1297 | }; | |
| 1298 | } | |
| 1299 | yield* emitProviderEvent({ | |
| 1300 | type: "node.updated", | |
| 1301 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1302 | node, | |
| 1303 | }); | |
| 1304 | yield* emitProviderEvent({ | |
| 1305 | type: "turn_item.updated", | |
| 1306 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1307 | turnItem, | |
| 1308 | }); | |
| 1309 | }); | |
| 1310 | ||
| 1311 | const ensureToolStarted = Effect.fnUntraced(function* ( | |
| 1312 | context: ActiveCursorTurn, | |
| 1313 | callId: string, | |
| 1314 | toolCall: ToolCall, | |
| 1315 | ) { | |
| 1316 | const existing = context.tools.get(callId); | |
| 1317 | if (existing !== undefined) { | |
| 1318 | existing.toolCall = toolCall; | |
| 1319 | return existing; | |
| 1320 | } | |
| 1321 | const startedAt = yield* DateTime.now; | |
| 1322 | const active: ActiveCursorToolCall = { | |
| 1323 | callId, | |
| 1324 | toolCall, | |
| 1325 | target: parentTarget(context), | |
| 1326 | ordinal: yield* resolveItemOrdinal(context, callId), | |
| 1327 | startedAt, | |
| 1328 | streamedOutput: "", | |
| 1329 | }; | |
| 1330 | context.tools.set(callId, active); | |
| 1331 | yield* emitToolArtifacts({ active, completed: false }); | |
| 1332 | return active; | |
| 1333 | }); | |
| 1334 | ||
| 1335 | const emitPlanArtifacts = Effect.fnUntraced(function* (input: { | |
| 1336 | readonly context: ActiveCursorTurn; | |
| 1337 | readonly callId: string; | |
| 1338 | readonly markdown: string; | |
| 1339 | readonly completed: boolean; | |
| 1340 | readonly failed: boolean; | |
| 1341 | }) { | |
| 1342 | const nativeItemId = `plan:${input.callId}`; | |
| 1343 | const now = yield* DateTime.now; | |
| 1344 | const planId = yield* resolvePlanId(input.context, nativeItemId); | |
| 1345 | const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId); | |
| 1346 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1347 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1348 | nativeItemId, | |
| 1349 | }); | |
| 1350 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1351 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1352 | nativeItemId, | |
| 1353 | }); | |
| 1354 | const status = input.failed ? "failed" : input.completed ? "completed" : "running"; | |
| 1355 | const nativeItemRef = { | |
| 1356 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1357 | nativeId: input.callId, | |
| 1358 | strength: "strong" as const, | |
| 1359 | }; | |
| 1360 | const plan: OrchestrationV2PlanArtifact = { | |
| 1361 | id: planId, | |
| 1362 | threadId: input.context.input.threadId, | |
| 1363 | runId: input.context.input.runId, | |
| 1364 | nodeId, | |
| 1365 | kind: "proposed_plan", | |
| 1366 | status: input.completed ? "active" : "draft", | |
| 1367 | markdown: input.markdown, | |
| 1368 | }; | |
| 1369 | yield* emitProviderEvent({ | |
| 1370 | type: "node.updated", | |
| 1371 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1372 | node: { | |
| 1373 | id: nodeId, | |
| 1374 | threadId: input.context.input.threadId, | |
| 1375 | runId: input.context.input.runId, | |
| 1376 | parentNodeId: input.context.input.rootNodeId, | |
| 1377 | rootNodeId: input.context.input.rootNodeId, | |
| 1378 | kind: "plan", | |
| 1379 | status, | |
| 1380 | countsForRun: false, | |
| 1381 | providerThreadId: input.context.input.providerThread.id, | |
| 1382 | providerTurnId: input.context.providerTurnId, | |
| 1383 | nativeItemRef, | |
| 1384 | runtimeRequestId: null, | |
| 1385 | checkpointScopeId: null, | |
| 1386 | startedAt: input.context.startedAt, | |
| 1387 | completedAt: input.completed ? now : null, | |
| 1388 | }, | |
| 1389 | }); | |
| 1390 | yield* emitProviderEvent({ | |
| 1391 | type: "plan.updated", | |
| 1392 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1393 | plan, | |
| 1394 | }); | |
| 1395 | yield* emitProviderEvent({ | |
| 1396 | type: "turn_item.updated", | |
| 1397 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1398 | turnItem: { | |
| 1399 | id: turnItemId, | |
| 1400 | threadId: input.context.input.threadId, | |
| 1401 | runId: input.context.input.runId, | |
| 1402 | nodeId, | |
| 1403 | providerThreadId: input.context.input.providerThread.id, | |
| 1404 | providerTurnId: input.context.providerTurnId, | |
| 1405 | nativeItemRef, | |
| 1406 | parentItemId: null, | |
| 1407 | ordinal, | |
| 1408 | status, | |
| 1409 | title: null, | |
| 1410 | startedAt: input.context.startedAt, | |
| 1411 | completedAt: input.completed ? now : null, | |
| 1412 | updatedAt: now, | |
| 1413 | type: "proposed_plan", | |
| 1414 | planId, | |
| 1415 | markdown: input.markdown, | |
| 1416 | streaming: !input.completed, | |
| 1417 | }, | |
| 1418 | }); | |
| 1419 | }); | |
| 1420 | ||
| 1421 | const emitTodoArtifacts = Effect.fnUntraced(function* (input: { | |
| 1422 | readonly context: ActiveCursorTurn; | |
| 1423 | readonly callId: string; | |
| 1424 | readonly toolCall: Extract<ToolCall, { readonly type: "updateTodos" }>; | |
| 1425 | readonly completed: boolean; | |
| 1426 | readonly failed: boolean; | |
| 1427 | }) { | |
| 1428 | const nativeItemId = `todos:${input.callId}`; | |
| 1429 | const now = yield* DateTime.now; | |
| 1430 | const planId = yield* resolvePlanId(input.context, nativeItemId); | |
| 1431 | const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId); | |
| 1432 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1433 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1434 | nativeItemId, | |
| 1435 | }); | |
| 1436 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1437 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1438 | nativeItemId, | |
| 1439 | }); | |
| 1440 | const status = input.failed ? "failed" : input.completed ? "completed" : "running"; | |
| 1441 | const steps = cursorTodoSteps(input.toolCall); | |
| 1442 | const nativeItemRef = { | |
| 1443 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1444 | nativeId: input.callId, | |
| 1445 | strength: "strong" as const, | |
| 1446 | }; | |
| 1447 | yield* emitProviderEvent({ | |
| 1448 | type: "node.updated", | |
| 1449 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1450 | node: { | |
| 1451 | id: nodeId, | |
| 1452 | threadId: input.context.input.threadId, | |
| 1453 | runId: input.context.input.runId, | |
| 1454 | parentNodeId: input.context.input.rootNodeId, | |
| 1455 | rootNodeId: input.context.input.rootNodeId, | |
| 1456 | kind: "todo_list", | |
| 1457 | status, | |
| 1458 | countsForRun: false, | |
| 1459 | providerThreadId: input.context.input.providerThread.id, | |
| 1460 | providerTurnId: input.context.providerTurnId, | |
| 1461 | nativeItemRef, | |
| 1462 | runtimeRequestId: null, | |
| 1463 | checkpointScopeId: null, | |
| 1464 | startedAt: input.context.startedAt, | |
| 1465 | completedAt: input.completed ? now : null, | |
| 1466 | }, | |
| 1467 | }); | |
| 1468 | yield* emitProviderEvent({ | |
| 1469 | type: "plan.updated", | |
| 1470 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1471 | plan: { | |
| 1472 | id: planId, | |
| 1473 | threadId: input.context.input.threadId, | |
| 1474 | runId: input.context.input.runId, | |
| 1475 | nodeId, | |
| 1476 | kind: "todo_list", | |
| 1477 | status: input.completed ? "active" : "draft", | |
| 1478 | steps: [...steps], | |
| 1479 | }, | |
| 1480 | }); | |
| 1481 | yield* emitProviderEvent({ | |
| 1482 | type: "turn_item.updated", | |
| 1483 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1484 | turnItem: { | |
| 1485 | id: turnItemId, | |
| 1486 | threadId: input.context.input.threadId, | |
| 1487 | runId: input.context.input.runId, | |
| 1488 | nodeId, | |
| 1489 | providerThreadId: input.context.input.providerThread.id, | |
| 1490 | providerTurnId: input.context.providerTurnId, | |
| 1491 | nativeItemRef, | |
| 1492 | parentItemId: null, | |
| 1493 | ordinal, | |
| 1494 | status, | |
| 1495 | title: null, | |
| 1496 | startedAt: input.context.startedAt, | |
| 1497 | completedAt: input.completed ? now : null, | |
| 1498 | updatedAt: now, | |
| 1499 | type: "todo_list", | |
| 1500 | planId, | |
| 1501 | steps: [...steps], | |
| 1502 | }, | |
| 1503 | }); | |
| 1504 | }); | |
| 1505 | ||
| 1506 | const emitSubagent = Effect.fnUntraced(function* (input: { | |
| 1507 | readonly context: ActiveCursorTurn; | |
| 1508 | readonly callId: string; | |
| 1509 | readonly toolCall: Extract<ToolCall, { readonly type: "task" }>; | |
| 1510 | readonly completed: boolean; | |
| 1511 | readonly status?: OrchestrationV2Subagent["status"]; | |
| 1512 | }) { | |
| 1513 | const args = input.toolCall.args; | |
| 1514 | const result = | |
| 1515 | input.toolCall.result?.status === "success" ? input.toolCall.result.value : undefined; | |
| 1516 | const existing = input.context.subagents.get(input.callId); | |
| 1517 | if ( | |
| 1518 | existing !== undefined && | |
| 1519 | !isOrchestrationV2WorkActive(existing.task.status) && | |
| 1520 | !input.completed | |
| 1521 | ) | |
| 1522 | return; | |
| 1523 | const now = yield* DateTime.now; | |
| 1524 | const status: OrchestrationV2Subagent["status"] = | |
| 1525 | input.status ?? | |
| 1526 | (input.completed | |
| 1527 | ? cursorToolFailed(input.toolCall) | |
| 1528 | ? "failed" | |
| 1529 | : "completed" | |
| 1530 | : "running"); | |
| 1531 | const resultText = [ | |
| 1532 | ...assistantTextsFromConversationSteps(result?.conversationSteps ?? []), | |
| 1533 | ...(result?.resultSuffix === undefined ? [] : [result.resultSuffix]), | |
| 1534 | ] | |
| 1535 | .filter((part) => part.trim().length > 0) | |
| 1536 | .join("\n"); | |
| 1537 | const nativeItemId = input.callId; | |
| 1538 | const nodeId = | |
| 1539 | existing?.task.id ?? | |
| 1540 | idAllocator.derive.nodeFromProviderItem({ | |
| 1541 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1542 | nativeItemId, | |
| 1543 | }); | |
| 1544 | const childRootNodeId = | |
| 1545 | existing?.childRootNodeId ?? | |
| 1546 | idAllocator.derive.nodeFromProviderItem({ | |
| 1547 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1548 | nativeItemId: `${nativeItemId}:child-root`, | |
| 1549 | }); | |
| 1550 | const childThreadId = | |
| 1551 | existing?.childThreadId ?? | |
| 1552 | idAllocator.derive.threadFromProviderThread({ | |
| 1553 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1554 | nativeThreadId: `${input.context.run.runId}:task:${input.callId}`, | |
| 1555 | }); | |
| 1556 | const task: OrchestrationV2Subagent = { | |
| 1557 | ...(existing?.task ?? { | |
| 1558 | id: nodeId, | |
| 1559 | threadId: input.context.input.threadId, | |
| 1560 | runId: input.context.input.runId, | |
| 1561 | parentNodeId: input.context.input.rootNodeId, | |
| 1562 | origin: "provider_native" as const, | |
| 1563 | createdBy: "agent" as const, | |
| 1564 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1565 | providerInstanceId: input.context.input.modelSelection.instanceId, | |
| 1566 | providerThreadId: null, | |
| 1567 | childThreadId, | |
| 1568 | nativeTaskRef: { | |
| 1569 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1570 | nativeId: input.callId, | |
| 1571 | strength: "strong" as const, | |
| 1572 | }, | |
| 1573 | prompt: args.prompt, | |
| 1574 | title: args.description, | |
| 1575 | result: null, | |
| 1576 | startedAt: now, | |
| 1577 | }), | |
| 1578 | model: args.model?.trim() || existing?.task.model || null, | |
| 1579 | nativeTaskRef: { | |
| 1580 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1581 | nativeId: input.callId, | |
| 1582 | strength: "strong" as const, | |
| 1583 | }, | |
| 1584 | status, | |
| 1585 | result: resultText.length === 0 ? (existing?.task.result ?? null) : resultText, | |
| 1586 | completedAt: input.completed ? (existing?.task.completedAt ?? now) : null, | |
| 1587 | updatedAt: now, | |
| 1588 | }; | |
| 1589 | const subagent: ActiveCursorSubagent = { | |
| 1590 | task, | |
| 1591 | toolCall: input.toolCall, | |
| 1592 | callId: input.callId, | |
| 1593 | childThreadId, | |
| 1594 | childRootNodeId, | |
| 1595 | turnItemId: | |
| 1596 | existing?.turnItemId ?? | |
| 1597 | idAllocator.derive.turnItemFromProviderItem({ | |
| 1598 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1599 | nativeItemId, | |
| 1600 | }), | |
| 1601 | turnItemOrdinal: | |
| 1602 | existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(input.context, nativeItemId)), | |
| 1603 | nextChildOrdinal: existing?.nextChildOrdinal ?? 100, | |
| 1604 | resultProjected: existing?.resultProjected ?? false, | |
| 1605 | }; | |
| 1606 | input.context.subagents.set(input.callId, subagent); | |
| 1607 | ||
| 1608 | if (existing === undefined) { | |
| 1609 | yield* emitProviderEvent({ | |
| 1610 | type: "app_thread.created", | |
| 1611 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1612 | appThread: makeSubagentChildThread({ | |
| 1613 | parentThread: input.context.input.appThread, | |
| 1614 | childThreadId, | |
| 1615 | parentNodeId: nodeId, | |
| 1616 | activeProviderThreadId: null, | |
| 1617 | providerInstanceId: input.context.input.modelSelection.instanceId, | |
| 1618 | modelSelection: input.context.input.modelSelection, | |
| 1619 | title: subagentThreadTitle({ | |
| 1620 | parentTitle: input.context.input.appThread.title, | |
| 1621 | title: args.description, | |
| 1622 | prompt: args.prompt, | |
| 1623 | ordinal: input.context.subagents.size, | |
| 1624 | }), | |
| 1625 | now, | |
| 1626 | createdBy: "agent", | |
| 1627 | creationSource: "provider", | |
| 1628 | }), | |
| 1629 | }); | |
| 1630 | const promptNativeId = `${nativeItemId}:prompt`; | |
| 1631 | const promptArtifacts = makeSubagentConversationArtifacts({ | |
| 1632 | senderThreadId: input.context.input.threadId, | |
| 1633 | messageId: idAllocator.derive.messageFromProviderItem({ | |
| 1634 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1635 | nativeItemId: promptNativeId, | |
| 1636 | }), | |
| 1637 | turnItemId: idAllocator.derive.turnItemFromProviderItem({ | |
| 1638 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1639 | nativeItemId: promptNativeId, | |
| 1640 | }), | |
| 1641 | threadId: childThreadId, | |
| 1642 | rootNodeId: childRootNodeId, | |
| 1643 | providerThreadId: null, | |
| 1644 | providerTurnId: null, | |
| 1645 | nativeItemRef: { | |
| 1646 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1647 | nativeId: promptNativeId, | |
| 1648 | strength: "weak", | |
| 1649 | }, | |
| 1650 | role: "user", | |
| 1651 | text: args.prompt, | |
| 1652 | ordinal: 100, | |
| 1653 | now, | |
| 1654 | }); | |
| 1655 | yield* emitProviderEvent({ | |
| 1656 | type: "message.updated", | |
| 1657 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1658 | message: promptArtifacts.message, | |
| 1659 | }); | |
| 1660 | yield* emitProviderEvent({ | |
| 1661 | type: "turn_item.updated", | |
| 1662 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1663 | turnItem: promptArtifacts.turnItem, | |
| 1664 | }); | |
| 1665 | } | |
| 1666 | ||
| 1667 | yield* emitProviderEvent({ | |
| 1668 | type: "node.updated", | |
| 1669 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1670 | node: { | |
| 1671 | id: nodeId, | |
| 1672 | threadId: input.context.input.threadId, | |
| 1673 | runId: input.context.input.runId, | |
| 1674 | parentNodeId: input.context.input.rootNodeId, | |
| 1675 | rootNodeId: input.context.input.rootNodeId, | |
| 1676 | kind: "subagent", | |
| 1677 | status, | |
| 1678 | countsForRun: false, | |
| 1679 | providerThreadId: input.context.input.providerThread.id, | |
| 1680 | providerTurnId: input.context.providerTurnId, | |
| 1681 | nativeItemRef: task.nativeTaskRef, | |
| 1682 | runtimeRequestId: null, | |
| 1683 | checkpointScopeId: null, | |
| 1684 | startedAt: task.startedAt, | |
| 1685 | completedAt: task.completedAt, | |
| 1686 | }, | |
| 1687 | }); | |
| 1688 | yield* emitProviderEvent({ | |
| 1689 | type: "node.updated", | |
| 1690 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1691 | node: { | |
| 1692 | id: childRootNodeId, | |
| 1693 | threadId: childThreadId, | |
| 1694 | runId: null, | |
| 1695 | parentNodeId: null, | |
| 1696 | rootNodeId: childRootNodeId, | |
| 1697 | kind: "root_turn", | |
| 1698 | status, | |
| 1699 | countsForRun: false, | |
| 1700 | providerThreadId: null, | |
| 1701 | providerTurnId: null, | |
| 1702 | nativeItemRef: task.nativeTaskRef, | |
| 1703 | runtimeRequestId: null, | |
| 1704 | checkpointScopeId: null, | |
| 1705 | startedAt: task.startedAt, | |
| 1706 | completedAt: task.completedAt, | |
| 1707 | }, | |
| 1708 | }); | |
| 1709 | yield* emitProviderEvent({ | |
| 1710 | type: "subagent.updated", | |
| 1711 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1712 | subagent: task, | |
| 1713 | }); | |
| 1714 | yield* emitProviderEvent({ | |
| 1715 | type: "turn_item.updated", | |
| 1716 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1717 | turnItem: { | |
| 1718 | id: subagent.turnItemId, | |
| 1719 | threadId: input.context.input.threadId, | |
| 1720 | runId: input.context.input.runId, | |
| 1721 | nodeId, | |
| 1722 | providerThreadId: input.context.input.providerThread.id, | |
| 1723 | providerTurnId: input.context.providerTurnId, | |
| 1724 | nativeItemRef: task.nativeTaskRef, | |
| 1725 | parentItemId: null, | |
| 1726 | ordinal: subagent.turnItemOrdinal, | |
| 1727 | status, | |
| 1728 | title: task.title, | |
| 1729 | startedAt: task.startedAt, | |
| 1730 | completedAt: task.completedAt, | |
| 1731 | updatedAt: now, | |
| 1732 | type: "subagent", | |
| 1733 | subagentId: task.id, | |
| 1734 | origin: task.origin, | |
| 1735 | driver: task.driver, | |
| 1736 | providerInstanceId: task.providerInstanceId, | |
| 1737 | childThreadId, | |
| 1738 | prompt: task.prompt, | |
| 1739 | result: task.result, | |
| 1740 | }, | |
| 1741 | }); | |
| 1742 | ||
| 1743 | if (input.completed && !subagent.resultProjected) { | |
| 1744 | for (const [index, nestedTool] of toolCallsFromConversationSteps( | |
| 1745 | result?.conversationSteps ?? [], | |
| 1746 | ).entries()) { | |
| 1747 | const callId = `${nativeItemId}:child-tool:${nestedTool.callId || index + 1}`; | |
| 1748 | const startedAt = task.startedAt ?? now; | |
| 1749 | const active: ActiveCursorToolCall = { | |
| 1750 | callId, | |
| 1751 | toolCall: nestedTool.toolCall, | |
| 1752 | target: { | |
| 1753 | threadId: childThreadId, | |
| 1754 | runId: null, | |
| 1755 | rootNodeId: childRootNodeId, | |
| 1756 | parentNodeId: childRootNodeId, | |
| 1757 | providerThreadId: null, | |
| 1758 | providerTurnId: null, | |
| 1759 | }, | |
| 1760 | ordinal: ++subagent.nextChildOrdinal, | |
| 1761 | startedAt, | |
| 1762 | streamedOutput: "", | |
| 1763 | }; | |
| 1764 | yield* emitToolArtifacts({ active, completed: true }); | |
| 1765 | } | |
| 1766 | if (task.result !== null && task.result.length > 0) { | |
| 1767 | const resultNativeId = `${nativeItemId}:result`; | |
| 1768 | const resultArtifacts = makeSubagentConversationArtifacts({ | |
| 1769 | messageId: idAllocator.derive.messageFromProviderItem({ | |
| 1770 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1771 | nativeItemId: resultNativeId, | |
| 1772 | }), | |
| 1773 | turnItemId: idAllocator.derive.turnItemFromProviderItem({ | |
| 1774 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1775 | nativeItemId: resultNativeId, | |
| 1776 | }), | |
| 1777 | threadId: childThreadId, | |
| 1778 | rootNodeId: childRootNodeId, | |
| 1779 | providerThreadId: null, | |
| 1780 | providerTurnId: null, | |
| 1781 | nativeItemRef: { | |
| 1782 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1783 | nativeId: resultNativeId, | |
| 1784 | strength: "weak", | |
| 1785 | }, | |
| 1786 | role: "assistant", | |
| 1787 | text: task.result, | |
| 1788 | ordinal: ++subagent.nextChildOrdinal, | |
| 1789 | now, | |
| 1790 | }); | |
| 1791 | yield* emitProviderEvent({ | |
| 1792 | type: "message.updated", | |
| 1793 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1794 | message: resultArtifacts.message, | |
| 1795 | }); | |
| 1796 | yield* emitProviderEvent({ | |
| 1797 | type: "turn_item.updated", | |
| 1798 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1799 | turnItem: resultArtifacts.turnItem, | |
| 1800 | }); | |
| 1801 | } | |
| 1802 | subagent.resultProjected = true; | |
| 1803 | } | |
| 1804 | }); | |
| 1805 | ||
| 1806 | const handleToolUpdate = Effect.fnUntraced(function* ( | |
| 1807 | context: ActiveCursorTurn, | |
| 1808 | update: Extract< | |
| 1809 | InteractionUpdate, | |
| 1810 | { | |
| 1811 | readonly type: "tool-call-started" | "partial-tool-call" | "tool-call-completed"; | |
| 1812 | } | |
| 1813 | >, | |
| 1814 | ) { | |
| 1815 | const completed = update.type === "tool-call-completed"; | |
| 1816 | const toolCall = update.toolCall; | |
| 1817 | switch (toolCall.type) { | |
| 1818 | case "createPlan": | |
| 1819 | yield* emitPlanArtifacts({ | |
| 1820 | context, | |
| 1821 | callId: update.callId, | |
| 1822 | markdown: toolCall.args.plan, | |
| 1823 | completed, | |
| 1824 | failed: completed && cursorToolFailed(toolCall), | |
| 1825 | }); | |
| 1826 | return; | |
| 1827 | case "updateTodos": | |
| 1828 | yield* emitTodoArtifacts({ | |
| 1829 | context, | |
| 1830 | callId: update.callId, | |
| 1831 | toolCall, | |
| 1832 | completed, | |
| 1833 | failed: completed && cursorToolFailed(toolCall), | |
| 1834 | }); | |
| 1835 | return; | |
| 1836 | case "task": | |
| 1837 | if (update.type === "partial-tool-call") { | |
| 1838 | return; | |
| 1839 | } | |
| 1840 | yield* emitSubagent({ | |
| 1841 | context, | |
| 1842 | callId: update.callId, | |
| 1843 | toolCall, | |
| 1844 | completed, | |
| 1845 | }); | |
| 1846 | return; | |
| 1847 | default: { | |
| 1848 | const active = yield* ensureToolStarted(context, update.callId, toolCall); | |
| 1849 | active.toolCall = toolCall; | |
| 1850 | if (update.type !== "tool-call-started") { | |
| 1851 | yield* emitToolArtifacts({ active, completed }); | |
| 1852 | } | |
| 1853 | if (completed) { | |
| 1854 | context.tools.delete(update.callId); | |
| 1855 | } | |
| 1856 | } | |
| 1857 | } | |
| 1858 | }); | |
| 1859 | ||
| 1860 | const shellOutputText = (event: Record<string, unknown>): string => { | |
| 1861 | const candidates = [ | |
| 1862 | event.text, | |
| 1863 | event.output, | |
| 1864 | event.data, | |
| 1865 | event.stdout, | |
| 1866 | event.stderr, | |
| 1867 | event.chunk, | |
| 1868 | ]; | |
| 1869 | return candidates.filter((value): value is string => typeof value === "string").join(""); | |
| 1870 | }; | |
| 1871 | ||
| 1872 | const handleInteractionUpdate = Effect.fnUntraced(function* ( | |
| 1873 | context: ActiveCursorTurn, | |
| 1874 | update: InteractionUpdate, | |
| 1875 | ) { | |
| 1876 | if (context.finalized) { | |
| 1877 | return; | |
| 1878 | } | |
| 1879 | switch (update.type) { | |
| 1880 | case "text-delta": | |
| 1881 | context.assistantReply.push(update.text); | |
| 1882 | yield* completeReasoning(context); | |
| 1883 | yield* appendTextSegment({ | |
| 1884 | context, | |
| 1885 | stream: context.assistant, | |
| 1886 | kind: "assistant", | |
| 1887 | text: update.text, | |
| 1888 | }); | |
| 1889 | yield* emitAssistant(context, false); | |
| 1890 | return; | |
| 1891 | case "thinking-delta": | |
| 1892 | yield* completeAssistant(context); | |
| 1893 | yield* appendTextSegment({ | |
| 1894 | context, | |
| 1895 | stream: context.reasoning, | |
| 1896 | kind: "reasoning", | |
| 1897 | text: update.text, | |
| 1898 | }); | |
| 1899 | yield* emitReasoning(context, false); | |
| 1900 | return; | |
| 1901 | case "thinking-completed": | |
| 1902 | yield* completeReasoning(context); | |
| 1903 | return; | |
| 1904 | case "tool-call-started": | |
| 1905 | case "partial-tool-call": | |
| 1906 | case "tool-call-completed": | |
| 1907 | yield* completeAssistant(context); | |
| 1908 | yield* completeReasoning(context); | |
| 1909 | yield* handleToolUpdate(context, update); | |
| 1910 | return; | |
| 1911 | case "step-completed": | |
| 1912 | case "turn-ended": | |
| 1913 | yield* completeAssistant(context); | |
| 1914 | yield* completeReasoning(context); | |
| 1915 | return; | |
| 1916 | case "shell-output-delta": { | |
| 1917 | const shell = Array.from(context.tools.values()) | |
| 1918 | .toReversed() | |
| 1919 | .find((candidate) => candidate.toolCall.type === "shell"); | |
| 1920 | if (shell === undefined) { | |
| 1921 | return; | |
| 1922 | } | |
| 1923 | shell.streamedOutput += shellOutputText(update.event); | |
| 1924 | yield* emitToolArtifacts({ active: shell, completed: false }); | |
| 1925 | return; | |
| 1926 | } | |
| 1927 | default: | |
| 1928 | return; | |
| 1929 | } | |
| 1930 | }); | |
| 1931 | ||
| 1932 | const providerTurnPayload = (input: { | |
| 1933 | readonly context: ActiveCursorTurn; | |
| 1934 | readonly status: OrchestrationV2ProviderTurn["status"]; | |
| 1935 | readonly completedAt: DateTime.Utc | null; | |
| 1936 | }): OrchestrationV2ProviderTurn => ({ | |
| 1937 | id: input.context.providerTurnId, | |
| 1938 | providerThreadId: input.context.input.providerThread.id, | |
| 1939 | nodeId: input.context.input.rootNodeId, | |
| 1940 | runAttemptId: input.context.input.attemptId, | |
| 1941 | nativeTurnRef: { | |
| 1942 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1943 | nativeId: input.context.run.runId, | |
| 1944 | strength: "strong", | |
| 1945 | }, | |
| 1946 | ordinal: input.context.input.providerTurnOrdinal, | |
| 1947 | status: input.status, | |
| 1948 | startedAt: input.context.startedAt, | |
| 1949 | completedAt: input.completedAt, | |
| 1950 | }); | |
| 1951 | ||
| 1952 | const finalizeTurn = Effect.fnUntraced(function* (input: { | |
| 1953 | readonly context: ActiveCursorTurn; | |
| 1954 | readonly status: Extract< | |
| 1955 | OrchestrationV2ProviderTurn["status"], | |
| 1956 | "completed" | "interrupted" | "failed" | "cancelled" | |
| 1957 | >; | |
| 1958 | readonly failure?: OrchestrationV2ProviderFailure; | |
| 1959 | readonly threadDisposition?: "reusable" | "broken"; | |
| 1960 | }) { | |
| 1961 | if (input.context.finalized) { | |
| 1962 | return; | |
| 1963 | } | |
| 1964 | input.context.finalized = true; | |
| 1965 | const completedAt = yield* DateTime.now; | |
| 1966 | // Tools still here never got a tool-call-completed. A stopped or | |
| 1967 | // failed turn cut them short, so they end with the turn's status. | |
| 1968 | for (const tool of input.context.tools.values()) { | |
| 1969 | yield* emitToolArtifacts({ | |
| 1970 | active: tool, | |
| 1971 | completed: true, | |
| 1972 | ...(input.status === "completed" ? {} : { unfinishedStatus: input.status }), | |
| 1973 | }); | |
| 1974 | } | |
| 1975 | input.context.tools.clear(); | |
| 1976 | // This interaction stops delivering updates at finalization. Tasks | |
| 1977 | // without a completion have an unknown outcome. Background launch | |
| 1978 | // acknowledgements have already settled their rows. | |
| 1979 | for (const subagent of input.context.subagents.values()) { | |
| 1980 | if (!isOrchestrationV2WorkActive(subagent.task.status)) continue; | |
| 1981 | yield* emitSubagent({ | |
| 1982 | context: input.context, | |
| 1983 | callId: subagent.callId, | |
| 1984 | toolCall: subagent.toolCall, | |
| 1985 | completed: true, | |
| 1986 | status: input.status === "completed" ? "idle" : input.status, | |
| 1987 | }); | |
| 1988 | } | |
| 1989 | yield* completeReasoning(input.context); | |
| 1990 | yield* completeAssistant(input.context); | |
| 1991 | yield* emitProviderEvent({ | |
| 1992 | type: "provider_turn.updated", | |
| 1993 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1994 | providerTurn: providerTurnPayload({ | |
| 1995 | context: input.context, | |
| 1996 | status: input.status, | |
| 1997 | completedAt, | |
| 1998 | }), | |
| 1999 | }); | |
| 2000 | yield* emitProviderEvent({ | |
| 2001 | type: "provider_thread.updated", | |
| 2002 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2003 | providerThread: { | |
| 2004 | ...input.context.input.providerThread, | |
| 2005 | providerSessionId: session.id, | |
| 2006 | status: "active", | |
| 2007 | firstRunOrdinal: | |
| 2008 | input.context.input.providerThread.firstRunOrdinal ?? | |
| 2009 | input.context.input.runOrdinal, | |
| 2010 | lastRunOrdinal: input.context.input.runOrdinal, | |
| 2011 | updatedAt: completedAt, | |
| 2012 | }, | |
| 2013 | }); | |
| 2014 | const threadDisposition = input.threadDisposition ?? "reusable"; | |
| 2015 | yield* emitProviderEvent( | |
| 2016 | input.status === "failed" | |
| 2017 | ? { | |
| 2018 | type: "turn.terminal", | |
| 2019 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2020 | providerThreadId: input.context.input.providerThread.id, | |
| 2021 | providerTurnId: input.context.providerTurnId, | |
| 2022 | runOrdinal: input.context.input.runOrdinal, | |
| 2023 | failureItemOrdinal: yield* resolveItemOrdinal( | |
| 2024 | input.context, | |
| 2025 | `terminal-failure:${input.context.providerTurnId}`, | |
| 2026 | ), | |
| 2027 | status: input.status, | |
| 2028 | failure: input.failure ?? makeProviderFailure({ class: "provider_error" }), | |
| 2029 | threadDisposition, | |
| 2030 | } | |
| 2031 | : { | |
| 2032 | type: "turn.terminal", | |
| 2033 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2034 | providerThreadId: input.context.input.providerThread.id, | |
| 2035 | providerTurnId: input.context.providerTurnId, | |
| 2036 | runOrdinal: input.context.input.runOrdinal, | |
| 2037 | status: input.status, | |
| 2038 | failure: null, | |
| 2039 | threadDisposition, | |
| 2040 | }, | |
| 2041 | ); | |
| 2042 | yield* Ref.update(activeTurn, (current) => | |
| 2043 | current?.providerTurnId === input.context.providerTurnId ? null : current, | |
| 2044 | ); | |
| 2045 | yield* Deferred.succeed(input.context.completed, undefined); | |
| 2046 | }); | |
| 2047 | ||
| 2048 | const terminalStatus = ( | |
| 2049 | context: ActiveCursorTurn, | |
| 2050 | result: RunResult, | |
| 2051 | ): Extract< | |
| 2052 | OrchestrationV2ProviderTurn["status"], | |
| 2053 | "completed" | "interrupted" | "failed" | "cancelled" | |
| 2054 | > => { | |
| 2055 | if (context.interrupted) { | |
| 2056 | return "interrupted"; | |
| 2057 | } | |
| 2058 | switch (result.status) { | |
| 2059 | case "finished": | |
| 2060 | return "completed"; | |
| 2061 | case "cancelled": | |
| 2062 | return "cancelled"; | |
| 2063 | case "error": | |
| 2064 | return "failed"; | |
| 2065 | } | |
| 2066 | }; | |
| 2067 | ||
| 2068 | const openAgent = Effect.fnUntraced(function* (openInput: { | |
| 2069 | readonly operation: "create" | "resume"; | |
| 2070 | readonly threadId: ThreadId; | |
| 2071 | readonly modelSelection: ModelSelection; | |
| 2072 | readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; | |
| 2073 | readonly agentId?: string; | |
| 2074 | }) { | |
| 2075 | const existing = yield* Ref.get(liveAgent); | |
| 2076 | if ( | |
| 2077 | existing !== null && | |
| 2078 | openInput.operation === "resume" && | |
| 2079 | existing.nativeThreadId === openInput.agentId | |
| 2080 | ) { | |
| 2081 | return existing; | |
| 2082 | } | |
| 2083 | if (existing !== null) { | |
| 2084 | yield* existing.session.close.pipe(Effect.ignore); | |
| 2085 | yield* Ref.set(liveAgent, null); | |
| 2086 | } | |
| 2087 | const sdkSession = yield* runner.open({ | |
| 2088 | operation: openInput.operation, | |
| 2089 | ...(openInput.agentId === undefined ? {} : { agentId: openInput.agentId }), | |
| 2090 | options: makeCursorAgentOptions({ | |
| 2091 | ...(apiKey === undefined ? {} : { apiKey }), | |
| 2092 | modelSelection: openInput.modelSelection, | |
| 2093 | runtimePolicy: openInput.runtimePolicy, | |
| 2094 | threadId: openInput.threadId, | |
| 2095 | }), | |
| 2096 | threadId: openInput.threadId, | |
| 2097 | providerSessionId: input.providerSessionId, | |
| 2098 | }); | |
| 2099 | const next = { | |
| 2100 | nativeThreadId: sdkSession.agentId, | |
| 2101 | session: sdkSession, | |
| 2102 | } satisfies CursorLiveAgent; | |
| 2103 | yield* Ref.set(liveAgent, next); | |
| 2104 | return next; | |
| 2105 | }); | |
| 2106 | ||
| 2107 | let cursorSkillNames: ReadonlySet<string> | undefined; | |
| 2108 | const resolveUserMessage = Effect.fnUntraced(function* ( | |
| 2109 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | |
| 2110 | ) { | |
| 2111 | const rawText = turnInput.message.text; | |
| 2112 | if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) { | |
| 2113 | return "/compress"; | |
| 2114 | } | |
| 2115 | if (hasCursorSkillMention(rawText) && cursorSkillNames === undefined) { | |
| 2116 | const skills = yield* discoverCursorSkills( | |
| 2117 | turnInput.runtimePolicy.cwd ?? undefined, | |
| 2118 | adapterOptions.environment, | |
| 2119 | ).pipe( | |
| 2120 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 2121 | Effect.provideService(Path.Path, path), | |
| 2122 | ); | |
| 2123 | cursorSkillNames = new Set( | |
| 2124 | skills | |
| 2125 | .filter((skill) => skill.enabled && skill.userInvocable !== false) | |
| 2126 | .map((skill) => skill.name), | |
| 2127 | ); | |
| 2128 | } | |
| 2129 | const userText = t3OrchestrationPromptForFirstRun({ | |
| 2130 | prompt: providerMessageTextWithAttachmentPaths({ | |
| 2131 | text: | |
| 2132 | cursorSkillNames === undefined | |
| 2133 | ? rawText | |
| 2134 | : rewriteCursorSkillMentions(rawText, cursorSkillNames), | |
| 2135 | attachments: turnInput.message.attachments, | |
| 2136 | resolveAttachmentPath: (attachment) => | |
| 2137 | resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), | |
| 2138 | }), | |
| 2139 | runOrdinal: turnInput.runOrdinal, | |
| 2140 | hasT3Mcp: cursorMcpServers(turnInput.threadId) !== undefined, | |
| 2141 | }); | |
| 2142 | const images = yield* Effect.forEach( | |
| 2143 | turnInput.message.attachments.filter(isProviderNativeImageAttachment), | |
| 2144 | (attachment: ChatAttachment) => | |
| 2145 | Effect.gen(function* () { | |
| 2146 | const path = resolveAttachmentPath({ | |
| 2147 | attachmentsDir: serverConfig.attachmentsDir, | |
| 2148 | attachment, | |
| 2149 | }); | |
| 2150 | if (path === null) { | |
| 2151 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2152 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2153 | detail: `Invalid attachment id '${attachment.id}'.`, | |
| 2154 | }); | |
| 2155 | } | |
| 2156 | const bytes = yield* fileSystem.readFile(path).pipe( | |
| 2157 | Effect.mapError( | |
| 2158 | (cause) => | |
| 2159 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2160 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2161 | detail: `Failed to read attachment '${attachment.id}'.`, | |
| 2162 | payload: cause, | |
| 2163 | }), | |
| 2164 | ), | |
| 2165 | ); | |
| 2166 | return { | |
| 2167 | data: Buffer.from(bytes).toString("base64"), | |
| 2168 | mimeType: attachment.mimeType, | |
| 2169 | }; | |
| 2170 | }), | |
| 2171 | { concurrency: 1 }, | |
| 2172 | ); | |
| 2173 | if (userText.length === 0 && images.length === 0) { | |
| 2174 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2175 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2176 | detail: "Cursor turn requires non-empty text or attachments.", | |
| 2177 | }); | |
| 2178 | } | |
| 2179 | const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`; | |
| 2180 | return images.length === 0 | |
| 2181 | ? text | |
| 2182 | : ({ | |
| 2183 | text, | |
| 2184 | images, | |
| 2185 | } satisfies SDKUserMessage); | |
| 2186 | }); | |
| 2187 | ||
| 2188 | const startTurn = Effect.fn("CursorAdapterV2.startTurn")( | |
| 2189 | function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { | |
| 2190 | const current = yield* Ref.get(activeTurn); | |
| 2191 | if (current !== null) { | |
| 2192 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2193 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2194 | detail: `Cursor provider turn ${current.providerTurnId} is still active.`, | |
| 2195 | }); | |
| 2196 | } | |
| 2197 | const agentId = nativeThreadId(turnInput.providerThread); | |
| 2198 | const agent = yield* openAgent({ | |
| 2199 | operation: "resume", | |
| 2200 | agentId, | |
| 2201 | threadId: turnInput.threadId, | |
| 2202 | modelSelection: turnInput.modelSelection, | |
| 2203 | runtimePolicy: turnInput.runtimePolicy, | |
| 2204 | }); | |
| 2205 | const message = yield* resolveUserMessage(turnInput); | |
| 2206 | const mcpServers = cursorMcpServers(turnInput.threadId); | |
| 2207 | const pendingUpdates: Array<InteractionUpdate> = []; | |
| 2208 | let context: ActiveCursorTurn | null = null; | |
| 2209 | const sdkRun = yield* agent.session.send({ | |
| 2210 | message, | |
| 2211 | options: { | |
| 2212 | model: cursorSdkModelSelection(turnInput.modelSelection), | |
| 2213 | mode: turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", | |
| 2214 | ...(mcpServers === undefined ? {} : { mcpServers }), | |
| 2215 | }, | |
| 2216 | onDelta: (update) => { | |
| 2217 | if (context === null) { | |
| 2218 | return Effect.sync(() => { | |
| 2219 | pendingUpdates.push(update); | |
| 2220 | }); | |
| 2221 | } | |
| 2222 | return handleInteractionUpdate(context, update); | |
| 2223 | }, | |
| 2224 | }); | |
| 2225 | const startedAt = yield* DateTime.now; | |
| 2226 | const completed = yield* Deferred.make<void, never>(); | |
| 2227 | const providerTurnId = idAllocator.derive.providerTurn({ | |
| 2228 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2229 | nativeTurnId: sdkRun.runId, | |
| 2230 | }); | |
| 2231 | context = { | |
| 2232 | input: turnInput, | |
| 2233 | run: sdkRun, | |
| 2234 | providerTurnId, | |
| 2235 | startedAt, | |
| 2236 | completed, | |
| 2237 | itemOrdinals: new Map(), | |
| 2238 | tools: new Map(), | |
| 2239 | subagents: new Map(), | |
| 2240 | assistant: { | |
| 2241 | current: null, | |
| 2242 | nextSegment: 0, | |
| 2243 | }, | |
| 2244 | assistantReply: new CursorTransportFailure(), | |
| 2245 | reasoning: { | |
| 2246 | current: null, | |
| 2247 | nextSegment: 0, | |
| 2248 | }, | |
| 2249 | interrupted: false, | |
| 2250 | finalized: false, | |
| 2251 | }; | |
| 2252 | yield* Ref.set(activeTurn, context); | |
| 2253 | yield* emitProviderEvent({ | |
| 2254 | type: "provider_turn.updated", | |
| 2255 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2256 | providerTurn: providerTurnPayload({ | |
| 2257 | context, | |
| 2258 | status: "running", | |
| 2259 | completedAt: null, | |
| 2260 | }), | |
| 2261 | }); | |
| 2262 | yield* emitProviderEvent({ | |
| 2263 | type: "provider_thread.updated", | |
| 2264 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2265 | providerThread: { | |
| 2266 | ...turnInput.providerThread, | |
| 2267 | providerSessionId: session.id, | |
| 2268 | status: "active", | |
| 2269 | updatedAt: startedAt, | |
| 2270 | }, | |
| 2271 | }); | |
| 2272 | for (const update of pendingUpdates) { | |
| 2273 | yield* handleInteractionUpdate(context, update); | |
| 2274 | } | |
| 2275 | ||
| 2276 | yield* sdkRun.wait.pipe( | |
| 2277 | Effect.flatMap((result) => | |
| 2278 | Effect.gen(function* () { | |
| 2279 | if ( | |
| 2280 | context !== null && | |
| 2281 | context.assistant.nextSegment === 0 && | |
| 2282 | result.result !== undefined && | |
| 2283 | result.result.length > 0 | |
| 2284 | ) { | |
| 2285 | context.assistantReply.push(result.result); | |
| 2286 | yield* appendTextSegment({ | |
| 2287 | context, | |
| 2288 | stream: context.assistant, | |
| 2289 | kind: "assistant", | |
| 2290 | text: result.result, | |
| 2291 | }); | |
| 2292 | } | |
| 2293 | if (context !== null) { | |
| 2294 | const transportFailure = context.assistantReply.failure; | |
| 2295 | const status = | |
| 2296 | transportFailure === undefined ? terminalStatus(context, result) : "failed"; | |
| 2297 | yield* finalizeTurn({ | |
| 2298 | context, | |
| 2299 | status, | |
| 2300 | ...(status === "failed" | |
| 2301 | ? { | |
| 2302 | failure: makeProviderFailure({ | |
| 2303 | cause: | |
| 2304 | transportFailure ?? (result as { readonly error?: unknown }).error, | |
| 2305 | class: | |
| 2306 | transportFailure === undefined | |
| 2307 | ? "provider_error" | |
| 2308 | : "transport_error", | |
| 2309 | }), | |
| 2310 | } | |
| 2311 | : {}), | |
| 2312 | }); | |
| 2313 | } | |
| 2314 | }), | |
| 2315 | ), | |
| 2316 | Effect.catch((cause) => | |
| 2317 | Effect.gen(function* () { | |
| 2318 | if (context !== null) { | |
| 2319 | yield* finalizeTurn({ | |
| 2320 | context, | |
| 2321 | status: context.interrupted ? "interrupted" : "failed", | |
| 2322 | ...(context.interrupted | |
| 2323 | ? {} | |
| 2324 | : { | |
| 2325 | failure: makeProviderFailure({ cause, class: "transport_error" }), | |
| 2326 | }), | |
| 2327 | }); | |
| 2328 | } | |
| 2329 | yield* Effect.logWarning("orchestration-v2.cursor-run-failed", { | |
| 2330 | providerSessionId: input.providerSessionId, | |
| 2331 | providerThreadId: turnInput.providerThread.id, | |
| 2332 | providerTurnId, | |
| 2333 | cause, | |
| 2334 | }); | |
| 2335 | }), | |
| 2336 | ), | |
| 2337 | Effect.forkIn(sessionScope), | |
| 2338 | ); | |
| 2339 | }, | |
| 2340 | (effect, turnInput) => | |
| 2341 | effect.pipe( | |
| 2342 | Effect.mapError( | |
| 2343 | (cause) => | |
| 2344 | new ProviderAdapter.ProviderAdapterTurnStartError({ | |
| 2345 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2346 | threadId: turnInput.threadId, | |
| 2347 | providerThreadId: turnInput.providerThread.id, | |
| 2348 | runId: turnInput.runId, | |
| 2349 | cause, | |
| 2350 | }), | |
| 2351 | ), | |
| 2352 | ), | |
| 2353 | ); | |
| 2354 | ||
| 2355 | const closeSession = Effect.fnUntraced(function* () { | |
| 2356 | const existing = yield* Ref.get(liveAgent); | |
| 2357 | if (existing !== null) { | |
| 2358 | yield* existing.session.close.pipe(Effect.ignore); | |
| 2359 | yield* Ref.set(liveAgent, null); | |
| 2360 | } | |
| 2361 | yield* runner.assertComplete.pipe( | |
| 2362 | Effect.catchCause((cause) => | |
| 2363 | Effect.logWarning("orchestration-v2.cursor-runner-incomplete", { | |
| 2364 | providerSessionId: input.providerSessionId, | |
| 2365 | cause, | |
| 2366 | }), | |
| 2367 | ), | |
| 2368 | ); | |
| 2369 | }); | |
| 2370 | yield* Effect.addFinalizer(() => closeSession()); | |
| 2371 | ||
| 2372 | const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { | |
| 2373 | instanceId: adapterOptions.instanceId, | |
| 2374 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2375 | providerSessionId: input.providerSessionId, | |
| 2376 | providerSession: session, | |
| 2377 | events: Stream.fromEffectRepeat(Queue.take(events)), | |
| 2378 | ensureThread: Effect.fn("CursorAdapterV2.ensureThread")( | |
| 2379 | function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) { | |
| 2380 | const opened = yield* openAgent({ | |
| 2381 | operation: "create", | |
| 2382 | threadId: threadInput.threadId, | |
| 2383 | modelSelection: threadInput.modelSelection, | |
| 2384 | runtimePolicy: threadInput.runtimePolicy, | |
| 2385 | }); | |
| 2386 | const now = yield* DateTime.now; | |
| 2387 | return makeProviderThread({ | |
| 2388 | idAllocator, | |
| 2389 | providerInstanceId: adapterOptions.instanceId, | |
| 2390 | appThreadId: threadInput.threadId, | |
| 2391 | providerSessionId: input.providerSessionId, | |
| 2392 | nativeThreadId: opened.nativeThreadId, | |
| 2393 | now, | |
| 2394 | }); | |
| 2395 | }, | |
| 2396 | (effect, threadInput) => | |
| 2397 | effect.pipe( | |
| 2398 | Effect.mapError( | |
| 2399 | (cause) => | |
| 2400 | new ProviderAdapter.ProviderAdapterEnsureThreadError({ | |
| 2401 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2402 | threadId: threadInput.threadId, | |
| 2403 | cause, | |
| 2404 | }), | |
| 2405 | ), | |
| 2406 | ), | |
| 2407 | ), | |
| 2408 | resumeThread: Effect.fn("CursorAdapterV2.resumeThread")( | |
| 2409 | function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) { | |
| 2410 | const agentId = nativeThreadId(threadInput.providerThread); | |
| 2411 | yield* openAgent({ | |
| 2412 | operation: "resume", | |
| 2413 | agentId, | |
| 2414 | threadId: threadInput.providerThread.appThreadId ?? input.threadId, | |
| 2415 | modelSelection: input.modelSelection, | |
| 2416 | runtimePolicy: input.runtimePolicy, | |
| 2417 | }); | |
| 2418 | const now = yield* DateTime.now; | |
| 2419 | return { | |
| 2420 | ...threadInput.providerThread, | |
| 2421 | providerSessionId: input.providerSessionId, | |
| 2422 | status: "idle" as const, | |
| 2423 | updatedAt: now, | |
| 2424 | }; | |
| 2425 | }, | |
| 2426 | (effect, threadInput) => | |
| 2427 | effect.pipe( | |
| 2428 | Effect.mapError( | |
| 2429 | (cause) => | |
| 2430 | new ProviderAdapter.ProviderAdapterResumeThreadError({ | |
| 2431 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2432 | providerSessionId: input.providerSessionId, | |
| 2433 | providerThreadId: threadInput.providerThread.id, | |
| 2434 | cause, | |
| 2435 | }), | |
| 2436 | ), | |
| 2437 | ), | |
| 2438 | ), | |
| 2439 | startTurn, | |
| 2440 | compactThread: (turnInput) => | |
| 2441 | startTurn({ | |
| 2442 | ...turnInput, | |
| 2443 | message: { ...turnInput.message, text: "/compress" }, | |
| 2444 | }), | |
| 2445 | steerTurn: (turnInput) => | |
| 2446 | Effect.fail( | |
| 2447 | new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ | |
| 2448 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2449 | providerThreadId: turnInput.providerThread.id, | |
| 2450 | }), | |
| 2451 | ), | |
| 2452 | interruptTurn: Effect.fn("CursorAdapterV2.interruptTurn")( | |
| 2453 | function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { | |
| 2454 | const context = yield* Ref.get(activeTurn); | |
| 2455 | // Stop on a settled turn: finalization already ended its tools | |
| 2456 | // and subagents, so nothing of it is left running to stop. | |
| 2457 | if (context === null && turnInput.requestRuntimeRestart === true) return; | |
| 2458 | if (context?.providerTurnId !== turnInput.providerTurnId) { | |
| 2459 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2460 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2461 | detail: `Cursor provider turn ${turnInput.providerTurnId} is not active.`, | |
| 2462 | }); | |
| 2463 | } | |
| 2464 | context.interrupted = true; | |
| 2465 | yield* context.run.cancel; | |
| 2466 | const stopped = yield* Deferred.await(context.completed).pipe( | |
| 2467 | Effect.timeoutOption("10 seconds"), | |
| 2468 | ); | |
| 2469 | if (Option.isSome(stopped)) { | |
| 2470 | return; | |
| 2471 | } | |
| 2472 | yield* Effect.logWarning("orchestration-v2.cursor-interrupt-timeout", { | |
| 2473 | providerSessionId: input.providerSessionId, | |
| 2474 | providerThreadId: turnInput.providerThread.id, | |
| 2475 | providerTurnId: turnInput.providerTurnId, | |
| 2476 | }); | |
| 2477 | yield* finalizeTurn({ context, status: "interrupted" }); | |
| 2478 | }, | |
| 2479 | (effect, turnInput) => | |
| 2480 | effect.pipe( | |
| 2481 | Effect.mapError( | |
| 2482 | (cause) => | |
| 2483 | new ProviderAdapter.ProviderAdapterInterruptError({ | |
| 2484 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2485 | providerThreadId: turnInput.providerThread.id, | |
| 2486 | providerTurnId: turnInput.providerTurnId, | |
| 2487 | cause, | |
| 2488 | }), | |
| 2489 | ), | |
| 2490 | ), | |
| 2491 | ), | |
| 2492 | respondToRuntimeRequest: (requestInput) => | |
| 2493 | Effect.fail( | |
| 2494 | new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ | |
| 2495 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2496 | requestId: requestInput.requestId, | |
| 2497 | cause: new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2498 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2499 | detail: "Cursor Agent SDK does not expose interactive approval requests.", | |
| 2500 | }), | |
| 2501 | }), | |
| 2502 | ), | |
| 2503 | readThreadSnapshot: Effect.fn("CursorAdapterV2.readThreadSnapshot")( | |
| 2504 | function* (snapshotInput) { | |
| 2505 | const requestedAgentId = nativeThreadId(snapshotInput.providerThread); | |
| 2506 | const agent = yield* openAgent({ | |
| 2507 | operation: "resume", | |
| 2508 | agentId: requestedAgentId, | |
| 2509 | threadId: snapshotInput.providerThread.appThreadId ?? input.threadId, | |
| 2510 | modelSelection: input.modelSelection, | |
| 2511 | runtimePolicy: input.runtimePolicy, | |
| 2512 | }); | |
| 2513 | const messages = yield* agent.session.listMessages; | |
| 2514 | const now = yield* DateTime.now; | |
| 2515 | const threadId = snapshotInput.providerThread.appThreadId ?? input.threadId; | |
| 2516 | const projectedMessages: Array<OrchestrationV2ConversationMessage> = messages.flatMap( | |
| 2517 | (message) => { | |
| 2518 | const text = textFromAgentMessage(message); | |
| 2519 | if (text.length === 0) { | |
| 2520 | return []; | |
| 2521 | } | |
| 2522 | return [ | |
| 2523 | { | |
| 2524 | createdBy: message.type === "user" ? "user" : "agent", | |
| 2525 | creationSource: "provider", | |
| 2526 | id: idAllocator.derive.messageFromProviderItem({ | |
| 2527 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2528 | nativeItemId: message.uuid, | |
| 2529 | }), | |
| 2530 | threadId, | |
| 2531 | runId: null, | |
| 2532 | nodeId: null, | |
| 2533 | role: message.type, | |
| 2534 | text, | |
| 2535 | attachments: [], | |
| 2536 | streaming: false, | |
| 2537 | createdAt: now, | |
| 2538 | updatedAt: now, | |
| 2539 | }, | |
| 2540 | ]; | |
| 2541 | }, | |
| 2542 | ); | |
| 2543 | return { | |
| 2544 | providerThread: { | |
| 2545 | ...snapshotInput.providerThread, | |
| 2546 | providerSessionId: input.providerSessionId, | |
| 2547 | status: "idle" as const, | |
| 2548 | updatedAt: now, | |
| 2549 | }, | |
| 2550 | providerTurns: [], | |
| 2551 | messages: projectedMessages, | |
| 2552 | runtimeRequests: [], | |
| 2553 | providerPayload: { | |
| 2554 | agentId: requestedAgentId, | |
| 2555 | messages, | |
| 2556 | }, | |
| 2557 | }; | |
| 2558 | }, | |
| 2559 | (effect, snapshotInput) => | |
| 2560 | effect.pipe( | |
| 2561 | Effect.mapError( | |
| 2562 | (cause) => | |
| 2563 | new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ | |
| 2564 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2565 | providerThreadId: snapshotInput.providerThread.id, | |
| 2566 | cause, | |
| 2567 | }), | |
| 2568 | ), | |
| 2569 | ), | |
| 2570 | ), | |
| 2571 | rollbackThread: (rollbackInput) => | |
| 2572 | Effect.fail( | |
| 2573 | new ProviderAdapter.ProviderAdapterRollbackThreadError({ | |
| 2574 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2575 | providerThreadId: rollbackInput.providerThread.id, | |
| 2576 | checkpointId: rollbackInput.target.checkpointId, | |
| 2577 | cause: "Cursor Agent SDK does not expose conversation rollback.", | |
| 2578 | }), | |
| 2579 | ), | |
| 2580 | forkThread: (forkInput) => | |
| 2581 | Effect.fail( | |
| 2582 | new ProviderAdapter.ProviderAdapterForkThreadError({ | |
| 2583 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2584 | providerThreadId: forkInput.sourceProviderThread.id, | |
| 2585 | cause: "Cursor Agent SDK does not expose native agent forks.", | |
| 2586 | }), | |
| 2587 | ), | |
| 2588 | }; | |
| 2589 | return runtime; | |
| 2590 | }, | |
| 2591 | (effect, input) => | |
| 2592 | effect.pipe( | |
| 2593 | Effect.mapError( | |
| 2594 | (cause) => | |
| 2595 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | |
| 2596 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2597 | providerSessionId: input.providerSessionId, | |
| 2598 | cause, | |
| 2599 | }), | |
| 2600 | ), | |
| 2601 | ), | |
| 2602 | ), | |
| 2603 | }); | |
| 2604 | } | |
| 2605 | ||
| 2606 | export type CursorAdapterV2DriverEnv = | |
| 2607 | | CursorAgentSdk.CursorAgentSdkRunner | |
| 2608 | | FileSystem.FileSystem | |
| 2609 | | Path.Path | |
| 2610 | | IdAllocator.IdAllocatorV2 | |
| 2611 | | ServerConfig.ServerConfig; | |
| 2612 | ||
| 2613 | export const CursorAdapterV2Driver: ProviderAdapterDriver< | |
| 2614 | CursorSettings, | |
| 2615 | CursorAdapterV2DriverEnv | |
| 2616 | > = { | |
| 2617 | driverKind: CURSOR_DRIVER_KIND, | |
| 2618 | configSchema: CursorSettings, | |
| 2619 | defaultConfig: (): CursorSettings => DEFAULT_CURSOR_SETTINGS, | |
| 2620 | create: Effect.fn("CursorAdapterV2Driver.create")( | |
| 2621 | function* (input: ProviderAdapterDriverCreateInput<CursorSettings>) { | |
| 2622 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 2623 | const fileSystem = yield* FileSystem.FileSystem; | |
| 2624 | const path = yield* Path.Path; | |
| 2625 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 2626 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 2627 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 2628 | return makeCursorAdapterV2({ | |
| 2629 | instanceId: input.instanceId, | |
| 2630 | settings: { | |
| 2631 | ...input.config, | |
| 2632 | enabled: input.enabled, | |
| 2633 | }, | |
| 2634 | environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 2635 | fileSystem, | |
| 2636 | path, | |
| 2637 | idAllocator, | |
| 2638 | runner, | |
| 2639 | serverConfig, | |
| 2640 | }); | |
| 2641 | }, | |
| 2642 | (effect, input) => | |
| 2643 | effect.pipe( | |
| 2644 | Effect.mapError( | |
| 2645 | (cause) => | |
| 2646 | new ProviderAdapterDriverCreateError({ | |
| 2647 | driver: CURSOR_DRIVER_KIND, | |
| 2648 | instanceId: input.instanceId, | |
| 2649 | detail: "Failed to create Cursor Agent SDK adapter.", | |
| 2650 | cause, | |
| 2651 | }), | |
| 2652 | ), | |
| 2653 | ), | |
| 2654 | ), | |
| 2655 | }; | |
| 2656 | ||
| 2657 | const layer: Layer.Layer< | |
| 2658 | ProviderAdapter.ProviderAdapterV2, | |
| 2659 | never, | |
| 2660 | | CursorAgentSdk.CursorAgentSdkRunner | |
| 2661 | | FileSystem.FileSystem | |
| 2662 | | Path.Path | |
| 2663 | | IdAllocator.IdAllocatorV2 | |
| 2664 | | ServerConfig.ServerConfig | |
| 2665 | > = Layer.effect( | |
| 2666 | ProviderAdapter.ProviderAdapterV2, | |
| 2667 | Effect.gen(function* () { | |
| 2668 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 2669 | const fileSystem = yield* FileSystem.FileSystem; | |
| 2670 | const path = yield* Path.Path; | |
| 2671 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 2672 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 2673 | const serverConfig = yield* ServerConfig.ServerConfig; | |
| 2674 | return makeCursorAdapterV2({ | |
| 2675 | instanceId: CURSOR_DEFAULT_INSTANCE_ID, | |
| 2676 | settings: DEFAULT_CURSOR_SETTINGS, | |
| 2677 | environment: hostEnvironment, | |
| 2678 | fileSystem, | |
| 2679 | path, | |
| 2680 | idAllocator, | |
| 2681 | runner, | |
| 2682 | serverConfig, | |
| 2683 | }); | |
| 2684 | }), | |
| 2685 | ); |
apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.test.ts deleted-304| ... | ... | @@ -1,304 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import { vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; | |
| 9 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | |
| 10 | ||
| 11 | const cursorSdkMock = vi.hoisted(() => { | |
| 12 | const closeExecutionOrder: Array<string> = []; | |
| 13 | const runOperations: Array<string> = []; | |
| 14 | let agentCloseFailure: unknown; | |
| 15 | let staleRunActive = false; | |
| 16 | const runWait = vi.fn(async () => ({ | |
| 17 | id: "run-cursor-agent-sdk-test", | |
| 18 | requestId: "request-cursor-agent-sdk-test", | |
| 19 | status: "finished", | |
| 20 | result: "ok", | |
| 21 | model: { id: "default" }, | |
| 22 | durationMs: 1, | |
| 23 | })); | |
| 24 | const runCancel = vi.fn(async () => {}); | |
| 25 | const agentClose = vi.fn(() => { | |
| 26 | closeExecutionOrder.push("sdk:agent.close"); | |
| 27 | if (agentCloseFailure !== undefined) { | |
| 28 | throw agentCloseFailure; | |
| 29 | } | |
| 30 | }); | |
| 31 | const send = vi.fn( | |
| 32 | async ( | |
| 33 | _message: unknown, | |
| 34 | options: { readonly onDelta?: (input: { readonly update: unknown }) => Promise<void> }, | |
| 35 | ) => { | |
| 36 | runOperations.push("run.start"); | |
| 37 | if (staleRunActive) { | |
| 38 | throw new Error("Agent agent-cursor-agent-sdk-test already has active run"); | |
| 39 | } | |
| 40 | await options.onDelta?.({ | |
| 41 | update: { | |
| 42 | type: "assistant-message-chunk", | |
| 43 | text: "hello", | |
| 44 | }, | |
| 45 | }); | |
| 46 | return { | |
| 47 | id: "run-cursor-agent-sdk-test", | |
| 48 | agentId: "agent-cursor-agent-sdk-test", | |
| 49 | wait: runWait, | |
| 50 | cancel: runCancel, | |
| 51 | }; | |
| 52 | }, | |
| 53 | ); | |
| 54 | const create = vi.fn(async () => ({ | |
| 55 | agentId: "agent-cursor-agent-sdk-test", | |
| 56 | send, | |
| 57 | close: agentClose, | |
| 58 | })); | |
| 59 | const resume = vi.fn(async () => ({ | |
| 60 | agentId: "agent-cursor-agent-sdk-test", | |
| 61 | send, | |
| 62 | close: agentClose, | |
| 63 | })); | |
| 64 | const listRuns = vi.fn(async () => { | |
| 65 | runOperations.push("agent.listRuns"); | |
| 66 | return { | |
| 67 | items: staleRunActive ? [{ id: "run-stale", status: "running" }] : [], | |
| 68 | }; | |
| 69 | }); | |
| 70 | const cancelRun = vi.fn(async () => { | |
| 71 | runOperations.push("agent.cancelRun"); | |
| 72 | staleRunActive = false; | |
| 73 | }); | |
| 74 | ||
| 75 | return { | |
| 76 | agentClose, | |
| 77 | cancelRun, | |
| 78 | closeExecutionOrder, | |
| 79 | create, | |
| 80 | listRuns, | |
| 81 | resume, | |
| 82 | runOperations, | |
| 83 | runCancel, | |
| 84 | runWait, | |
| 85 | send, | |
| 86 | setAgentCloseFailure: (failure: unknown) => { | |
| 87 | agentCloseFailure = failure; | |
| 88 | }, | |
| 89 | setStaleRunActive: (active: boolean) => { | |
| 90 | staleRunActive = active; | |
| 91 | }, | |
| 92 | }; | |
| 93 | }); | |
| 94 | ||
| 95 | vi.mock("../../provider/cursorSdk.ts", () => ({ | |
| 96 | Agent: { | |
| 97 | create: cursorSdkMock.create, | |
| 98 | resume: cursorSdkMock.resume, | |
| 99 | listRuns: cursorSdkMock.listRuns, | |
| 100 | cancelRun: cursorSdkMock.cancelRun, | |
| 101 | messages: { | |
| 102 | list: vi.fn(async () => []), | |
| 103 | }, | |
| 104 | }, | |
| 105 | })); | |
| 106 | ||
| 107 | const layerTest = CursorAgentSdk.layer.pipe( | |
| 108 | Layer.provide( | |
| 109 | Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, { | |
| 110 | native: { | |
| 111 | filePath: "cursor-agent-sdk-test.log", | |
| 112 | write: (event: unknown) => | |
| 113 | Effect.sync(() => { | |
| 114 | if ( | |
| 115 | typeof event === "object" && | |
| 116 | event !== null && | |
| 117 | Reflect.get(Reflect.get(event, "event"), "payload")?.type === "agent.close" | |
| 118 | ) { | |
| 119 | cursorSdkMock.closeExecutionOrder.push("protocol:agent.close"); | |
| 120 | } | |
| 121 | }), | |
| 122 | close: () => Effect.void, | |
| 123 | }, | |
| 124 | canonical: undefined, | |
| 125 | }), | |
| 126 | ), | |
| 127 | Layer.provide(NodeServices.layer), | |
| 128 | ); | |
| 129 | ||
| 130 | describe("CursorAgentSdkRunner", () => { | |
| 131 | it.effect("leaves a resumed agent alone when its next send succeeds", () => | |
| 132 | Effect.gen(function* () { | |
| 133 | cursorSdkMock.runOperations.length = 0; | |
| 134 | cursorSdkMock.setStaleRunActive(false); | |
| 135 | ||
| 136 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 137 | const session = yield* runner.open({ | |
| 138 | operation: "resume", | |
| 139 | agentId: "agent-cursor-agent-sdk-test", | |
| 140 | options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } }, | |
| 141 | threadId: ThreadId.make("thread-cursor-agent-sdk-normal-resume-test"), | |
| 142 | providerSessionId: ProviderSessionId.make( | |
| 143 | "provider-session-cursor-agent-sdk-normal-resume-test", | |
| 144 | ), | |
| 145 | }); | |
| 146 | const run = yield* session.send({ message: "continue" }); | |
| 147 | yield* run.wait; | |
| 148 | ||
| 149 | assert.deepStrictEqual(cursorSdkMock.runOperations, ["run.start"]); | |
| 150 | }).pipe(Effect.provide(layerTest)), | |
| 151 | ); | |
| 152 | ||
| 153 | it.effect("retries a resumed send after cancelling its abandoned local run", () => | |
| 154 | Effect.gen(function* () { | |
| 155 | cursorSdkMock.runOperations.length = 0; | |
| 156 | cursorSdkMock.listRuns.mockClear(); | |
| 157 | cursorSdkMock.cancelRun.mockClear(); | |
| 158 | cursorSdkMock.setStaleRunActive(true); | |
| 159 | ||
| 160 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 161 | const session = yield* runner.open({ | |
| 162 | operation: "resume", | |
| 163 | agentId: "agent-cursor-agent-sdk-test", | |
| 164 | options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } }, | |
| 165 | threadId: ThreadId.make("thread-cursor-agent-sdk-stale-run-test"), | |
| 166 | providerSessionId: ProviderSessionId.make( | |
| 167 | "provider-session-cursor-agent-sdk-stale-run-test", | |
| 168 | ), | |
| 169 | }); | |
| 170 | assert.deepStrictEqual(cursorSdkMock.runOperations, []); | |
| 171 | const run = yield* session.send({ message: "continue" }); | |
| 172 | yield* run.wait; | |
| 173 | ||
| 174 | assert.deepStrictEqual(cursorSdkMock.runOperations, [ | |
| 175 | "run.start", | |
| 176 | "agent.listRuns", | |
| 177 | "agent.cancelRun", | |
| 178 | "run.start", | |
| 179 | ]); | |
| 180 | assert.deepStrictEqual(cursorSdkMock.listRuns.mock.calls[0], [ | |
| 181 | "agent-cursor-agent-sdk-test", | |
| 182 | { runtime: "local", cwd: process.cwd(), limit: 1 }, | |
| 183 | ]); | |
| 184 | assert.deepStrictEqual(cursorSdkMock.cancelRun.mock.calls[0], [ | |
| 185 | "run-stale", | |
| 186 | { | |
| 187 | runtime: "local", | |
| 188 | cwd: process.cwd(), | |
| 189 | }, | |
| 190 | ]); | |
| 191 | }).pipe(Effect.provide(layerTest)), | |
| 192 | ); | |
| 193 | ||
| 194 | it.effect("logs agent.close before invoking the Cursor SDK", () => | |
| 195 | Effect.gen(function* () { | |
| 196 | cursorSdkMock.agentClose.mockClear(); | |
| 197 | cursorSdkMock.closeExecutionOrder.length = 0; | |
| 198 | cursorSdkMock.setAgentCloseFailure(undefined); | |
| 199 | ||
| 200 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 201 | const session = yield* runner.open({ | |
| 202 | operation: "create", | |
| 203 | options: { | |
| 204 | model: { id: "default" }, | |
| 205 | mode: "agent", | |
| 206 | local: { | |
| 207 | cwd: process.cwd(), | |
| 208 | }, | |
| 209 | }, | |
| 210 | threadId: ThreadId.make("thread-cursor-agent-sdk-close-order-test"), | |
| 211 | providerSessionId: ProviderSessionId.make( | |
| 212 | "provider-session-cursor-agent-sdk-close-order-test", | |
| 213 | ), | |
| 214 | }); | |
| 215 | ||
| 216 | cursorSdkMock.closeExecutionOrder.length = 0; | |
| 217 | yield* session.close; | |
| 218 | ||
| 219 | assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [ | |
| 220 | "protocol:agent.close", | |
| 221 | "sdk:agent.close", | |
| 222 | ]); | |
| 223 | assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1); | |
| 224 | }).pipe(Effect.provide(layerTest)), | |
| 225 | ); | |
| 226 | ||
| 227 | it.effect("logs agent.close even when the Cursor SDK close fails", () => | |
| 228 | Effect.gen(function* () { | |
| 229 | cursorSdkMock.agentClose.mockClear(); | |
| 230 | cursorSdkMock.closeExecutionOrder.length = 0; | |
| 231 | const closeFailure = new Error("agent close failed"); | |
| 232 | cursorSdkMock.setAgentCloseFailure(closeFailure); | |
| 233 | ||
| 234 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 235 | const session = yield* runner.open({ | |
| 236 | operation: "create", | |
| 237 | options: { | |
| 238 | model: { id: "default" }, | |
| 239 | mode: "agent", | |
| 240 | local: { | |
| 241 | cwd: process.cwd(), | |
| 242 | }, | |
| 243 | }, | |
| 244 | threadId: ThreadId.make("thread-cursor-agent-sdk-close-failure-test"), | |
| 245 | providerSessionId: ProviderSessionId.make( | |
| 246 | "provider-session-cursor-agent-sdk-close-failure-test", | |
| 247 | ), | |
| 248 | }); | |
| 249 | ||
| 250 | cursorSdkMock.closeExecutionOrder.length = 0; | |
| 251 | const error = yield* Effect.flip(session.close); | |
| 252 | ||
| 253 | assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError); | |
| 254 | assert.equal(error.method, "agent.close"); | |
| 255 | assert.strictEqual(error.cause, closeFailure); | |
| 256 | assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [ | |
| 257 | "protocol:agent.close", | |
| 258 | "sdk:agent.close", | |
| 259 | ]); | |
| 260 | assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1); | |
| 261 | cursorSdkMock.setAgentCloseFailure(undefined); | |
| 262 | }).pipe(Effect.provide(layerTest)), | |
| 263 | ); | |
| 264 | ||
| 265 | it.effect("surfaces interaction callback failures through run.wait", () => | |
| 266 | Effect.gen(function* () { | |
| 267 | cursorSdkMock.create.mockClear(); | |
| 268 | cursorSdkMock.runWait.mockClear(); | |
| 269 | cursorSdkMock.send.mockClear(); | |
| 270 | ||
| 271 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 272 | const session = yield* runner.open({ | |
| 273 | operation: "create", | |
| 274 | options: { | |
| 275 | model: { id: "default" }, | |
| 276 | mode: "agent", | |
| 277 | local: { | |
| 278 | cwd: process.cwd(), | |
| 279 | autoReview: false, | |
| 280 | sandboxOptions: { enabled: false }, | |
| 281 | enableAgentRetries: true, | |
| 282 | }, | |
| 283 | }, | |
| 284 | threadId: ThreadId.make("thread-cursor-agent-sdk-test"), | |
| 285 | providerSessionId: ProviderSessionId.make("provider-session-cursor-agent-sdk-test"), | |
| 286 | }); | |
| 287 | ||
| 288 | const callbackFailure = new Error("delta callback failed"); | |
| 289 | const run = yield* session.send({ | |
| 290 | message: "hello", | |
| 291 | onDelta: () => Effect.fail(callbackFailure), | |
| 292 | }); | |
| 293 | ||
| 294 | const error = yield* Effect.flip(run.wait); | |
| 295 | ||
| 296 | assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError); | |
| 297 | assert.equal(error.method, "run.wait"); | |
| 298 | assert.strictEqual(error.cause, callbackFailure); | |
| 299 | assert.equal(cursorSdkMock.create.mock.calls.length, 1); | |
| 300 | assert.equal(cursorSdkMock.send.mock.calls.length, 1); | |
| 301 | assert.equal(cursorSdkMock.runWait.mock.calls.length, 1); | |
| 302 | }).pipe(Effect.provide(layerTest)), | |
| 303 | ); | |
| 304 | }); |
apps/server/src/orchestration-v2/Adapters/CursorAgentSdk.ts deleted-612| ... | ... | @@ -1,612 +0,0 @@ |
| 1 | import type { | |
| 2 | AgentMessage, | |
| 3 | AgentOptions, | |
| 4 | InteractionUpdate, | |
| 5 | RunResult, | |
| 6 | SDKUserMessage, | |
| 7 | SendOptions, | |
| 8 | } from "@cursor/sdk"; | |
| 9 | import { | |
| 10 | type OrchestrationV2ProviderSession, | |
| 11 | ProviderDriverKind, | |
| 12 | type ThreadId, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import * as Context from "effect/Context"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Schema from "effect/Schema"; | |
| 18 | ||
| 19 | import { Agent, createAgentPlatform } from "../../provider/cursorSdk.ts"; | |
| 20 | import type { EventNdjsonLogger } from "../../provider/EventNdjsonLogger.ts"; | |
| 21 | import * as ProviderEventLoggers from "../../provider/ProviderEventLoggers.ts"; | |
| 22 | ||
| 23 | export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; | |
| 24 | export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor"); | |
| 25 | ||
| 26 | export class CursorAgentSdkRunnerError extends Schema.TaggedError<CursorAgentSdkRunnerError>()( | |
| 27 | "CursorAgentSdkRunnerError", | |
| 28 | { | |
| 29 | method: Schema.String, | |
| 30 | cause: Schema.Defect(), | |
| 31 | }, | |
| 32 | ) { | |
| 33 | override get message(): string { | |
| 34 | return `Cursor Agent SDK ${this.method} failed.`; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | const isCursorAgentSdkRunnerError = Schema.is(CursorAgentSdkRunnerError); | |
| 39 | ||
| 40 | export interface CursorAgentSdkOpenInput { | |
| 41 | readonly operation: "create" | "resume"; | |
| 42 | readonly agentId?: string; | |
| 43 | readonly options: AgentOptions; | |
| 44 | readonly threadId: ThreadId; | |
| 45 | readonly providerSessionId: OrchestrationV2ProviderSession["id"]; | |
| 46 | } | |
| 47 | ||
| 48 | export interface CursorAgentSdkSendInput<Error> { | |
| 49 | readonly message: string | SDKUserMessage; | |
| 50 | readonly options?: Omit<SendOptions, "onDelta">; | |
| 51 | readonly onDelta?: (update: InteractionUpdate) => Effect.Effect<void, Error>; | |
| 52 | } | |
| 53 | ||
| 54 | export interface CursorAgentSdkRun { | |
| 55 | readonly runId: string; | |
| 56 | readonly agentId: string; | |
| 57 | readonly wait: Effect.Effect<RunResult, CursorAgentSdkRunnerError>; | |
| 58 | readonly cancel: Effect.Effect<void, CursorAgentSdkRunnerError>; | |
| 59 | } | |
| 60 | ||
| 61 | export interface CursorAgentSdkSession { | |
| 62 | readonly agentId: string; | |
| 63 | readonly send: <Error>( | |
| 64 | input: CursorAgentSdkSendInput<Error>, | |
| 65 | ) => Effect.Effect<CursorAgentSdkRun, CursorAgentSdkRunnerError>; | |
| 66 | readonly listMessages: Effect.Effect<ReadonlyArray<AgentMessage>, CursorAgentSdkRunnerError>; | |
| 67 | readonly close: Effect.Effect<void, CursorAgentSdkRunnerError>; | |
| 68 | } | |
| 69 | ||
| 70 | export interface CursorAgentSdkRunnerShape { | |
| 71 | readonly open: ( | |
| 72 | input: CursorAgentSdkOpenInput, | |
| 73 | ) => Effect.Effect<CursorAgentSdkSession, CursorAgentSdkRunnerError>; | |
| 74 | readonly assertComplete: Effect.Effect<void, CursorAgentSdkRunnerError>; | |
| 75 | } | |
| 76 | ||
| 77 | export class CursorAgentSdkRunner extends Context.Service< | |
| 78 | CursorAgentSdkRunner, | |
| 79 | CursorAgentSdkRunnerShape | |
| 80 | >()("t3/orchestration-v2/Adapters/CursorAgentSdk/CursorAgentSdkRunner") {} | |
| 81 | ||
| 82 | export interface CursorAgentSdkLoggedAgentOptions { | |
| 83 | readonly model?: AgentOptions["model"]; | |
| 84 | readonly hasName?: boolean; | |
| 85 | readonly mode?: AgentOptions["mode"]; | |
| 86 | readonly local?: { | |
| 87 | readonly hasCwd?: boolean; | |
| 88 | readonly autoReview?: boolean; | |
| 89 | readonly settingSources?: AgentOptions["local"] extends infer Local | |
| 90 | ? Local extends { readonly settingSources?: infer Sources } | |
| 91 | ? Sources | |
| 92 | : never | |
| 93 | : never; | |
| 94 | readonly sandboxEnabled?: boolean; | |
| 95 | readonly enableAgentRetries?: boolean; | |
| 96 | readonly hasCustomTools?: boolean; | |
| 97 | }; | |
| 98 | readonly agents?: ReadonlyArray<string>; | |
| 99 | } | |
| 100 | ||
| 101 | export interface CursorAgentSdkLoggedSendOptions { | |
| 102 | readonly model?: SendOptions["model"]; | |
| 103 | readonly mode?: SendOptions["mode"]; | |
| 104 | readonly local?: { | |
| 105 | readonly force?: boolean; | |
| 106 | readonly hasCustomTools?: boolean; | |
| 107 | }; | |
| 108 | readonly idempotencyKey?: string; | |
| 109 | } | |
| 110 | ||
| 111 | export type CursorAgentSdkProtocolLogEvent = | |
| 112 | | { | |
| 113 | readonly direction: "outgoing"; | |
| 114 | readonly stage: "decoded"; | |
| 115 | readonly payload: { | |
| 116 | readonly type: "agent.open"; | |
| 117 | readonly operation: CursorAgentSdkOpenInput["operation"]; | |
| 118 | readonly agentId?: string; | |
| 119 | readonly options: CursorAgentSdkLoggedAgentOptions; | |
| 120 | }; | |
| 121 | } | |
| 122 | | { | |
| 123 | readonly direction: "incoming"; | |
| 124 | readonly stage: "decoded"; | |
| 125 | readonly payload: { | |
| 126 | readonly type: "agent.opened"; | |
| 127 | readonly agentId: string; | |
| 128 | }; | |
| 129 | } | |
| 130 | | { | |
| 131 | readonly direction: "outgoing"; | |
| 132 | readonly stage: "decoded"; | |
| 133 | readonly payload: { | |
| 134 | readonly type: "run.start"; | |
| 135 | readonly message: string | SDKUserMessage; | |
| 136 | readonly options: CursorAgentSdkLoggedSendOptions; | |
| 137 | }; | |
| 138 | } | |
| 139 | | { | |
| 140 | readonly direction: "incoming"; | |
| 141 | readonly stage: "decoded"; | |
| 142 | readonly payload: { | |
| 143 | readonly type: "run.started"; | |
| 144 | readonly runId: string; | |
| 145 | readonly agentId: string; | |
| 146 | }; | |
| 147 | } | |
| 148 | | { | |
| 149 | readonly direction: "incoming"; | |
| 150 | readonly stage: "decoded"; | |
| 151 | readonly payload: { | |
| 152 | readonly type: "interaction.update"; | |
| 153 | readonly runId: string; | |
| 154 | readonly update: InteractionUpdate; | |
| 155 | }; | |
| 156 | } | |
| 157 | | { | |
| 158 | readonly direction: "incoming"; | |
| 159 | readonly stage: "decoded"; | |
| 160 | readonly payload: { | |
| 161 | readonly type: "run.completed"; | |
| 162 | readonly result: RunResult; | |
| 163 | }; | |
| 164 | } | |
| 165 | | { | |
| 166 | readonly direction: "outgoing"; | |
| 167 | readonly stage: "decoded"; | |
| 168 | readonly payload: { | |
| 169 | readonly type: "run.cancel"; | |
| 170 | readonly runId: string; | |
| 171 | }; | |
| 172 | } | |
| 173 | | { | |
| 174 | readonly direction: "outgoing"; | |
| 175 | readonly stage: "decoded"; | |
| 176 | readonly payload: { | |
| 177 | readonly type: "agent.messages.list"; | |
| 178 | readonly agentId: string; | |
| 179 | }; | |
| 180 | } | |
| 181 | | { | |
| 182 | readonly direction: "incoming"; | |
| 183 | readonly stage: "decoded"; | |
| 184 | readonly payload: { | |
| 185 | readonly type: "agent.messages"; | |
| 186 | readonly agentId: string; | |
| 187 | readonly messages: ReadonlyArray<AgentMessage>; | |
| 188 | }; | |
| 189 | } | |
| 190 | | { | |
| 191 | readonly direction: "outgoing"; | |
| 192 | readonly stage: "decoded"; | |
| 193 | readonly payload: { | |
| 194 | readonly type: "agent.close"; | |
| 195 | readonly agentId: string; | |
| 196 | }; | |
| 197 | }; | |
| 198 | ||
| 199 | export type CursorAgentSdkProtocolLogger = ( | |
| 200 | event: CursorAgentSdkProtocolLogEvent, | |
| 201 | ) => Effect.Effect<void>; | |
| 202 | ||
| 203 | function runnerError(cause: unknown, method: string): CursorAgentSdkRunnerError { | |
| 204 | return isCursorAgentSdkRunnerError(cause) | |
| 205 | ? cause | |
| 206 | : new CursorAgentSdkRunnerError({ method, cause }); | |
| 207 | } | |
| 208 | ||
| 209 | function isActiveRunConflict(cause: unknown): boolean { | |
| 210 | return cause instanceof Error && /already has active run/i.test(cause.message); | |
| 211 | } | |
| 212 | ||
| 213 | export function isCursorCancellationError(cause: unknown): boolean { | |
| 214 | let current = cause; | |
| 215 | const seen = new Set<object>(); | |
| 216 | ||
| 217 | while (typeof current === "object" && current !== null && !seen.has(current)) { | |
| 218 | if (Reflect.get(current, "name") === "AbortError") { | |
| 219 | return true; | |
| 220 | } | |
| 221 | seen.add(current); | |
| 222 | current = Reflect.get(current, "cause"); | |
| 223 | } | |
| 224 | ||
| 225 | return false; | |
| 226 | } | |
| 227 | ||
| 228 | export function loggedCursorAgentOptions(options: AgentOptions): CursorAgentSdkLoggedAgentOptions { | |
| 229 | return { | |
| 230 | ...(options.model === undefined ? {} : { model: options.model }), | |
| 231 | ...(options.name === undefined ? {} : { hasName: true }), | |
| 232 | ...(options.mode === undefined ? {} : { mode: options.mode }), | |
| 233 | ...(options.local === undefined | |
| 234 | ? {} | |
| 235 | : { | |
| 236 | local: { | |
| 237 | ...(options.local.cwd === undefined ? {} : { hasCwd: true }), | |
| 238 | ...(options.local.autoReview === undefined | |
| 239 | ? {} | |
| 240 | : { autoReview: options.local.autoReview }), | |
| 241 | ...(options.local.settingSources === undefined | |
| 242 | ? {} | |
| 243 | : { settingSources: options.local.settingSources }), | |
| 244 | ...(options.local.sandboxOptions === undefined | |
| 245 | ? {} | |
| 246 | : { sandboxEnabled: options.local.sandboxOptions.enabled }), | |
| 247 | ...(options.local.enableAgentRetries === undefined | |
| 248 | ? {} | |
| 249 | : { enableAgentRetries: options.local.enableAgentRetries }), | |
| 250 | ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }), | |
| 251 | }, | |
| 252 | }), | |
| 253 | ...(options.agents === undefined ? {} : { agents: Object.keys(options.agents).toSorted() }), | |
| 254 | }; | |
| 255 | } | |
| 256 | ||
| 257 | export function loggedCursorSendOptions( | |
| 258 | options: Omit<SendOptions, "onDelta"> | undefined, | |
| 259 | ): CursorAgentSdkLoggedSendOptions { | |
| 260 | if (options === undefined) { | |
| 261 | return {}; | |
| 262 | } | |
| 263 | return { | |
| 264 | ...(options.model === undefined ? {} : { model: options.model }), | |
| 265 | ...(options.mode === undefined ? {} : { mode: options.mode }), | |
| 266 | ...(options.local === undefined | |
| 267 | ? {} | |
| 268 | : { | |
| 269 | local: { | |
| 270 | ...(options.local.force === undefined ? {} : { force: options.local.force }), | |
| 271 | ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }), | |
| 272 | }, | |
| 273 | }), | |
| 274 | ...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }), | |
| 275 | }; | |
| 276 | } | |
| 277 | ||
| 278 | function makeCursorAgentSdkProtocolLogger(input: { | |
| 279 | readonly nativeEventLogger: EventNdjsonLogger | undefined; | |
| 280 | readonly threadId: ThreadId; | |
| 281 | readonly providerSessionId: OrchestrationV2ProviderSession["id"]; | |
| 282 | }): CursorAgentSdkProtocolLogger | undefined { | |
| 283 | if (input.nativeEventLogger === undefined) { | |
| 284 | return undefined; | |
| 285 | } | |
| 286 | const nativeEventLogger = input.nativeEventLogger; | |
| 287 | return (event) => | |
| 288 | nativeEventLogger | |
| 289 | .write( | |
| 290 | { | |
| 291 | provider: CURSOR_PROVIDER, | |
| 292 | protocol: CURSOR_AGENT_SDK_PROTOCOL, | |
| 293 | kind: "protocol", | |
| 294 | providerSessionId: input.providerSessionId, | |
| 295 | event, | |
| 296 | }, | |
| 297 | input.threadId, | |
| 298 | ) | |
| 299 | .pipe(Effect.ignore); | |
| 300 | } | |
| 301 | ||
| 302 | /** | |
| 303 | * The Cursor SDK decides once per process whether local sandboxing works, and | |
| 304 | * caches the answer the first time any run starts. Only sandboxed runs point | |
| 305 | * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access) | |
| 306 | * run it caches "unsupported" and rejects every later sandboxed run until the | |
| 307 | * server restarts. Warming a bare sandboxed executor before the first | |
| 308 | * unsandboxed agent opens lets the SDK find the helper and cache the real | |
| 309 | * answer. Warming is best effort: on a machine without sandbox support it | |
| 310 | * fails, the SDK caches "unsupported", and sandboxed runs report that as | |
| 311 | * before. | |
| 312 | */ | |
| 313 | let cursorSandboxSupportPrime: Promise<void> | undefined; | |
| 314 | ||
| 315 | function primeCursorSandboxSupport(options: AgentOptions): Promise<void> { | |
| 316 | cursorSandboxSupportPrime ??= (async () => { | |
| 317 | const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined; | |
| 318 | const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd }); | |
| 319 | const release = await platform.prewarmLocalWorkspace({ | |
| 320 | ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), | |
| 321 | local: { | |
| 322 | ...(cwd === undefined ? {} : { cwd }), | |
| 323 | settingSources: [], | |
| 324 | sandboxOptions: { enabled: true }, | |
| 325 | }, | |
| 326 | }); | |
| 327 | await release(); | |
| 328 | })().catch(() => undefined); | |
| 329 | return cursorSandboxSupportPrime; | |
| 330 | } | |
| 331 | ||
| 332 | /** | |
| 333 | * Runs agents through the Cursor SDK, logging every frame to the protocol | |
| 334 | * logger chosen for each opened agent. The live layer writes the native | |
| 335 | * provider event log; the replay recorder turns the same frames into a | |
| 336 | * transcript. | |
| 337 | */ | |
| 338 | export function makeCursorAgentSdkRunner( | |
| 339 | protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined, | |
| 340 | ): CursorAgentSdkRunnerShape { | |
| 341 | return CursorAgentSdkRunner.of({ | |
| 342 | open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) { | |
| 343 | if (input.options.local?.sandboxOptions?.enabled === false) { | |
| 344 | yield* Effect.promise(() => primeCursorSandboxSupport(input.options)); | |
| 345 | } | |
| 346 | const protocolLogger = protocolLoggerFor(input); | |
| 347 | const log = (event: CursorAgentSdkProtocolLogEvent) => | |
| 348 | protocolLogger === undefined ? Effect.void : protocolLogger(event); | |
| 349 | ||
| 350 | yield* log({ | |
| 351 | direction: "outgoing", | |
| 352 | stage: "decoded", | |
| 353 | payload: { | |
| 354 | type: "agent.open", | |
| 355 | operation: input.operation, | |
| 356 | ...(input.agentId === undefined ? {} : { agentId: input.agentId }), | |
| 357 | options: loggedCursorAgentOptions(input.options), | |
| 358 | }, | |
| 359 | }); | |
| 360 | ||
| 361 | const agent = yield* Effect.tryPromise({ | |
| 362 | try: () => | |
| 363 | input.operation === "create" | |
| 364 | ? Agent.create(input.options) | |
| 365 | : Agent.resume(input.agentId!, input.options), | |
| 366 | catch: (cause) => runnerError(cause, `agent.${input.operation}`), | |
| 367 | }); | |
| 368 | ||
| 369 | yield* log({ | |
| 370 | direction: "incoming", | |
| 371 | stage: "decoded", | |
| 372 | payload: { | |
| 373 | type: "agent.opened", | |
| 374 | agentId: agent.agentId, | |
| 375 | }, | |
| 376 | }); | |
| 377 | ||
| 378 | const cwd = | |
| 379 | typeof input.options.local?.cwd === "string" | |
| 380 | ? input.options.local.cwd | |
| 381 | : input.options.local?.cwd?.[0]; | |
| 382 | const runOptions = { | |
| 383 | runtime: "local" as const, | |
| 384 | ...(cwd === undefined ? {} : { cwd }), | |
| 385 | ...(input.options.local?.store === undefined ? {} : { store: input.options.local.store }), | |
| 386 | }; | |
| 387 | let abandonedRunRecoveryAvailable = input.operation === "resume"; | |
| 388 | ||
| 389 | return { | |
| 390 | agentId: agent.agentId, | |
| 391 | send: Effect.fn("CursorAgentSdkSession.send")(function* (sendInput) { | |
| 392 | const context = yield* Effect.context(); | |
| 393 | yield* log({ | |
| 394 | direction: "outgoing", | |
| 395 | stage: "decoded", | |
| 396 | payload: { | |
| 397 | type: "run.start", | |
| 398 | message: sendInput.message, | |
| 399 | options: loggedCursorSendOptions(sendInput.options), | |
| 400 | }, | |
| 401 | }); | |
| 402 | ||
| 403 | let callbacksReady = false; | |
| 404 | const pendingUpdates: Array<InteractionUpdate> = []; | |
| 405 | let callbackFailure: { readonly cause: unknown } | undefined; | |
| 406 | let callbackChain = Promise.resolve(); | |
| 407 | let runId = ""; | |
| 408 | const dispatchUpdate = (update: InteractionUpdate): Promise<void> => { | |
| 409 | callbackChain = callbackChain | |
| 410 | .then(() => { | |
| 411 | if (callbackFailure !== undefined) { | |
| 412 | return; | |
| 413 | } | |
| 414 | return Effect.runPromiseWith(context)( | |
| 415 | log({ | |
| 416 | direction: "incoming", | |
| 417 | stage: "decoded", | |
| 418 | payload: { | |
| 419 | type: "interaction.update", | |
| 420 | runId, | |
| 421 | update, | |
| 422 | }, | |
| 423 | }).pipe(Effect.andThen(sendInput.onDelta?.(update) ?? Effect.void)), | |
| 424 | ); | |
| 425 | }) | |
| 426 | .catch((cause) => { | |
| 427 | callbackFailure ??= { cause }; | |
| 428 | }); | |
| 429 | return callbackChain; | |
| 430 | }; | |
| 431 | ||
| 432 | const startRun = () => | |
| 433 | Effect.tryPromise({ | |
| 434 | try: () => | |
| 435 | agent.send(sendInput.message, { | |
| 436 | ...sendInput.options, | |
| 437 | onDelta: async ({ update }) => { | |
| 438 | if (!callbacksReady) { | |
| 439 | pendingUpdates.push(update); | |
| 440 | return; | |
| 441 | } | |
| 442 | await dispatchUpdate(update); | |
| 443 | }, | |
| 444 | }), | |
| 445 | catch: (cause) => runnerError(cause, "run.start"), | |
| 446 | }); | |
| 447 | const run = yield* startRun().pipe( | |
| 448 | Effect.catchIf( | |
| 449 | (error) => abandonedRunRecoveryAvailable && isActiveRunConflict(error.cause), | |
| 450 | (error) => | |
| 451 | Effect.gen(function* () { | |
| 452 | abandonedRunRecoveryAvailable = false; | |
| 453 | const latestRun = yield* Effect.tryPromise({ | |
| 454 | try: () => Agent.listRuns(agent.agentId, { ...runOptions, limit: 1 }), | |
| 455 | catch: (cause) => runnerError(cause, "agent.listRuns"), | |
| 456 | }); | |
| 457 | const activeRun = latestRun.items.find( | |
| 458 | (candidate) => candidate.status === "running", | |
| 459 | ); | |
| 460 | if (activeRun === undefined) { | |
| 461 | return yield* error; | |
| 462 | } | |
| 463 | yield* log({ | |
| 464 | direction: "outgoing", | |
| 465 | stage: "decoded", | |
| 466 | payload: { type: "run.cancel", runId: activeRun.id }, | |
| 467 | }); | |
| 468 | yield* Effect.tryPromise({ | |
| 469 | try: () => Agent.cancelRun(activeRun.id, runOptions), | |
| 470 | catch: (cause) => runnerError(cause, "agent.cancelRun"), | |
| 471 | }); | |
| 472 | return yield* startRun(); | |
| 473 | }), | |
| 474 | ), | |
| 475 | ); | |
| 476 | abandonedRunRecoveryAvailable = false; | |
| 477 | runId = run.id; | |
| 478 | yield* log({ | |
| 479 | direction: "incoming", | |
| 480 | stage: "decoded", | |
| 481 | payload: { | |
| 482 | type: "run.started", | |
| 483 | runId: run.id, | |
| 484 | agentId: run.agentId, | |
| 485 | }, | |
| 486 | }); | |
| 487 | callbacksReady = true; | |
| 488 | for (const update of pendingUpdates) { | |
| 489 | yield* Effect.tryPromise({ | |
| 490 | try: () => dispatchUpdate(update), | |
| 491 | catch: (cause) => runnerError(cause, "run.onDelta"), | |
| 492 | }); | |
| 493 | } | |
| 494 | ||
| 495 | return { | |
| 496 | runId: run.id, | |
| 497 | agentId: run.agentId, | |
| 498 | wait: Effect.tryPromise({ | |
| 499 | try: async () => { | |
| 500 | const result = await run.wait(); | |
| 501 | await callbackChain; | |
| 502 | if (callbackFailure !== undefined) { | |
| 503 | throw callbackFailure.cause; | |
| 504 | } | |
| 505 | return result; | |
| 506 | }, | |
| 507 | catch: (cause) => runnerError(cause, "run.wait"), | |
| 508 | }).pipe( | |
| 509 | Effect.tap((result) => | |
| 510 | log({ | |
| 511 | direction: "incoming", | |
| 512 | stage: "decoded", | |
| 513 | payload: { | |
| 514 | type: "run.completed", | |
| 515 | result, | |
| 516 | }, | |
| 517 | }), | |
| 518 | ), | |
| 519 | ), | |
| 520 | cancel: log({ | |
| 521 | direction: "outgoing", | |
| 522 | stage: "decoded", | |
| 523 | payload: { | |
| 524 | type: "run.cancel", | |
| 525 | runId: run.id, | |
| 526 | }, | |
| 527 | }).pipe( | |
| 528 | Effect.andThen( | |
| 529 | Effect.tryPromise({ | |
| 530 | try: async () => { | |
| 531 | try { | |
| 532 | await run.cancel(); | |
| 533 | } catch (cause) { | |
| 534 | if (!isCursorCancellationError(cause)) { | |
| 535 | throw cause; | |
| 536 | } | |
| 537 | } | |
| 538 | }, | |
| 539 | catch: (cause) => runnerError(cause, "run.cancel"), | |
| 540 | }), | |
| 541 | ), | |
| 542 | ), | |
| 543 | } satisfies CursorAgentSdkRun; | |
| 544 | }), | |
| 545 | listMessages: log({ | |
| 546 | direction: "outgoing", | |
| 547 | stage: "decoded", | |
| 548 | payload: { | |
| 549 | type: "agent.messages.list", | |
| 550 | agentId: agent.agentId, | |
| 551 | }, | |
| 552 | }).pipe( | |
| 553 | Effect.andThen( | |
| 554 | Effect.tryPromise({ | |
| 555 | try: () => | |
| 556 | Agent.messages.list(agent.agentId, { | |
| 557 | runtime: "local", | |
| 558 | ...(cwd === undefined ? {} : { cwd }), | |
| 559 | }), | |
| 560 | catch: (cause) => runnerError(cause, "agent.messages.list"), | |
| 561 | }), | |
| 562 | ), | |
| 563 | Effect.tap((messages) => | |
| 564 | log({ | |
| 565 | direction: "incoming", | |
| 566 | stage: "decoded", | |
| 567 | payload: { | |
| 568 | type: "agent.messages", | |
| 569 | agentId: agent.agentId, | |
| 570 | messages, | |
| 571 | }, | |
| 572 | }), | |
| 573 | ), | |
| 574 | ), | |
| 575 | close: log({ | |
| 576 | direction: "outgoing", | |
| 577 | stage: "decoded", | |
| 578 | payload: { | |
| 579 | type: "agent.close", | |
| 580 | agentId: agent.agentId, | |
| 581 | }, | |
| 582 | }).pipe( | |
| 583 | Effect.andThen( | |
| 584 | Effect.try({ | |
| 585 | try: () => agent.close(), | |
| 586 | catch: (cause) => runnerError(cause, "agent.close"), | |
| 587 | }), | |
| 588 | ), | |
| 589 | ), | |
| 590 | } satisfies CursorAgentSdkSession; | |
| 591 | }), | |
| 592 | assertComplete: Effect.void, | |
| 593 | }); | |
| 594 | } | |
| 595 | ||
| 596 | export const layer: Layer.Layer< | |
| 597 | CursorAgentSdkRunner, | |
| 598 | never, | |
| 599 | ProviderEventLoggers.ProviderEventLoggers | |
| 600 | > = Layer.effect( | |
| 601 | CursorAgentSdkRunner, | |
| 602 | Effect.gen(function* () { | |
| 603 | const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; | |
| 604 | return makeCursorAgentSdkRunner((input) => | |
| 605 | makeCursorAgentSdkProtocolLogger({ | |
| 606 | nativeEventLogger, | |
| 607 | threadId: input.threadId, | |
| 608 | providerSessionId: input.providerSessionId, | |
| 609 | }), | |
| 610 | ); | |
| 611 | }), | |
| 612 | ); |
apps/server/src/orchestration-v2/CommandPolicy.test.ts+1-1| ... | ... | @@ -12,7 +12,7 @@ import { |
| 12 | 12 | import * as Effect from "effect/Effect"; |
| 13 | 13 | |
| 14 | 14 | import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; |
| 15 | import { CursorProviderCapabilitiesV2 } from "./Adapters/CursorAdapterV2.ts"; | |
| 15 | import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; | |
| 16 | 16 | import { GrokProviderCapabilitiesV2 } from "./Adapters/GrokAdapterV2.ts"; |
| 17 | 17 | import * as CommandPolicy from "./CommandPolicy.ts"; |
| 18 | 18 |
apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts+1-1| ... | ... | @@ -12,7 +12,7 @@ import { CodexAdapterV2Driver, type CodexAdapterV2DriverEnv } from "./Adapters/C |
| 12 | 12 | import { |
| 13 | 13 | CursorAdapterV2Driver, |
| 14 | 14 | type CursorAdapterV2DriverEnv, |
| 15 | } from "./Adapters/CursorAdapterV2.ts"; | |
| 15 | } from "@t3tools/provider-cursor/server"; | |
| 16 | 16 | import { GrokAdapterV2Driver, type GrokAdapterV2DriverEnv } from "./Adapters/GrokAdapterV2.ts"; |
| 17 | 17 | import { |
| 18 | 18 | OpenCodeAdapterV2Driver, |
apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts+1-1| ... | ... | @@ -43,7 +43,7 @@ import { |
| 43 | 43 | canReuseCodexContextUsage, |
| 44 | 44 | } from "../Adapters/CodexAdapterV2.ts"; |
| 45 | 45 | import { AcpProviderCapabilitiesV2 } from "../Adapters/AcpAdapterV2.ts"; |
| 46 | import { CursorProviderCapabilitiesV2 } from "../Adapters/CursorAdapterV2.ts"; | |
| 46 | import { CursorProviderCapabilitiesV2 } from "@t3tools/provider-cursor/testing"; | |
| 47 | 47 | import * as EventSink from "../EventSink.ts"; |
| 48 | 48 | import * as EventStore from "../EventStore.ts"; |
| 49 | 49 | import * as LegacyV1ThreadImporter from "../legacy/LegacyV1ThreadImporter.ts"; |
apps/server/src/provider/AntigravityProvider.test.ts+3-1| ... | ... | @@ -22,6 +22,7 @@ import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 22 | 22 | import * as ServerConfig from "../config.ts"; |
| 23 | 23 | import * as ServerSettings from "../serverSettings.ts"; |
| 24 | 24 | import * as ProviderHostLive from "./ProviderHostLive.ts"; |
| 25 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 25 | 26 | import type { AcpSessionRuntimeStartResult } from "./acp/AcpSessionRuntime.ts"; |
| 26 | 27 | import { |
| 27 | 28 | buildAntigravityModelsFromSession, |
| ... | ... | @@ -116,9 +117,10 @@ const layerTest = ProviderHostLive.layer.pipe( |
| 116 | 117 | }), |
| 117 | 118 | ), |
| 118 | 119 | Layer.provideMerge(ServerSettings.layerTest()), |
| 120 | Layer.provideMerge(ServerSecretStore.layer), | |
| 119 | 121 | Layer.provide( |
| 120 | 122 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-antigravity-provider-test-" }).pipe( |
| 121 | Layer.provide(NodeServices.layer), | |
| 123 | Layer.provideMerge(NodeServices.layer), | |
| 122 | 124 | ), |
| 123 | 125 | ), |
| 124 | 126 | ); |
apps/server/src/provider/CursorAuth.test.ts deleted-356| ... | ... | @@ -1,356 +0,0 @@ |
| 1 | import type { SdkLoginOptions, StoredSdkCredentials } from "@cursor/sdk"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { expect, it } from "@effect/vitest"; | |
| 4 | import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; | |
| 5 | import * as Clock from "effect/Clock"; | |
| 6 | import * as Deferred from "effect/Deferred"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Exit from "effect/Exit"; | |
| 9 | import * as Fiber from "effect/Fiber"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Scope from "effect/Scope"; | |
| 12 | import * as Stream from "effect/Stream"; | |
| 13 | import * as TestClock from "effect/testing/TestClock"; | |
| 14 | ||
| 15 | import { makeCursorAuth, type CursorAuthOptions } from "./CursorAuth.ts"; | |
| 16 | import { InMemoryCredentialStore } from "./cursorSdk.ts"; | |
| 17 | ||
| 18 | const instanceId = ProviderInstanceId.make("cursor-personal"); | |
| 19 | const owner = "owner-client"; | |
| 20 | const otherOwner = "other-client"; | |
| 21 | const authorizationUrl = "https://cursor.com/loginDeepControl?challenge=test-challenge"; | |
| 22 | ||
| 23 | const makeHarness = Effect.fn("makeCursorAuthHarness")(function* ( | |
| 24 | overrides: Partial<CursorAuthOptions> = {}, | |
| 25 | ) { | |
| 26 | const store = new InMemoryCredentialStore(); | |
| 27 | const complete = Promise.withResolvers<void>(); | |
| 28 | const returned = Promise.withResolvers<void>(); | |
| 29 | const now = yield* Clock.currentTimeMillis; | |
| 30 | const credentials: StoredSdkCredentials = { | |
| 31 | version: 1, | |
| 32 | backendUrl: "https://api2.cursor.sh", | |
| 33 | apiKey: "browser-key", | |
| 34 | createdAtMs: now, | |
| 35 | apiKeyExpiresAtMs: now + 86_400_000, | |
| 36 | email: "cursor@example.com", | |
| 37 | }; | |
| 38 | let loginOptions: SdkLoginOptions | undefined; | |
| 39 | const changes: boolean[] = []; | |
| 40 | const auth = yield* makeCursorAuth({ | |
| 41 | instanceId, | |
| 42 | displayName: "Personal Cursor", | |
| 43 | enabled: true, | |
| 44 | store, | |
| 45 | onChanged: (signedIn) => | |
| 46 | Effect.sync(() => { | |
| 47 | changes.push(signedIn); | |
| 48 | }), | |
| 49 | login: async (options) => { | |
| 50 | loginOptions = options; | |
| 51 | options.onLoginUrl?.(authorizationUrl); | |
| 52 | await complete.promise; | |
| 53 | await options.store?.save(credentials); | |
| 54 | returned.resolve(); | |
| 55 | return { | |
| 56 | apiKey: credentials.apiKey, | |
| 57 | apiKeyExpiresAtMs: credentials.apiKeyExpiresAtMs!, | |
| 58 | email: credentials.email!, | |
| 59 | }; | |
| 60 | }, | |
| 61 | ...overrides, | |
| 62 | }); | |
| 63 | return { | |
| 64 | auth, | |
| 65 | store, | |
| 66 | complete, | |
| 67 | returned, | |
| 68 | credentials, | |
| 69 | changes, | |
| 70 | loginOptions: () => loginOptions, | |
| 71 | }; | |
| 72 | }); | |
| 73 | ||
| 74 | type Auth = Effect.Success<ReturnType<typeof makeCursorAuth>>; | |
| 75 | const phase = (auth: Auth, phase: ProviderAuthState["phase"], session = owner) => | |
| 76 | auth.controller.subscribe(session).pipe( | |
| 77 | Stream.filter((state) => state.phase === phase), | |
| 78 | Stream.runHead, | |
| 79 | Effect.map(Option.getOrThrow), | |
| 80 | ); | |
| 81 | ||
| 82 | it.layer(NodeServices.layer)("CursorAuth", (it) => { | |
| 83 | it.effect("closes SDK sessions and interrupts in-flight credential work before signing out", () => | |
| 84 | Effect.gen(function* () { | |
| 85 | const harness = yield* makeHarness(); | |
| 86 | yield* Effect.promise(() => harness.store.save(harness.credentials)); | |
| 87 | const acquired = yield* Deferred.make<void>(); | |
| 88 | let closedSession = false; | |
| 89 | let closedRequest = false; | |
| 90 | yield* harness.auth.withAccess( | |
| 91 | Effect.acquireRelease(Effect.succeed("session"), () => | |
| 92 | Effect.sync(() => { | |
| 93 | closedSession = true; | |
| 94 | }), | |
| 95 | ), | |
| 96 | ); | |
| 97 | const request = yield* harness.auth | |
| 98 | .withAccess( | |
| 99 | Effect.gen(function* () { | |
| 100 | yield* Effect.addFinalizer(() => | |
| 101 | Effect.sync(() => { | |
| 102 | closedRequest = true; | |
| 103 | }), | |
| 104 | ); | |
| 105 | yield* Deferred.succeed(acquired, undefined); | |
| 106 | return yield* Effect.never; | |
| 107 | }), | |
| 108 | ) | |
| 109 | .pipe(Effect.forkScoped); | |
| 110 | yield* Deferred.await(acquired); | |
| 111 | yield* harness.auth.controller.logout(Effect.void); | |
| 112 | expect(closedSession).toBe(true); | |
| 113 | expect(closedRequest).toBe(true); | |
| 114 | expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true); | |
| 115 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 116 | }).pipe(Effect.scoped), | |
| 117 | ); | |
| 118 | ||
| 119 | it.effect("completes a remote browser login, saves its key and refreshes provider status", () => | |
| 120 | Effect.gen(function* () { | |
| 121 | const harness = yield* makeHarness(); | |
| 122 | let stopped = false; | |
| 123 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 124 | const starting = yield* harness.auth.controller.start( | |
| 125 | owner, | |
| 126 | Effect.sync(() => { | |
| 127 | stopped = true; | |
| 128 | }), | |
| 129 | ); | |
| 130 | const waiting = yield* phase(harness.auth, "waiting"); | |
| 131 | expect(stopped).toBe(true); | |
| 132 | expect(waiting.authorizationUrl).toBe(authorizationUrl); | |
| 133 | expect(waiting.flowId).toBe(starting.flowId); | |
| 134 | expect(harness.loginOptions()?.openBrowser).toBe(false); | |
| 135 | expect((yield* harness.auth.controller.start(owner)).flowId).toBe(starting.flowId); | |
| 136 | expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); | |
| 137 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 138 | const succeeded = yield* phase(harness.auth, "succeeded"); | |
| 139 | expect(succeeded.authorizationUrl).toBeNull(); | |
| 140 | expect(yield* harness.auth.requireApiKey).toBe("browser-key"); | |
| 141 | expect(harness.changes).toEqual([true]); | |
| 142 | expect(Object.values(succeeded)).not.toContain("browser-key"); | |
| 143 | expect(yield* Effect.promise(() => harness.store.load())).toEqual(harness.credentials); | |
| 144 | }).pipe(Effect.scoped), | |
| 145 | ); | |
| 146 | ||
| 147 | it.effect("keeps the login link private to its owner and rejects other clients' mutations", () => | |
| 148 | Effect.gen(function* () { | |
| 149 | const { auth } = yield* makeHarness(); | |
| 150 | yield* auth.controller.start(owner); | |
| 151 | const waiting = yield* phase(auth, "waiting"); | |
| 152 | const foreign = yield* phase(auth, "waiting", otherOwner); | |
| 153 | expect(foreign).toMatchObject({ flowId: null, authorizationUrl: null, expiresAt: null }); | |
| 154 | expect(Exit.isFailure(yield* Effect.exit(auth.controller.start(otherOwner)))).toBe(true); | |
| 155 | expect( | |
| 156 | Exit.isFailure(yield* Effect.exit(auth.controller.cancel(otherOwner, waiting.flowId!))), | |
| 157 | ).toBe(true); | |
| 158 | expect( | |
| 159 | Exit.isFailure( | |
| 160 | yield* Effect.exit( | |
| 161 | auth.controller.complete(owner, { | |
| 162 | flowId: waiting.flowId!, | |
| 163 | callbackUrl: "http://localhost/callback", | |
| 164 | }), | |
| 165 | ), | |
| 166 | ), | |
| 167 | ).toBe(true); | |
| 168 | expect((yield* phase(auth, "waiting")).authorizationUrl).toBe(authorizationUrl); | |
| 169 | }).pipe(Effect.scoped), | |
| 170 | ); | |
| 171 | ||
| 172 | it.effect("aborts cancellation and ignores a late successful SDK login", () => | |
| 173 | Effect.gen(function* () { | |
| 174 | const harness = yield* makeHarness(); | |
| 175 | yield* harness.auth.controller.start(owner); | |
| 176 | const waiting = yield* phase(harness.auth, "waiting"); | |
| 177 | const cancelled = yield* harness.auth.controller.cancel(owner, waiting.flowId!); | |
| 178 | expect(cancelled.phase).toBe("cancelled"); | |
| 179 | expect(cancelled.authorizationUrl).toBeNull(); | |
| 180 | expect(harness.loginOptions()?.signal?.aborted).toBe(true); | |
| 181 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 182 | yield* Effect.promise(() => harness.returned.promise); | |
| 183 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 184 | expect(harness.changes).toEqual([]); | |
| 185 | }).pipe(Effect.scoped), | |
| 186 | ); | |
| 187 | ||
| 188 | it.effect("times out pending logins and permits a retry", () => | |
| 189 | Effect.gen(function* () { | |
| 190 | const harness = yield* makeHarness(); | |
| 191 | yield* harness.auth.controller.start(owner); | |
| 192 | const waiting = yield* phase(harness.auth, "waiting"); | |
| 193 | yield* TestClock.adjust(300_000); | |
| 194 | expect((yield* phase(harness.auth, "failed")).authorizationUrl).toBeNull(); | |
| 195 | expect(harness.loginOptions()?.signal?.aborted).toBe(true); | |
| 196 | const retry = yield* harness.auth.controller.start(owner); | |
| 197 | expect(retry.flowId).not.toBe(waiting.flowId); | |
| 198 | }).pipe(Effect.scoped), | |
| 199 | ); | |
| 200 | ||
| 201 | it.effect("finishes saving a completed login atomically across the timeout", () => | |
| 202 | Effect.gen(function* () { | |
| 203 | const store = new InMemoryCredentialStore(); | |
| 204 | const saving = Promise.withResolvers<void>(); | |
| 205 | const finishSave = Promise.withResolvers<void>(); | |
| 206 | const scope = yield* Scope.make(); | |
| 207 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | |
| 208 | const harness = yield* makeHarness({ | |
| 209 | store: { | |
| 210 | load: () => store.load(), | |
| 211 | clear: () => store.clear(), | |
| 212 | save: async (credentials) => { | |
| 213 | saving.resolve(); | |
| 214 | await finishSave.promise; | |
| 215 | await store.save(credentials); | |
| 216 | }, | |
| 217 | }, | |
| 218 | }).pipe(Effect.provideService(Scope.Scope, scope)); | |
| 219 | yield* Effect.addFinalizer(() => Effect.sync(() => finishSave.resolve())); | |
| 220 | yield* harness.auth.controller.start(owner); | |
| 221 | yield* phase(harness.auth, "waiting"); | |
| 222 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 223 | yield* Effect.promise(() => saving.promise); | |
| 224 | yield* TestClock.adjust(300_000); | |
| 225 | yield* Effect.sync(() => finishSave.resolve()); | |
| 226 | yield* phase(harness.auth, "succeeded"); | |
| 227 | expect(yield* harness.auth.requireApiKey).toBe("browser-key"); | |
| 228 | yield* Scope.close(scope, Exit.void); | |
| 229 | const state = yield* harness.auth.controller.subscribe(owner).pipe(Stream.runHead); | |
| 230 | expect(Option.getOrThrow(state).phase).toBe("succeeded"); | |
| 231 | expect(harness.changes).toEqual([true]); | |
| 232 | }).pipe(Effect.scoped), | |
| 233 | ); | |
| 234 | ||
| 235 | it.effect("discards a credential rejected by verification and permits a fresh login", () => | |
| 236 | Effect.gen(function* () { | |
| 237 | let rejected = true; | |
| 238 | const harness = yield* makeHarness({ | |
| 239 | onChanged: (signedIn) => | |
| 240 | signedIn && rejected | |
| 241 | ? Effect.fail( | |
| 242 | new ProviderSetupError({ | |
| 243 | instanceId, | |
| 244 | operation: "start", | |
| 245 | detail: "Could not verify the Cursor sign-in.", | |
| 246 | }), | |
| 247 | ) | |
| 248 | : Effect.void, | |
| 249 | }); | |
| 250 | yield* harness.auth.controller.start(owner); | |
| 251 | yield* phase(harness.auth, "waiting"); | |
| 252 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 253 | yield* phase(harness.auth, "failed"); | |
| 254 | expect(yield* Effect.promise(() => harness.store.load())).toBeUndefined(); | |
| 255 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 256 | expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); | |
| 257 | rejected = false; | |
| 258 | yield* harness.auth.controller.start(owner); | |
| 259 | yield* phase(harness.auth, "succeeded"); | |
| 260 | expect(yield* harness.auth.requireApiKey).toBe("browser-key"); | |
| 261 | }).pipe(Effect.scoped), | |
| 262 | ); | |
| 263 | ||
| 264 | it.effect("clears only this instance's credential after its sessions stop", () => | |
| 265 | Effect.gen(function* () { | |
| 266 | const harness = yield* makeHarness(); | |
| 267 | const other = yield* makeHarness({ instanceId: ProviderInstanceId.make("cursor-work") }); | |
| 268 | yield* Effect.promise(() => harness.store.save(harness.credentials)); | |
| 269 | yield* Effect.promise(() => other.store.save({ ...other.credentials, apiKey: "other-key" })); | |
| 270 | const state = yield* harness.auth.controller.logout( | |
| 271 | Effect.gen(function* () { | |
| 272 | expect(yield* harness.auth.readApiKey).toBe("browser-key"); | |
| 273 | expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); | |
| 274 | }), | |
| 275 | ); | |
| 276 | expect(state.message).toBe("Signed out of Cursor."); | |
| 277 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 278 | expect(yield* other.auth.requireApiKey).toBe("other-key"); | |
| 279 | expect(harness.changes).toEqual([false]); | |
| 280 | }).pipe(Effect.scoped), | |
| 281 | ); | |
| 282 | ||
| 283 | it.effect("cancels a pending sign-in on logout without saving its eventual key", () => | |
| 284 | Effect.gen(function* () { | |
| 285 | const harness = yield* makeHarness(); | |
| 286 | yield* harness.auth.controller.start(owner); | |
| 287 | yield* phase(harness.auth, "waiting"); | |
| 288 | yield* harness.auth.controller.logout(Effect.void); | |
| 289 | expect(harness.loginOptions()?.signal?.aborted).toBe(true); | |
| 290 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 291 | yield* Effect.promise(() => harness.returned.promise); | |
| 292 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 293 | }).pipe(Effect.scoped), | |
| 294 | ); | |
| 295 | ||
| 296 | it.effect("retains credentials if sessions cannot be stopped for logout", () => | |
| 297 | Effect.gen(function* () { | |
| 298 | const harness = yield* makeHarness(); | |
| 299 | yield* Effect.promise(() => harness.store.save(harness.credentials)); | |
| 300 | const result = yield* Effect.exit( | |
| 301 | harness.auth.controller.logout( | |
| 302 | Effect.fail( | |
| 303 | new ProviderSetupError({ | |
| 304 | instanceId, | |
| 305 | operation: "stop", | |
| 306 | detail: "Unable to stop sessions", | |
| 307 | }), | |
| 308 | ), | |
| 309 | ), | |
| 310 | ); | |
| 311 | expect(Exit.isFailure(result)).toBe(true); | |
| 312 | expect(yield* harness.auth.requireApiKey).toBe("browser-key"); | |
| 313 | }).pipe(Effect.scoped), | |
| 314 | ); | |
| 315 | ||
| 316 | it.effect( | |
| 317 | "prefers explicit API keys and prevents browser login from silently overriding them", | |
| 318 | () => | |
| 319 | Effect.gen(function* () { | |
| 320 | const harness = yield* makeHarness({ apiKey: " explicit-key " }); | |
| 321 | yield* Effect.promise(() => harness.store.save(harness.credentials)); | |
| 322 | expect(yield* harness.auth.requireApiKey).toBe("explicit-key"); | |
| 323 | expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true); | |
| 324 | expect( | |
| 325 | Exit.isFailure(yield* Effect.exit(harness.auth.controller.logout(Effect.void))), | |
| 326 | ).toBe(true); | |
| 327 | expect(harness.loginOptions()).toBeUndefined(); | |
| 328 | expect((yield* Effect.promise(() => harness.store.load()))?.apiKey).toBe("browser-key"); | |
| 329 | }).pipe(Effect.scoped), | |
| 330 | ); | |
| 331 | ||
| 332 | it.effect("ignores expired browser credentials and rejects disabled sign-in", () => | |
| 333 | Effect.gen(function* () { | |
| 334 | const harness = yield* makeHarness({ enabled: false }); | |
| 335 | yield* Effect.promise(() => | |
| 336 | harness.store.save({ ...harness.credentials, apiKeyExpiresAtMs: 0 }), | |
| 337 | ); | |
| 338 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 339 | expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true); | |
| 340 | }).pipe(Effect.scoped), | |
| 341 | ); | |
| 342 | ||
| 343 | it.effect("aborts pending login when the provider instance is disposed", () => | |
| 344 | Effect.gen(function* () { | |
| 345 | const scope = yield* Scope.make(); | |
| 346 | const harness = yield* makeHarness().pipe(Effect.provideService(Scope.Scope, scope)); | |
| 347 | yield* harness.auth.controller.start(owner); | |
| 348 | yield* phase(harness.auth, "waiting"); | |
| 349 | yield* Scope.close(scope, Exit.void); | |
| 350 | expect(harness.loginOptions()?.signal?.aborted).toBe(true); | |
| 351 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 352 | yield* Effect.promise(() => harness.returned.promise); | |
| 353 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 354 | }), | |
| 355 | ); | |
| 356 | }); |
apps/server/src/provider/CursorAuth.ts deleted-495| ... | ... | @@ -1,495 +0,0 @@ |
| 1 | import type { SdkCredentialStore, SdkLoginOptions, SdkLoginResult } from "@cursor/sdk"; | |
| 2 | import { | |
| 3 | ProviderSetupError, | |
| 4 | type ProviderAuthState, | |
| 5 | type ProviderInstanceId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Clock from "effect/Clock"; | |
| 8 | import * as Crypto from "effect/Crypto"; | |
| 9 | import * as DateTime from "effect/DateTime"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Exit from "effect/Exit"; | |
| 12 | import * as Fiber from "effect/Fiber"; | |
| 13 | import * as Queue from "effect/Queue"; | |
| 14 | import * as Scope from "effect/Scope"; | |
| 15 | import * as Semaphore from "effect/Semaphore"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 18 | ||
| 19 | import { Cursor, InMemoryCredentialStore } from "./cursorSdk.ts"; | |
| 20 | import type { ProviderAuthController } from "./ProviderAuthService.ts"; | |
| 21 | ||
| 22 | const AUTH_TIMEOUT_MS = 300_000; | |
| 23 | ||
| 24 | interface AuthFlow { | |
| 25 | readonly id: string; | |
| 26 | readonly owner: string; | |
| 27 | fiber?: Fiber.Fiber<void>; | |
| 28 | } | |
| 29 | ||
| 30 | export interface CursorAuth { | |
| 31 | readonly controller: ProviderAuthController; | |
| 32 | readonly readApiKey: Effect.Effect<string | undefined, ProviderSetupError>; | |
| 33 | readonly requireApiKey: Effect.Effect<string, ProviderSetupError>; | |
| 34 | readonly usesApiKey: boolean; | |
| 35 | readonly withAccess: <A, E, R>( | |
| 36 | task: Effect.Effect<A, E, R>, | |
| 37 | ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>; | |
| 38 | } | |
| 39 | ||
| 40 | export interface CursorAuthOptions { | |
| 41 | readonly instanceId: ProviderInstanceId; | |
| 42 | readonly displayName: string; | |
| 43 | readonly enabled: boolean; | |
| 44 | readonly apiKey?: string; | |
| 45 | readonly store: SdkCredentialStore; | |
| 46 | readonly credentialBinding?: NonNullable<ProviderAuthController["credentialBinding"]>; | |
| 47 | readonly onChanged: (signedIn: boolean) => Effect.Effect<void, ProviderSetupError>; | |
| 48 | readonly login?: (options: SdkLoginOptions) => Promise<SdkLoginResult>; | |
| 49 | } | |
| 50 | ||
| 51 | /** Each instance owns its browser credential; explicit API keys always take precedence. */ | |
| 52 | export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: CursorAuthOptions) { | |
| 53 | const scope = yield* Scope.Scope; | |
| 54 | const crypto = yield* Crypto.Crypto; | |
| 55 | const lock = yield* Semaphore.make(1); | |
| 56 | const configuredKey = options.apiKey?.trim() || undefined; | |
| 57 | const emptyState: ProviderAuthState = { | |
| 58 | instanceId: options.instanceId, | |
| 59 | phase: "idle", | |
| 60 | flowId: null, | |
| 61 | authorizationUrl: null, | |
| 62 | expiresAt: null, | |
| 63 | message: null, | |
| 64 | }; | |
| 65 | const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: emptyState }); | |
| 66 | let active: AuthFlow | undefined; | |
| 67 | let operation: "idle" | "login" | "stopping" | "closed" = "idle"; | |
| 68 | const sessions = new Set<Scope.Closeable>(); | |
| 69 | ||
| 70 | const publish = (flow: AuthFlow, patch: Partial<ProviderAuthState>) => | |
| 71 | SubscriptionRef.update(snapshot, (current) => ({ | |
| 72 | owner: flow.owner, | |
| 73 | state: { ...current.state, ...patch }, | |
| 74 | })); | |
| 75 | ||
| 76 | const readApiKey = Effect.gen(function* () { | |
| 77 | if (configuredKey) return configuredKey; | |
| 78 | const credentials = yield* Effect.tryPromise({ | |
| 79 | try: () => options.store.load(), | |
| 80 | catch: (cause) => | |
| 81 | new ProviderSetupError({ | |
| 82 | instanceId: options.instanceId, | |
| 83 | operation: "credentials", | |
| 84 | detail: "Could not read the Cursor sign-in. Try signing in again.", | |
| 85 | cause, | |
| 86 | }), | |
| 87 | }); | |
| 88 | const now = yield* Clock.currentTimeMillis; | |
| 89 | return credentials && | |
| 90 | (credentials.apiKeyExpiresAtMs === undefined || credentials.apiKeyExpiresAtMs > now) | |
| 91 | ? credentials.apiKey | |
| 92 | : undefined; | |
| 93 | }); | |
| 94 | ||
| 95 | const requireApiKey = Effect.gen(function* () { | |
| 96 | if (operation !== "idle") { | |
| 97 | return yield* new ProviderSetupError({ | |
| 98 | instanceId: options.instanceId, | |
| 99 | operation: "credentials", | |
| 100 | detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.", | |
| 101 | }); | |
| 102 | } | |
| 103 | const apiKey = yield* readApiKey; | |
| 104 | if (!apiKey) { | |
| 105 | return yield* new ProviderSetupError({ | |
| 106 | instanceId: options.instanceId, | |
| 107 | operation: "credentials", | |
| 108 | detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | |
| 109 | }); | |
| 110 | } | |
| 111 | return apiKey; | |
| 112 | }); | |
| 113 | ||
| 114 | // Keep SDK sessions and one-shot text generation inside instance-owned scopes. | |
| 115 | // Closing admission before draining these scopes prevents cached credentials surviving sign-out. | |
| 116 | const withAccess: CursorAuth["withAccess"] = (task) => | |
| 117 | Effect.uninterruptibleMask((restore) => | |
| 118 | Effect.gen(function* () { | |
| 119 | const parent = yield* Scope.Scope; | |
| 120 | const child = yield* lock.withPermits(1)( | |
| 121 | Effect.gen(function* () { | |
| 122 | if (operation !== "idle") | |
| 123 | return yield* new ProviderSetupError({ | |
| 124 | instanceId: options.instanceId, | |
| 125 | operation: "session", | |
| 126 | detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.", | |
| 127 | }); | |
| 128 | const child = yield* Scope.make(); | |
| 129 | sessions.add(child); | |
| 130 | yield* Scope.addFinalizer( | |
| 131 | child, | |
| 132 | Effect.sync(() => { | |
| 133 | sessions.delete(child); | |
| 134 | }), | |
| 135 | ); | |
| 136 | yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); | |
| 137 | return child; | |
| 138 | }), | |
| 139 | ); | |
| 140 | const fiber = yield* restore(task).pipe( | |
| 141 | Effect.provideService(Scope.Scope, child), | |
| 142 | Effect.forkIn(child), | |
| 143 | ); | |
| 144 | return yield* restore(Fiber.await(fiber)).pipe( | |
| 145 | Effect.flatMap((result) => result), | |
| 146 | Effect.onExit((result) => | |
| 147 | Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, | |
| 148 | ), | |
| 149 | ); | |
| 150 | }), | |
| 151 | ); | |
| 152 | const stopSessionsWithCredentials = Effect.suspend(() => | |
| 153 | Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { | |
| 154 | discard: true, | |
| 155 | concurrency: "unbounded", | |
| 156 | }), | |
| 157 | ); | |
| 158 | ||
| 159 | const runLogin = (flow: AuthFlow, stopSessions: Effect.Effect<void, ProviderSetupError>) => | |
| 160 | Effect.gen(function* () { | |
| 161 | yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials)); | |
| 162 | // The SDK first writes into memory. Cancellation must not save a late login result. | |
| 163 | const pendingStore = new InMemoryCredentialStore(); | |
| 164 | const urls = yield* Queue.unbounded<string>(); | |
| 165 | yield* Queue.take(urls).pipe( | |
| 166 | Effect.flatMap((authorizationUrl) => | |
| 167 | lock.withPermits(1)( | |
| 168 | Effect.gen(function* () { | |
| 169 | if (active !== flow) return; | |
| 170 | yield* publish(flow, { | |
| 171 | phase: "waiting", | |
| 172 | authorizationUrl, | |
| 173 | message: | |
| 174 | "Open the Cursor sign-in page and finish signing in. This page updates automatically.", | |
| 175 | }); | |
| 176 | }), | |
| 177 | ), | |
| 178 | ), | |
| 179 | Effect.forever, | |
| 180 | Effect.forkScoped, | |
| 181 | ); | |
| 182 | yield* Effect.tryPromise({ | |
| 183 | try: (signal) => | |
| 184 | (options.login ?? Cursor.auth.login)({ | |
| 185 | openBrowser: false, | |
| 186 | store: pendingStore, | |
| 187 | signal, | |
| 188 | apiKeyName: `T3 Code - ${options.displayName}`, | |
| 189 | onLoginUrl: (authorizationUrl) => { | |
| 190 | if (active === flow) Queue.offerUnsafe(urls, authorizationUrl); | |
| 191 | }, | |
| 192 | }), | |
| 193 | catch: (cause) => | |
| 194 | new ProviderSetupError({ | |
| 195 | instanceId: options.instanceId, | |
| 196 | operation: "start", | |
| 197 | detail: "Cursor sign-in failed. Start sign-in again.", | |
| 198 | cause, | |
| 199 | }), | |
| 200 | }); | |
| 201 | // Commit and publish together; interrupting a store Promise cannot stop a late write. | |
| 202 | yield* lock.withPermits(1)( | |
| 203 | Effect.gen(function* () { | |
| 204 | if (active !== flow) return; | |
| 205 | yield* publish(flow, { | |
| 206 | phase: "verifying", | |
| 207 | authorizationUrl: null, | |
| 208 | message: "Checking Cursor sign-in.", | |
| 209 | }); | |
| 210 | yield* Effect.tryPromise({ | |
| 211 | try: async () => { | |
| 212 | const credentials = await pendingStore.load(); | |
| 213 | if (!credentials) throw new Error("Cursor login did not return credentials"); | |
| 214 | await options.store.save(credentials); | |
| 215 | }, | |
| 216 | catch: (cause) => | |
| 217 | new ProviderSetupError({ | |
| 218 | instanceId: options.instanceId, | |
| 219 | operation: "start", | |
| 220 | detail: "Could not save the Cursor sign-in. Try again.", | |
| 221 | cause, | |
| 222 | }), | |
| 223 | }); | |
| 224 | const verified = yield* options.onChanged(true).pipe(Effect.exit); | |
| 225 | if (Exit.isFailure(verified)) { | |
| 226 | yield* Effect.tryPromise({ | |
| 227 | try: () => options.store.clear(), | |
| 228 | catch: (cause) => | |
| 229 | new ProviderSetupError({ | |
| 230 | instanceId: options.instanceId, | |
| 231 | operation: "start", | |
| 232 | detail: "Could not clear the rejected Cursor sign-in. Try signing out.", | |
| 233 | cause, | |
| 234 | }), | |
| 235 | }); | |
| 236 | return yield* Effect.failCause(verified.cause); | |
| 237 | } | |
| 238 | active = undefined; | |
| 239 | operation = "idle"; | |
| 240 | yield* publish(flow, { | |
| 241 | phase: "succeeded", | |
| 242 | authorizationUrl: null, | |
| 243 | expiresAt: null, | |
| 244 | message: "Signed in with Cursor.", | |
| 245 | }); | |
| 246 | }).pipe(Effect.uninterruptible), | |
| 247 | ); | |
| 248 | }).pipe( | |
| 249 | Effect.scoped, | |
| 250 | Effect.timeoutOrElse({ | |
| 251 | duration: AUTH_TIMEOUT_MS, | |
| 252 | orElse: () => | |
| 253 | Effect.fail( | |
| 254 | new ProviderSetupError({ | |
| 255 | instanceId: options.instanceId, | |
| 256 | operation: "start", | |
| 257 | detail: "Cursor sign-in expired. Start sign-in again.", | |
| 258 | }), | |
| 259 | ), | |
| 260 | }), | |
| 261 | Effect.exit, | |
| 262 | Effect.flatMap((result) => | |
| 263 | lock.withPermits(1)( | |
| 264 | Effect.gen(function* () { | |
| 265 | if (active !== flow) return; | |
| 266 | active = undefined; | |
| 267 | operation = "idle"; | |
| 268 | yield* publish(flow, { | |
| 269 | phase: Exit.isSuccess(result) ? "succeeded" : "failed", | |
| 270 | authorizationUrl: null, | |
| 271 | expiresAt: null, | |
| 272 | message: Exit.isSuccess(result) | |
| 273 | ? "Signed in with Cursor." | |
| 274 | : "Cursor sign-in failed or expired. Start sign-in again.", | |
| 275 | }); | |
| 276 | }), | |
| 277 | ), | |
| 278 | ), | |
| 279 | ); | |
| 280 | ||
| 281 | const requireFlow = (owner: string, id: string, name: string) => | |
| 282 | Effect.gen(function* () { | |
| 283 | if (!active || active.owner !== owner || active.id !== id) { | |
| 284 | return yield* new ProviderSetupError({ | |
| 285 | instanceId: options.instanceId, | |
| 286 | operation: name, | |
| 287 | detail: "This sign-in is no longer active in this client.", | |
| 288 | }); | |
| 289 | } | |
| 290 | return active; | |
| 291 | }); | |
| 292 | ||
| 293 | const controller: ProviderAuthController = { | |
| 294 | credentialBinding: options.credentialBinding ?? { | |
| 295 | owner: "t3", | |
| 296 | key: `cursor:${options.instanceId}`, | |
| 297 | }, | |
| 298 | isChangingCredentials: Effect.sync(() => operation !== "idle"), | |
| 299 | start: (owner, stopSessions = Effect.void) => | |
| 300 | lock.withPermits(1)( | |
| 301 | Effect.uninterruptible( | |
| 302 | Effect.gen(function* () { | |
| 303 | if (!options.enabled) | |
| 304 | return yield* new ProviderSetupError({ | |
| 305 | instanceId: options.instanceId, | |
| 306 | operation: "start", | |
| 307 | detail: "Enable Cursor before signing in.", | |
| 308 | }); | |
| 309 | if (configuredKey) | |
| 310 | return yield* new ProviderSetupError({ | |
| 311 | instanceId: options.instanceId, | |
| 312 | operation: "start", | |
| 313 | detail: | |
| 314 | "Remove CURSOR_API_KEY from this provider's environment before using browser sign-in.", | |
| 315 | }); | |
| 316 | if (active?.owner === owner && operation === "login") return snapshot.value.state; | |
| 317 | if (operation !== "idle") | |
| 318 | return yield* new ProviderSetupError({ | |
| 319 | instanceId: options.instanceId, | |
| 320 | operation: "start", | |
| 321 | detail: "Cursor setup is already in progress.", | |
| 322 | }); | |
| 323 | const id = yield* crypto.randomUUIDv4.pipe( | |
| 324 | Effect.mapError( | |
| 325 | (cause) => | |
| 326 | new ProviderSetupError({ | |
| 327 | instanceId: options.instanceId, | |
| 328 | operation: "start", | |
| 329 | detail: "Could not start Cursor sign-in. Try again.", | |
| 330 | cause, | |
| 331 | }), | |
| 332 | ), | |
| 333 | ); | |
| 334 | const expiresAt = DateTime.formatIso( | |
| 335 | DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS), | |
| 336 | ); | |
| 337 | const flow: AuthFlow = { id, owner }; | |
| 338 | active = flow; | |
| 339 | operation = "login"; | |
| 340 | const state: ProviderAuthState = { | |
| 341 | ...emptyState, | |
| 342 | phase: "starting", | |
| 343 | flowId: id, | |
| 344 | expiresAt, | |
| 345 | message: "Starting Cursor sign-in.", | |
| 346 | }; | |
| 347 | yield* SubscriptionRef.set(snapshot, { owner, state }); | |
| 348 | flow.fiber = yield* runLogin(flow, stopSessions).pipe( | |
| 349 | Effect.interruptible, | |
| 350 | Effect.forkIn(scope), | |
| 351 | ); | |
| 352 | return state; | |
| 353 | }), | |
| 354 | ), | |
| 355 | ), | |
| 356 | complete: () => | |
| 357 | Effect.fail( | |
| 358 | new ProviderSetupError({ | |
| 359 | instanceId: options.instanceId, | |
| 360 | operation: "complete", | |
| 361 | detail: "Finish signing in on the Cursor website. No redirect URL is needed.", | |
| 362 | }), | |
| 363 | ), | |
| 364 | cancel: (owner, id) => | |
| 365 | Effect.uninterruptible( | |
| 366 | Effect.gen(function* () { | |
| 367 | const flow = yield* lock.withPermits(1)( | |
| 368 | Effect.gen(function* () { | |
| 369 | const flow = yield* requireFlow(owner, id, "cancel"); | |
| 370 | active = undefined; | |
| 371 | operation = "stopping"; | |
| 372 | yield* publish(flow, { | |
| 373 | phase: "cancelled", | |
| 374 | authorizationUrl: null, | |
| 375 | expiresAt: null, | |
| 376 | message: "Cursor sign-in was cancelled.", | |
| 377 | }); | |
| 378 | return flow; | |
| 379 | }), | |
| 380 | ); | |
| 381 | if (flow.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 382 | operation = "idle"; | |
| 383 | return snapshot.value.state; | |
| 384 | }), | |
| 385 | ), | |
| 386 | logout: (stopSessions) => | |
| 387 | Effect.uninterruptible( | |
| 388 | Effect.gen(function* () { | |
| 389 | const flow = yield* lock.withPermits(1)( | |
| 390 | Effect.gen(function* () { | |
| 391 | if (configuredKey) | |
| 392 | return yield* new ProviderSetupError({ | |
| 393 | instanceId: options.instanceId, | |
| 394 | operation: "logout", | |
| 395 | detail: | |
| 396 | "Remove CURSOR_API_KEY from this provider's environment to disconnect it.", | |
| 397 | }); | |
| 398 | if (operation !== "idle" && operation !== "login") | |
| 399 | return yield* new ProviderSetupError({ | |
| 400 | instanceId: options.instanceId, | |
| 401 | operation: "logout", | |
| 402 | detail: "Cursor setup is already stopping.", | |
| 403 | }); | |
| 404 | operation = "stopping"; | |
| 405 | const flow = active; | |
| 406 | active = undefined; | |
| 407 | return flow; | |
| 408 | }), | |
| 409 | ); | |
| 410 | const result = yield* Effect.gen(function* () { | |
| 411 | if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 412 | yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials)); | |
| 413 | yield* Effect.tryPromise({ | |
| 414 | try: () => options.store.clear(), | |
| 415 | catch: (cause) => | |
| 416 | new ProviderSetupError({ | |
| 417 | instanceId: options.instanceId, | |
| 418 | operation: "logout", | |
| 419 | detail: "Could not clear the Cursor sign-in. Try again.", | |
| 420 | cause, | |
| 421 | }), | |
| 422 | }); | |
| 423 | yield* options.onChanged(false); | |
| 424 | }).pipe(Effect.exit); | |
| 425 | operation = "idle"; | |
| 426 | const state: ProviderAuthState = { | |
| 427 | ...emptyState, | |
| 428 | phase: Exit.isSuccess(result) ? "idle" : "failed", | |
| 429 | message: Exit.isSuccess(result) | |
| 430 | ? "Signed out of Cursor." | |
| 431 | : "Cursor sign-out failed. Try again.", | |
| 432 | }; | |
| 433 | yield* SubscriptionRef.set(snapshot, { owner: null, state }); | |
| 434 | if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); | |
| 435 | return state; | |
| 436 | }), | |
| 437 | ), | |
| 438 | subscribe: (owner) => | |
| 439 | SubscriptionRef.changes(snapshot).pipe( | |
| 440 | Stream.map((current) => { | |
| 441 | const state = { | |
| 442 | ...current.state, | |
| 443 | credentialOwner: "t3" as const, | |
| 444 | methods: [ | |
| 445 | { | |
| 446 | id: "browser", | |
| 447 | name: "Sign in with Cursor", | |
| 448 | description: null, | |
| 449 | type: "agent" as const, | |
| 450 | }, | |
| 451 | ], | |
| 452 | interaction: | |
| 453 | current.state.phase === "waiting" && | |
| 454 | current.state.authorizationUrl && | |
| 455 | current.state.flowId | |
| 456 | ? { | |
| 457 | type: "browser" as const, | |
| 458 | id: current.state.flowId, | |
| 459 | url: current.state.authorizationUrl, | |
| 460 | requiresConsent: false, | |
| 461 | } | |
| 462 | : null, | |
| 463 | }; | |
| 464 | if (current.owner === null || current.owner === owner) return state; | |
| 465 | return { | |
| 466 | ...state, | |
| 467 | interaction: null, | |
| 468 | flowId: null, | |
| 469 | authorizationUrl: null, | |
| 470 | expiresAt: null, | |
| 471 | message: active ? "Sign-in is in progress in another client." : current.state.message, | |
| 472 | }; | |
| 473 | }), | |
| 474 | ), | |
| 475 | isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", | |
| 476 | }; | |
| 477 | ||
| 478 | yield* Effect.addFinalizer(() => | |
| 479 | Effect.gen(function* () { | |
| 480 | operation = "closed"; | |
| 481 | const flow = active; | |
| 482 | active = undefined; | |
| 483 | if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 484 | yield* stopSessionsWithCredentials; | |
| 485 | }), | |
| 486 | ); | |
| 487 | ||
| 488 | return { | |
| 489 | controller, | |
| 490 | readApiKey, | |
| 491 | requireApiKey, | |
| 492 | withAccess, | |
| 493 | usesApiKey: configuredKey !== undefined, | |
| 494 | } satisfies CursorAuth; | |
| 495 | }); |
apps/server/src/provider/CursorCredentialStore.test.ts deleted-141| ... | ... | @@ -1,141 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | ||
| 9 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 10 | import { makeCursorCredentialStore } from "./CursorCredentialStore.ts"; | |
| 11 | ||
| 12 | const makeSecrets = () => { | |
| 13 | const data = new Map<string, Uint8Array>(); | |
| 14 | return { | |
| 15 | data, | |
| 16 | secrets: ServerSecretStore.ServerSecretStore.of({ | |
| 17 | get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), | |
| 18 | set: (key, bytes) => | |
| 19 | Effect.sync(() => { | |
| 20 | data.set(key, bytes); | |
| 21 | }), | |
| 22 | remove: (key) => | |
| 23 | Effect.sync(() => { | |
| 24 | data.delete(key); | |
| 25 | }), | |
| 26 | create: () => Effect.die("unused"), | |
| 27 | getOrCreateRandom: () => Effect.die("unused"), | |
| 28 | }), | |
| 29 | }; | |
| 30 | }; | |
| 31 | ||
| 32 | const legacyCredentials = { | |
| 33 | version: 1 as const, | |
| 34 | backendUrl: "https://api.cursor.com", | |
| 35 | apiKey: "test-only-legacy-key", | |
| 36 | createdAtMs: 100, | |
| 37 | }; | |
| 38 | // The SDK's FileCredentialStore writes pretty-printed JSON. | |
| 39 | const legacyFileText = `{ | |
| 40 | "version": 1, | |
| 41 | "backendUrl": "https://api.cursor.com", | |
| 42 | "apiKey": "test-only-legacy-key", | |
| 43 | "createdAtMs": 100 | |
| 44 | }`; | |
| 45 | ||
| 46 | it.effect.each([ | |
| 47 | { | |
| 48 | name: "imports a legacy sign-in", | |
| 49 | legacy: legacyFileText, | |
| 50 | stored: undefined, | |
| 51 | expected: "test-only-legacy-key", | |
| 52 | }, | |
| 53 | { | |
| 54 | name: "keeps a newer stored sign-in", | |
| 55 | legacy: legacyFileText, | |
| 56 | stored: legacyFileText.replace("test-only-legacy-key", "test-only-current-key"), | |
| 57 | expected: "test-only-current-key", | |
| 58 | }, | |
| 59 | { | |
| 60 | name: "replaces a damaged stored sign-in", | |
| 61 | legacy: legacyFileText, | |
| 62 | stored: "damaged", | |
| 63 | expected: "test-only-legacy-key", | |
| 64 | }, | |
| 65 | { | |
| 66 | name: "discards a damaged legacy file", | |
| 67 | legacy: "damaged", | |
| 68 | stored: undefined, | |
| 69 | expected: undefined, | |
| 70 | }, | |
| 71 | ])("$name and deletes the legacy file", ({ legacy, stored, expected }) => | |
| 72 | Effect.gen(function* () { | |
| 73 | const fileSystem = yield* FileSystem.FileSystem; | |
| 74 | const path = yield* Path.Path; | |
| 75 | const legacyFile = path.join(yield* fileSystem.makeTempDirectoryScoped(), "cursor.json"); | |
| 76 | yield* fileSystem.writeFileString(legacyFile, legacy); | |
| 77 | const { secrets } = makeSecrets(); | |
| 78 | const instanceId = ProviderInstanceId.make("personal"); | |
| 79 | if (stored !== undefined) { | |
| 80 | const current = yield* makeCursorCredentialStore(instanceId).pipe( | |
| 81 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), | |
| 82 | ); | |
| 83 | yield* secrets.set(current.binding.key, new TextEncoder().encode(stored)); | |
| 84 | } | |
| 85 | const migrated = yield* makeCursorCredentialStore(instanceId, legacyFile).pipe( | |
| 86 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), | |
| 87 | ); | |
| 88 | assert.strictEqual((yield* Effect.tryPromise(() => migrated.store.load()))?.apiKey, expected); | |
| 89 | assert.isFalse(yield* fileSystem.exists(legacyFile)); | |
| 90 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | |
| 91 | ); | |
| 92 | ||
| 93 | it.effect( | |
| 94 | "restores SDK credentials in a new controller and keeps another account when signing out", | |
| 95 | () => | |
| 96 | Effect.gen(function* () { | |
| 97 | const data = new Map<string, Uint8Array>(); | |
| 98 | const secrets = ServerSecretStore.ServerSecretStore.of({ | |
| 99 | get: (key) => Effect.sync(() => Option.fromUndefinedOr(data.get(key))), | |
| 100 | set: (key, bytes) => | |
| 101 | Effect.sync(() => { | |
| 102 | data.set(key, bytes); | |
| 103 | }), | |
| 104 | remove: (key) => | |
| 105 | Effect.sync(() => { | |
| 106 | data.delete(key); | |
| 107 | }), | |
| 108 | create: () => Effect.die("unused"), | |
| 109 | getOrCreateRandom: () => Effect.die("unused"), | |
| 110 | }); | |
| 111 | const makeStore = (id: string) => | |
| 112 | makeCursorCredentialStore(ProviderInstanceId.make(id)).pipe( | |
| 113 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), | |
| 114 | ); | |
| 115 | const personal = yield* makeStore("personal"); | |
| 116 | const work = yield* makeStore("work"); | |
| 117 | const credentials = { | |
| 118 | version: 1 as const, | |
| 119 | backendUrl: "https://api.cursor.com", | |
| 120 | apiKey: "test-only-key", | |
| 121 | createdAtMs: 100, | |
| 122 | apiKeyExpiresAtMs: 1000, | |
| 123 | email: "test@example.com", | |
| 124 | }; | |
| 125 | yield* Effect.tryPromise(() => personal.store.save(credentials)); | |
| 126 | yield* Effect.tryPromise(() => | |
| 127 | work.store.save({ ...credentials, apiKey: "test-only-work-key" }), | |
| 128 | ); | |
| 129 | const restored = yield* makeStore("personal"); | |
| 130 | assert.deepEqual(yield* Effect.tryPromise(() => restored.store.load()), credentials); | |
| 131 | data.set(personal.binding.key, new TextEncoder().encode("damaged credential")); | |
| 132 | assert.isUndefined(yield* Effect.tryPromise(() => restored.store.load())); | |
| 133 | yield* Effect.tryPromise(() => restored.store.save(credentials)); | |
| 134 | yield* Effect.tryPromise(() => restored.store.clear()); | |
| 135 | assert.isUndefined(yield* Effect.tryPromise(() => personal.store.load())); | |
| 136 | assert.strictEqual( | |
| 137 | (yield* Effect.tryPromise(() => work.store.load()))?.apiKey, | |
| 138 | "test-only-work-key", | |
| 139 | ); | |
| 140 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 141 | ); |
apps/server/src/provider/CursorCredentialStore.ts deleted-91| ... | ... | @@ -1,91 +0,0 @@ |
| 1 | import type { SdkCredentialStore } from "@cursor/sdk"; | |
| 2 | import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | ||
| 8 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; | |
| 9 | ||
| 10 | const Credentials = Schema.fromJsonString( | |
| 11 | Schema.Struct({ | |
| 12 | version: Schema.Literal(1), | |
| 13 | backendUrl: Schema.String, | |
| 14 | apiKey: Schema.String, | |
| 15 | createdAtMs: Schema.Finite, | |
| 16 | apiKeyExpiresAtMs: Schema.optionalKey(Schema.Finite), | |
| 17 | email: Schema.optionalKey(Schema.String), | |
| 18 | }), | |
| 19 | ); | |
| 20 | ||
| 21 | const decodeCredentials = Schema.decodeUnknownEffect(Credentials); | |
| 22 | const encodeCredentials = Schema.encodeEffect(Credentials); | |
| 23 | ||
| 24 | /** | |
| 25 | * The SDK owns the credential format; persistence uses the environment's secret store. | |
| 26 | * `legacyFile` is the SDK file store earlier versions used; its sign-in moves into | |
| 27 | * the secret store once and the file is deleted. | |
| 28 | */ | |
| 29 | export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")(function* ( | |
| 30 | instanceId: ProviderInstanceId, | |
| 31 | legacyFile?: string, | |
| 32 | ) { | |
| 33 | const credentials = yield* ProviderCredentialStore.make("cursor", instanceId); | |
| 34 | if (legacyFile !== undefined) { | |
| 35 | const fileSystem = yield* FileSystem.FileSystem; | |
| 36 | yield* Effect.gen(function* () { | |
| 37 | const legacy = yield* fileSystem.readFileString(legacyFile).pipe(Effect.option); | |
| 38 | if (Option.isNone(legacy)) return; | |
| 39 | // A stored sign-in wins unless it is damaged; the SDK would ignore it anyway. | |
| 40 | const stored = yield* credentials.get; | |
| 41 | const storedIsValid = | |
| 42 | Option.isSome(stored) && | |
| 43 | Option.isSome( | |
| 44 | yield* decodeCredentials(new TextDecoder().decode(stored.value)).pipe(Effect.option), | |
| 45 | ); | |
| 46 | if ( | |
| 47 | !storedIsValid && | |
| 48 | Option.isSome(yield* decodeCredentials(legacy.value).pipe(Effect.option)) | |
| 49 | ) { | |
| 50 | yield* credentials.set(new TextEncoder().encode(legacy.value)); | |
| 51 | } | |
| 52 | yield* fileSystem.remove(legacyFile); | |
| 53 | }).pipe( | |
| 54 | Effect.catch((error) => | |
| 55 | Effect.logWarning("Could not migrate legacy Cursor credentials", error), | |
| 56 | ), | |
| 57 | ); | |
| 58 | } | |
| 59 | const store: SdkCredentialStore = { | |
| 60 | load: () => | |
| 61 | Effect.runPromise( | |
| 62 | credentials.get.pipe( | |
| 63 | Effect.flatMap( | |
| 64 | Option.match({ | |
| 65 | onNone: () => Effect.succeed(undefined), | |
| 66 | onSome: (bytes) => | |
| 67 | decodeCredentials(new TextDecoder().decode(bytes)).pipe( | |
| 68 | Effect.orElseSucceed(() => undefined), | |
| 69 | ), | |
| 70 | }), | |
| 71 | ), | |
| 72 | ), | |
| 73 | ), | |
| 74 | save: (value) => | |
| 75 | Effect.runPromise( | |
| 76 | encodeCredentials(value).pipe( | |
| 77 | Effect.mapError( | |
| 78 | () => | |
| 79 | new ProviderSetupError({ | |
| 80 | instanceId, | |
| 81 | operation: "credentials", | |
| 82 | detail: "Cursor returned an unsupported credential format.", | |
| 83 | }), | |
| 84 | ), | |
| 85 | Effect.flatMap((encoded) => credentials.set(new TextEncoder().encode(encoded))), | |
| 86 | ), | |
| 87 | ), | |
| 88 | clear: () => Effect.runPromise(credentials.remove), | |
| 89 | }; | |
| 90 | return { store, binding: credentials.binding }; | |
| 91 | }); |
apps/server/src/provider/CursorProvider.test.ts deleted-252| ... | ... | @@ -1,252 +0,0 @@ |
| 1 | import type { SDKModel } from "@cursor/sdk"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import type { CursorSettings } from "@t3tools/contracts"; | |
| 6 | import { CursorSettings as CursorSettingsSchema } from "@t3tools/contracts"; | |
| 7 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 8 | ||
| 9 | import { | |
| 10 | buildCursorCapabilitiesFromSdkModel, | |
| 11 | buildCursorDiscoveredModelsFromSdk, | |
| 12 | buildCursorProviderSnapshot, | |
| 13 | buildInitialCursorProviderSnapshot, | |
| 14 | checkCursorProviderStatus, | |
| 15 | } from "./CursorProvider.ts"; | |
| 16 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | |
| 17 | ||
| 18 | const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema); | |
| 19 | ||
| 20 | function selectDescriptor( | |
| 21 | id: string, | |
| 22 | label: string, | |
| 23 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | |
| 24 | ) { | |
| 25 | return { | |
| 26 | id, | |
| 27 | label, | |
| 28 | type: "select" as const, | |
| 29 | options: [...options], | |
| 30 | ...(options.find((option) => option.isDefault)?.id | |
| 31 | ? { currentValue: options.find((option) => option.isDefault)?.id } | |
| 32 | : {}), | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | function booleanDescriptor(id: string, label: string, currentValue?: boolean) { | |
| 37 | return { | |
| 38 | id, | |
| 39 | label, | |
| 40 | type: "boolean" as const, | |
| 41 | ...(typeof currentValue === "boolean" ? { currentValue } : {}), | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | const baseCursorSettings: CursorSettings = decodeCursorSettings({ | |
| 46 | enabled: true, | |
| 47 | customModels: [], | |
| 48 | }); | |
| 49 | ||
| 50 | const sdkParameterizedModel = { | |
| 51 | id: "claude-opus-4-8", | |
| 52 | displayName: "Opus 4.8", | |
| 53 | parameters: [ | |
| 54 | { | |
| 55 | id: "thinking", | |
| 56 | displayName: "Thinking", | |
| 57 | values: [{ value: "false" }, { value: "true" }], | |
| 58 | }, | |
| 59 | { | |
| 60 | id: "context", | |
| 61 | displayName: "Context", | |
| 62 | values: [ | |
| 63 | { value: "300k", displayName: "300K" }, | |
| 64 | { value: "1m", displayName: "1M" }, | |
| 65 | ], | |
| 66 | }, | |
| 67 | { | |
| 68 | id: "effort", | |
| 69 | displayName: "Effort", | |
| 70 | values: [ | |
| 71 | { value: "low", displayName: "Low" }, | |
| 72 | { value: "high", displayName: "High" }, | |
| 73 | ], | |
| 74 | }, | |
| 75 | { | |
| 76 | id: "fast", | |
| 77 | displayName: "Fast", | |
| 78 | values: [{ value: "false" }, { value: "true", displayName: "Fast" }], | |
| 79 | }, | |
| 80 | ], | |
| 81 | variants: [ | |
| 82 | { | |
| 83 | displayName: "Opus 4.8", | |
| 84 | isDefault: true, | |
| 85 | params: [ | |
| 86 | { id: "thinking", value: "true" }, | |
| 87 | { id: "context", value: "1m" }, | |
| 88 | { id: "effort", value: "high" }, | |
| 89 | { id: "fast", value: "false" }, | |
| 90 | ], | |
| 91 | }, | |
| 92 | ], | |
| 93 | } satisfies SDKModel; | |
| 94 | ||
| 95 | describe("buildInitialCursorProviderSnapshot", () => { | |
| 96 | it.effect("uses SDK-specific pending status copy", () => | |
| 97 | Effect.gen(function* () { | |
| 98 | const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings); | |
| 99 | ||
| 100 | expect(provider).toMatchObject({ | |
| 101 | status: "warning", | |
| 102 | message: "Checking Cursor SDK availability...", | |
| 103 | }); | |
| 104 | }), | |
| 105 | ); | |
| 106 | }); | |
| 107 | ||
| 108 | describe("buildCursorProviderSnapshot", () => { | |
| 109 | it("downgrades ready status to warning when SDK model discovery returns no models", () => { | |
| 110 | expect( | |
| 111 | buildCursorProviderSnapshot({ | |
| 112 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 113 | cursorSettings: baseCursorSettings, | |
| 114 | parsed: { | |
| 115 | version: null, | |
| 116 | status: "ready", | |
| 117 | auth: { status: "authenticated", type: "api-key", label: "Cursor API key" }, | |
| 118 | }, | |
| 119 | discoveryWarning: "Cursor SDK model discovery returned no built-in models.", | |
| 120 | }), | |
| 121 | ).toMatchObject({ | |
| 122 | status: "warning", | |
| 123 | message: "Cursor SDK model discovery returned no built-in models.", | |
| 124 | models: [], | |
| 125 | supportsConversationRollback: false, | |
| 126 | }); | |
| 127 | }); | |
| 128 | }); | |
| 129 | ||
| 130 | describe("Cursor SDK model discovery", () => { | |
| 131 | it("maps native SDK parameter ids and default variant values to model capabilities", () => { | |
| 132 | expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual( | |
| 133 | createModelCapabilities({ | |
| 134 | optionDescriptors: [ | |
| 135 | selectDescriptor("effort", "Effort", [ | |
| 136 | { id: "low", label: "Low" }, | |
| 137 | { id: "high", label: "High", isDefault: true }, | |
| 138 | ]), | |
| 139 | selectDescriptor("contextWindow", "Context", [ | |
| 140 | { id: "300k", label: "300K" }, | |
| 141 | { id: "1m", label: "1M", isDefault: true }, | |
| 142 | ]), | |
| 143 | booleanDescriptor("fastMode", "Fast", false), | |
| 144 | booleanDescriptor("thinking", "Thinking", true), | |
| 145 | ], | |
| 146 | }), | |
| 147 | ); | |
| 148 | }); | |
| 149 | ||
| 150 | it("filters invalid and duplicate SDK model entries", () => { | |
| 151 | expect( | |
| 152 | buildCursorDiscoveredModelsFromSdk([ | |
| 153 | sdkParameterizedModel, | |
| 154 | { ...sdkParameterizedModel, displayName: "Duplicate" }, | |
| 155 | { id: "", displayName: "Invalid" }, | |
| 156 | ]), | |
| 157 | ).toEqual([ | |
| 158 | { | |
| 159 | slug: "claude-opus-4-8", | |
| 160 | name: "Opus 4.8", | |
| 161 | isCustom: false, | |
| 162 | capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel), | |
| 163 | }, | |
| 164 | ]); | |
| 165 | }); | |
| 166 | }); | |
| 167 | ||
| 168 | describe("checkCursorProviderStatus", () => { | |
| 169 | it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () => | |
| 170 | Effect.gen(function* () { | |
| 171 | const provider = yield* checkCursorProviderStatus( | |
| 172 | { | |
| 173 | ...baseCursorSettings, | |
| 174 | customModels: ["internal/cursor-model"], | |
| 175 | }, | |
| 176 | { CURSOR_API_KEY: "test-cursor-key" }, | |
| 177 | ).pipe( | |
| 178 | Effect.provide( | |
| 179 | CursorSdkCatalog.layerTest((apiKey) => { | |
| 180 | expect(apiKey).toBe("test-cursor-key"); | |
| 181 | return Effect.succeed({ | |
| 182 | user: { | |
| 183 | apiKeyName: "test-key", | |
| 184 | userEmail: "cursor@example.com", | |
| 185 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 186 | }, | |
| 187 | models: [sdkParameterizedModel], | |
| 188 | }); | |
| 189 | }), | |
| 190 | ), | |
| 191 | ); | |
| 192 | ||
| 193 | expect(provider).toMatchObject({ | |
| 194 | status: "ready", | |
| 195 | auth: { | |
| 196 | status: "authenticated", | |
| 197 | type: "api-key", | |
| 198 | label: "Cursor API key (test-key)", | |
| 199 | email: "cursor@example.com", | |
| 200 | }, | |
| 201 | models: [ | |
| 202 | { slug: "claude-opus-4-8", isCustom: false }, | |
| 203 | { slug: "internal/cursor-model", isCustom: true }, | |
| 204 | ], | |
| 205 | }); | |
| 206 | }), | |
| 207 | ); | |
| 208 | ||
| 209 | it.effect("surfaces SDK authentication failures", () => | |
| 210 | Effect.gen(function* () { | |
| 211 | const provider = yield* checkCursorProviderStatus(baseCursorSettings, { | |
| 212 | CURSOR_API_KEY: "invalid-test-key", | |
| 213 | }).pipe( | |
| 214 | Effect.provide( | |
| 215 | CursorSdkCatalog.layerTest(() => | |
| 216 | Effect.fail( | |
| 217 | new CursorSdkCatalog.CursorSdkCatalogError({ | |
| 218 | authenticationFailure: true, | |
| 219 | cause: new Error("unauthorized"), | |
| 220 | }), | |
| 221 | ), | |
| 222 | ), | |
| 223 | ), | |
| 224 | ); | |
| 225 | ||
| 226 | expect(provider).toMatchObject({ | |
| 227 | status: "error", | |
| 228 | auth: { status: "unauthenticated" }, | |
| 229 | message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.", | |
| 230 | }); | |
| 231 | }), | |
| 232 | ); | |
| 233 | ||
| 234 | it.effect("requires a Cursor API key without probing any external Cursor binary", () => | |
| 235 | Effect.gen(function* () { | |
| 236 | const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe( | |
| 237 | Effect.provide( | |
| 238 | CursorSdkCatalog.layerTest(() => | |
| 239 | Effect.die("SDK catalog must not be used without CURSOR_API_KEY"), | |
| 240 | ), | |
| 241 | ), | |
| 242 | ); | |
| 243 | ||
| 244 | expect(provider).toMatchObject({ | |
| 245 | installed: true, | |
| 246 | status: "error", | |
| 247 | auth: { status: "unauthenticated" }, | |
| 248 | message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | |
| 249 | }); | |
| 250 | }), | |
| 251 | ); | |
| 252 | }); |
apps/server/src/provider/CursorProvider.ts deleted-350| ... | ... | @@ -1,350 +0,0 @@ |
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | |
| 2 | import type { | |
| 3 | CursorSettings, | |
| 4 | ModelCapabilities, | |
| 5 | ProviderOptionDescriptor, | |
| 6 | ServerProviderAuth, | |
| 7 | ServerProviderModel, | |
| 8 | ServerProviderState, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 11 | import * as DateTime from "effect/DateTime"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Option from "effect/Option"; | |
| 14 | import * as Result from "effect/Result"; | |
| 15 | ||
| 16 | import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./cursorSdkModel.ts"; | |
| 17 | import { | |
| 18 | buildBooleanOptionDescriptor, | |
| 19 | buildSelectOptionDescriptor, | |
| 20 | buildServerProvider, | |
| 21 | providerModelsFromSettings, | |
| 22 | type ServerProviderDraft, | |
| 23 | } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 24 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | |
| 25 | ||
| 26 | const CURSOR_PRESENTATION = { | |
| 27 | displayName: "Cursor", | |
| 28 | supportsConversationRollback: false, | |
| 29 | showInteractionModeToggle: true, | |
| 30 | } as const; | |
| 31 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 32 | optionDescriptors: [], | |
| 33 | }); | |
| 34 | ||
| 35 | const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000; | |
| 36 | ||
| 37 | export function buildInitialCursorProviderSnapshot( | |
| 38 | cursorSettings: CursorSettings, | |
| 39 | ): Effect.Effect<ServerProviderDraft> { | |
| 40 | return Effect.gen(function* () { | |
| 41 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | |
| 42 | const models = getCursorFallbackModels(cursorSettings); | |
| 43 | ||
| 44 | if (!cursorSettings.enabled) { | |
| 45 | return buildServerProvider({ | |
| 46 | presentation: CURSOR_PRESENTATION, | |
| 47 | enabled: false, | |
| 48 | checkedAt, | |
| 49 | models, | |
| 50 | probe: { | |
| 51 | installed: false, | |
| 52 | version: null, | |
| 53 | status: "warning", | |
| 54 | auth: { status: "unknown" }, | |
| 55 | message: "Cursor is disabled in T3 Code settings.", | |
| 56 | }, | |
| 57 | }); | |
| 58 | } | |
| 59 | ||
| 60 | return buildServerProvider({ | |
| 61 | presentation: CURSOR_PRESENTATION, | |
| 62 | enabled: true, | |
| 63 | checkedAt, | |
| 64 | models, | |
| 65 | probe: { | |
| 66 | installed: true, | |
| 67 | version: null, | |
| 68 | status: "warning", | |
| 69 | auth: { status: "unknown" }, | |
| 70 | message: "Checking Cursor SDK availability...", | |
| 71 | }, | |
| 72 | }); | |
| 73 | }); | |
| 74 | } | |
| 75 | ||
| 76 | function getCursorFallbackModels( | |
| 77 | cursorSettings: Pick<CursorSettings, "customModels">, | |
| 78 | ): ReadonlyArray<ServerProviderModel> { | |
| 79 | return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); | |
| 80 | } | |
| 81 | ||
| 82 | function toTitleCaseWords(value: string): string { | |
| 83 | const parts: Array<string> = []; | |
| 84 | for (const part of value.split(/[\s_-]+/g)) { | |
| 85 | if (part.length > 0) { | |
| 86 | parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()); | |
| 87 | } | |
| 88 | } | |
| 89 | return parts.join(" "); | |
| 90 | } | |
| 91 | ||
| 92 | function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined { | |
| 93 | return model.variants | |
| 94 | ?.find((variant) => variant.isDefault) | |
| 95 | ?.params.find((parameter) => parameter.id === parameterId)?.value; | |
| 96 | } | |
| 97 | ||
| 98 | export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities { | |
| 99 | const seen = new Set<string>(); | |
| 100 | const optionDescriptors: Array<ProviderOptionDescriptor> = []; | |
| 101 | const parameters = (model.parameters ?? []) | |
| 102 | .map((parameter, index) => ({ parameter, index })) | |
| 103 | .toSorted( | |
| 104 | (left, right) => | |
| 105 | cursorSdkParameterPriority(left.parameter.id) - | |
| 106 | cursorSdkParameterPriority(right.parameter.id) || left.index - right.index, | |
| 107 | ); | |
| 108 | for (const { parameter } of parameters) { | |
| 109 | const nativeId = parameter.id.trim(); | |
| 110 | const id = cursorSdkProviderOptionId(nativeId); | |
| 111 | if (!nativeId || !id || seen.has(id)) { | |
| 112 | continue; | |
| 113 | } | |
| 114 | seen.add(id); | |
| 115 | ||
| 116 | const values = parameter.values.flatMap((entry) => { | |
| 117 | const value = entry.value.trim(); | |
| 118 | if (!value) { | |
| 119 | return []; | |
| 120 | } | |
| 121 | return [ | |
| 122 | { | |
| 123 | value, | |
| 124 | label: entry.displayName?.trim() || value, | |
| 125 | }, | |
| 126 | ]; | |
| 127 | }); | |
| 128 | if (values.length === 0) { | |
| 129 | continue; | |
| 130 | } | |
| 131 | ||
| 132 | const label = parameter.displayName?.trim() || toTitleCaseWords(id); | |
| 133 | const defaultValue = cursorSdkDefaultParameterValue(model, nativeId); | |
| 134 | const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase())); | |
| 135 | if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) { | |
| 136 | if (defaultValue === "true" || defaultValue === "false") { | |
| 137 | optionDescriptors.push( | |
| 138 | buildBooleanOptionDescriptor({ | |
| 139 | id, | |
| 140 | label, | |
| 141 | currentValue: defaultValue === "true", | |
| 142 | }), | |
| 143 | ); | |
| 144 | } else { | |
| 145 | optionDescriptors.push(buildBooleanOptionDescriptor({ id, label })); | |
| 146 | } | |
| 147 | continue; | |
| 148 | } | |
| 149 | ||
| 150 | optionDescriptors.push( | |
| 151 | buildSelectOptionDescriptor({ | |
| 152 | id, | |
| 153 | label, | |
| 154 | options: values.map((entry) => ({ | |
| 155 | ...entry, | |
| 156 | ...(entry.value === defaultValue ? { isDefault: true } : {}), | |
| 157 | })), | |
| 158 | }), | |
| 159 | ); | |
| 160 | } | |
| 161 | ||
| 162 | return createModelCapabilities({ optionDescriptors }); | |
| 163 | } | |
| 164 | ||
| 165 | export function buildCursorDiscoveredModelsFromSdk( | |
| 166 | models: ReadonlyArray<SDKModel>, | |
| 167 | ): ReadonlyArray<ServerProviderModel> { | |
| 168 | const seen = new Set<string>(); | |
| 169 | return models.flatMap((model) => { | |
| 170 | const slug = model.id.trim(); | |
| 171 | const name = model.displayName.trim(); | |
| 172 | if (!slug || !name || seen.has(slug)) { | |
| 173 | return []; | |
| 174 | } | |
| 175 | seen.add(slug); | |
| 176 | return [ | |
| 177 | { | |
| 178 | slug, | |
| 179 | name, | |
| 180 | isCustom: false, | |
| 181 | capabilities: buildCursorCapabilitiesFromSdkModel(model), | |
| 182 | } satisfies ServerProviderModel, | |
| 183 | ]; | |
| 184 | }); | |
| 185 | } | |
| 186 | ||
| 187 | function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth { | |
| 188 | const email = user.userEmail?.trim(); | |
| 189 | const apiKeyName = user.apiKeyName.trim(); | |
| 190 | return { | |
| 191 | status: "authenticated", | |
| 192 | type, | |
| 193 | label: | |
| 194 | type === "browser" | |
| 195 | ? "Cursor account" | |
| 196 | : apiKeyName | |
| 197 | ? `Cursor API key (${apiKeyName})` | |
| 198 | : "Cursor API key", | |
| 199 | ...(email ? { email } : {}), | |
| 200 | }; | |
| 201 | } | |
| 202 | ||
| 203 | interface CursorProviderProbeResult { | |
| 204 | readonly version: string | null; | |
| 205 | readonly status: Exclude<ServerProviderState, "disabled">; | |
| 206 | readonly auth: ServerProviderAuth; | |
| 207 | readonly message?: string; | |
| 208 | } | |
| 209 | ||
| 210 | function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined { | |
| 211 | const parts: Array<string> = []; | |
| 212 | for (const message of messages) { | |
| 213 | const trimmed = message?.trim(); | |
| 214 | if (trimmed) { | |
| 215 | parts.push(trimmed); | |
| 216 | } | |
| 217 | } | |
| 218 | return parts.length > 0 ? parts.join(" ") : undefined; | |
| 219 | } | |
| 220 | ||
| 221 | export function buildCursorProviderSnapshot(input: { | |
| 222 | readonly checkedAt: string; | |
| 223 | readonly cursorSettings: CursorSettings; | |
| 224 | readonly parsed: CursorProviderProbeResult; | |
| 225 | readonly discoveredModels?: ReadonlyArray<ServerProviderModel>; | |
| 226 | readonly discoveryWarning?: string; | |
| 227 | }): ServerProviderDraft { | |
| 228 | const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); | |
| 229 | return buildServerProvider({ | |
| 230 | presentation: CURSOR_PRESENTATION, | |
| 231 | enabled: input.cursorSettings.enabled, | |
| 232 | checkedAt: input.checkedAt, | |
| 233 | models: providerModelsFromSettings( | |
| 234 | input.discoveredModels ?? [], | |
| 235 | input.cursorSettings.customModels, | |
| 236 | EMPTY_CAPABILITIES, | |
| 237 | ), | |
| 238 | probe: { | |
| 239 | installed: true, | |
| 240 | version: input.parsed.version, | |
| 241 | status: | |
| 242 | input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, | |
| 243 | auth: input.parsed.auth, | |
| 244 | ...(message ? { message } : {}), | |
| 245 | }, | |
| 246 | }); | |
| 247 | } | |
| 248 | ||
| 249 | export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( | |
| 250 | cursorSettings: CursorSettings, | |
| 251 | environment?: NodeJS.ProcessEnv, | |
| 252 | authenticationType: "api-key" | "browser" = "api-key", | |
| 253 | ): Effect.fn.Return<ServerProviderDraft, never, CursorSdkCatalog.CursorSdkCatalog> { | |
| 254 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 255 | const fallbackModels = getCursorFallbackModels(cursorSettings); | |
| 256 | ||
| 257 | if (!cursorSettings.enabled) { | |
| 258 | return buildServerProvider({ | |
| 259 | presentation: CURSOR_PRESENTATION, | |
| 260 | enabled: false, | |
| 261 | checkedAt, | |
| 262 | models: fallbackModels, | |
| 263 | probe: { | |
| 264 | installed: false, | |
| 265 | version: null, | |
| 266 | status: "warning", | |
| 267 | auth: { status: "unknown" }, | |
| 268 | message: "Cursor is disabled in T3 Code settings.", | |
| 269 | }, | |
| 270 | }); | |
| 271 | } | |
| 272 | ||
| 273 | const sdkApiKey = environment?.CURSOR_API_KEY?.trim(); | |
| 274 | if (!sdkApiKey) { | |
| 275 | return buildServerProvider({ | |
| 276 | presentation: CURSOR_PRESENTATION, | |
| 277 | enabled: cursorSettings.enabled, | |
| 278 | checkedAt, | |
| 279 | models: fallbackModels, | |
| 280 | probe: { | |
| 281 | installed: true, | |
| 282 | version: null, | |
| 283 | status: "error", | |
| 284 | auth: { status: "unauthenticated" }, | |
| 285 | message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | |
| 286 | }, | |
| 287 | }); | |
| 288 | } | |
| 289 | ||
| 290 | const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; | |
| 291 | const catalogResult = yield* sdkCatalog | |
| 292 | .read(sdkApiKey) | |
| 293 | .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); | |
| 294 | ||
| 295 | if (Result.isFailure(catalogResult)) { | |
| 296 | yield* Effect.logWarning("Cursor SDK catalog probe failed", { | |
| 297 | cause: catalogResult.failure.cause, | |
| 298 | }); | |
| 299 | const authenticationFailure = catalogResult.failure.authenticationFailure; | |
| 300 | return buildServerProvider({ | |
| 301 | presentation: CURSOR_PRESENTATION, | |
| 302 | enabled: cursorSettings.enabled, | |
| 303 | checkedAt, | |
| 304 | models: fallbackModels, | |
| 305 | probe: { | |
| 306 | installed: true, | |
| 307 | version: null, | |
| 308 | status: "error", | |
| 309 | auth: { status: authenticationFailure ? "unauthenticated" : "unknown" }, | |
| 310 | message: authenticationFailure | |
| 311 | ? authenticationType === "browser" | |
| 312 | ? "Cursor sign-in expired or was rejected. Sign in again in provider settings." | |
| 313 | : "Cursor SDK authentication failed. Check CURSOR_API_KEY." | |
| 314 | : "Cursor SDK catalog request failed. Check server logs for details.", | |
| 315 | }, | |
| 316 | }); | |
| 317 | } | |
| 318 | ||
| 319 | if (Option.isNone(catalogResult.success)) { | |
| 320 | return buildServerProvider({ | |
| 321 | presentation: CURSOR_PRESENTATION, | |
| 322 | enabled: cursorSettings.enabled, | |
| 323 | checkedAt, | |
| 324 | models: fallbackModels, | |
| 325 | probe: { | |
| 326 | installed: true, | |
| 327 | version: null, | |
| 328 | status: "error", | |
| 329 | auth: { status: "unknown" }, | |
| 330 | message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`, | |
| 331 | }, | |
| 332 | }); | |
| 333 | } | |
| 334 | ||
| 335 | const snapshot = catalogResult.success.value; | |
| 336 | const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models); | |
| 337 | return buildCursorProviderSnapshot({ | |
| 338 | checkedAt, | |
| 339 | cursorSettings, | |
| 340 | parsed: { | |
| 341 | version: null, | |
| 342 | status: "ready", | |
| 343 | auth: cursorSdkAuth(snapshot.user, authenticationType), | |
| 344 | }, | |
| 345 | discoveredModels, | |
| 346 | ...(discoveredModels.length === 0 | |
| 347 | ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." } | |
| 348 | : {}), | |
| 349 | }); | |
| 350 | }); |
apps/server/src/provider/CursorSdkCatalog.test.ts deleted-66| ... | ... | @@ -1,66 +0,0 @@ |
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | ||
| 6 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | |
| 7 | ||
| 8 | const user = { | |
| 9 | apiKeyName: "test-key", | |
| 10 | userEmail: "cursor@example.com", | |
| 11 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 12 | } satisfies SDKUser; | |
| 13 | ||
| 14 | const model = { | |
| 15 | id: "claude-opus-4-8", | |
| 16 | displayName: "Opus 4.8", | |
| 17 | parameters: [], | |
| 18 | } satisfies SDKModel; | |
| 19 | ||
| 20 | const catalogError = () => | |
| 21 | new CursorSdkCatalog.CursorSdkCatalogError({ | |
| 22 | authenticationFailure: false, | |
| 23 | cause: new Error("catalog unavailable"), | |
| 24 | }); | |
| 25 | ||
| 26 | describe("CursorSdkCatalog", () => { | |
| 27 | it.effect("keeps the user probe fresh while caching models by API key", () => | |
| 28 | Effect.gen(function* () { | |
| 29 | let userCalls = 0; | |
| 30 | let modelCalls = 0; | |
| 31 | const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ | |
| 32 | readUser: () => Effect.sync(() => ((userCalls += 1), user)), | |
| 33 | readModels: () => Effect.sync(() => ((modelCalls += 1), [model])), | |
| 34 | }); | |
| 35 | ||
| 36 | yield* catalog.read("first-key"); | |
| 37 | yield* catalog.read("first-key"); | |
| 38 | yield* catalog.read("second-key"); | |
| 39 | ||
| 40 | expect(userCalls).toBe(3); | |
| 41 | expect(modelCalls).toBe(2); | |
| 42 | }), | |
| 43 | ); | |
| 44 | ||
| 45 | it.effect("does not cache failed or empty model discovery", () => | |
| 46 | Effect.gen(function* () { | |
| 47 | let modelCalls = 0; | |
| 48 | const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ | |
| 49 | readUser: () => Effect.succeed(user), | |
| 50 | readModels: () => | |
| 51 | Effect.suspend(() => { | |
| 52 | modelCalls += 1; | |
| 53 | if (modelCalls === 1) return Effect.fail(catalogError()); | |
| 54 | if (modelCalls === 2) return Effect.succeed([]); | |
| 55 | return Effect.succeed([model]); | |
| 56 | }), | |
| 57 | }); | |
| 58 | ||
| 59 | expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true); | |
| 60 | expect((yield* catalog.read("test-key")).models).toEqual([]); | |
| 61 | expect((yield* catalog.read("test-key")).models).toEqual([model]); | |
| 62 | expect((yield* catalog.read("test-key")).models).toEqual([model]); | |
| 63 | expect(modelCalls).toBe(3); | |
| 64 | }), | |
| 65 | ); | |
| 66 | }); |
apps/server/src/provider/CursorSdkCatalog.ts deleted-92| ... | ... | @@ -1,92 +0,0 @@ |
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | |
| 2 | import * as Cache from "effect/Cache"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Exit from "effect/Exit"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | ||
| 10 | import { AuthenticationError, Cursor, CursorSdkError } from "./cursorSdk.ts"; | |
| 11 | ||
| 12 | export interface CursorSdkCatalogSnapshot { | |
| 13 | readonly user: SDKUser; | |
| 14 | readonly models: ReadonlyArray<SDKModel>; | |
| 15 | } | |
| 16 | ||
| 17 | export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogError>()( | |
| 18 | "CursorSdkCatalogError", | |
| 19 | { | |
| 20 | authenticationFailure: Schema.Boolean, | |
| 21 | cause: Schema.Defect(), | |
| 22 | }, | |
| 23 | ) { | |
| 24 | override get message(): string { | |
| 25 | return this.authenticationFailure | |
| 26 | ? "Cursor SDK authentication failed." | |
| 27 | : "Cursor SDK catalog request failed."; | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | export interface CursorSdkCatalogShape { | |
| 32 | readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>; | |
| 33 | } | |
| 34 | ||
| 35 | export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()( | |
| 36 | "t3/provider/CursorSdkCatalog", | |
| 37 | ) {} | |
| 38 | ||
| 39 | function isAuthenticationFailure(cause: unknown): boolean { | |
| 40 | return ( | |
| 41 | cause instanceof AuthenticationError || | |
| 42 | (cause instanceof CursorSdkError && cause.status === 401) | |
| 43 | ); | |
| 44 | } | |
| 45 | ||
| 46 | export interface CursorSdkCatalogProbes { | |
| 47 | readonly readUser: (apiKey: string) => Effect.Effect<SDKUser, CursorSdkCatalogError>; | |
| 48 | readonly readModels: ( | |
| 49 | apiKey: string, | |
| 50 | ) => Effect.Effect<ReadonlyArray<SDKModel>, CursorSdkCatalogError>; | |
| 51 | } | |
| 52 | ||
| 53 | const sdkRequest = <A>(tryRequest: () => Promise<A>) => | |
| 54 | Effect.tryPromise({ | |
| 55 | try: tryRequest, | |
| 56 | catch: (cause) => | |
| 57 | new CursorSdkCatalogError({ | |
| 58 | authenticationFailure: isAuthenticationFailure(cause), | |
| 59 | cause, | |
| 60 | }), | |
| 61 | }); | |
| 62 | ||
| 63 | const liveProbes: CursorSdkCatalogProbes = { | |
| 64 | readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })), | |
| 65 | readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })), | |
| 66 | }; | |
| 67 | ||
| 68 | export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* ( | |
| 69 | probes: CursorSdkCatalogProbes = liveProbes, | |
| 70 | ) { | |
| 71 | const modelCache = yield* Cache.makeWith(probes.readModels, { | |
| 72 | capacity: 32, | |
| 73 | timeToLive: (exit) => | |
| 74 | Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, | |
| 75 | }); | |
| 76 | return CursorSdkCatalog.of({ | |
| 77 | read: (apiKey) => | |
| 78 | Effect.all( | |
| 79 | { | |
| 80 | user: probes.readUser(apiKey), | |
| 81 | models: Cache.get(modelCache, apiKey), | |
| 82 | }, | |
| 83 | { concurrency: "unbounded" }, | |
| 84 | ), | |
| 85 | }); | |
| 86 | }); | |
| 87 | ||
| 88 | export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); | |
| 89 | ||
| 90 | export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> { | |
| 91 | return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read })); | |
| 92 | } |
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+5-1| ... | ... | @@ -34,6 +34,7 @@ import * as ModelManifest from "../ModelManifest.ts"; |
| 34 | 34 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 35 | 35 | import { AntigravityDriver } from "./AntigravityDriver.ts"; |
| 36 | 36 | import * as ProviderHostLive from "../ProviderHostLive.ts"; |
| 37 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | |
| 37 | 38 | |
| 38 | 39 | const hostPlatform = HostProcessPlatform.defaultValue(); |
| 39 | 40 | const windowsHost = hostPlatform === "win32"; |
| ... | ... | @@ -264,7 +265,10 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { |
| 264 | 265 | Layer.provideMerge(ModelManifest.layerTest), |
| 265 | 266 | Layer.provideMerge(IdAllocator.layer), |
| 266 | 267 | ); |
| 267 | const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); | |
| 268 | const layerTest = ProviderHostLive.layer.pipe( | |
| 269 | Layer.provideMerge(ServerSecretStore.layer), | |
| 270 | Layer.provideMerge(layerDeps), | |
| 271 | ); | |
| 268 | 272 | |
| 269 | 273 | it.layer(layerTest)("AntigravityDriver", (it) => { |
| 270 | 274 | it.effect.skipIf(windowsHost)( |
apps/server/src/provider/Drivers/CursorDriver.test.ts deleted-185| ... | ... | @@ -1,185 +0,0 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 4 | import { expect, it } from "@effect/vitest"; | |
| 5 | import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | import { vi } from "vite-plus/test"; | |
| 11 | import { HttpClient } from "effect/http"; | |
| 12 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | |
| 13 | ||
| 14 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 15 | import * as ServerConfig from "../../config.ts"; | |
| 16 | import * as ServerSettings from "../../serverSettings.ts"; | |
| 17 | import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; | |
| 18 | import { CursorDriver } from "./CursorDriver.ts"; | |
| 19 | import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; | |
| 20 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 21 | import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 22 | import { Cursor } from "../cursorSdk.ts"; | |
| 23 | import * as ProviderHostLive from "../ProviderHostLive.ts"; | |
| 24 | ||
| 25 | const layerDeps = ServerSecretStore.layer.pipe( | |
| 26 | Layer.provideMerge( | |
| 27 | ServerConfig.layerTest(process.cwd(), { | |
| 28 | prefix: "t3-cursor-driver-copy-command-", | |
| 29 | }), | |
| 30 | ), | |
| 31 | Layer.provideMerge(NodeServices.layer), | |
| 32 | Layer.provideMerge(IdAllocator.layer), | |
| 33 | Layer.provideMerge( | |
| 34 | Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({ | |
| 35 | open: () => Effect.die("Maintenance resolution must not open a Cursor session"), | |
| 36 | }), | |
| 37 | ), | |
| 38 | Layer.provideMerge(ServerSettings.layerTest()), | |
| 39 | Layer.provideMerge( | |
| 40 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | |
| 41 | shouldRunScopeWork: () => Effect.succeed(false), | |
| 42 | }), | |
| 43 | ), | |
| 44 | Layer.provideMerge( | |
| 45 | Layer.succeed( | |
| 46 | ProviderEventLoggers.ProviderEventLoggers, | |
| 47 | ProviderEventLoggers.NoOpProviderEventLoggers, | |
| 48 | ), | |
| 49 | ), | |
| 50 | Layer.provideMerge( | |
| 51 | Layer.succeed( | |
| 52 | HttpClient.HttpClient, | |
| 53 | HttpClient.make(() => Effect.die("Disabled Cursor must not make an HTTP request")), | |
| 54 | ), | |
| 55 | ), | |
| 56 | ); | |
| 57 | const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); | |
| 58 | ||
| 59 | it.layer(layerTest)("CursorDriver", (it) => { | |
| 60 | it.effect( | |
| 61 | "persists browser credentials, uses them for chat, and closes the SDK session on logout", | |
| 62 | () => | |
| 63 | Effect.gen(function* () { | |
| 64 | const login = vi.spyOn(Cursor.auth, "login").mockImplementation(async (options) => { | |
| 65 | options?.onLoginUrl?.("https://cursor.com/loginDeepControl?challenge=test-only"); | |
| 66 | await options?.store?.save({ | |
| 67 | version: 1, | |
| 68 | backendUrl: "https://api2.cursor.sh", | |
| 69 | apiKey: "instance-browser-key", | |
| 70 | createdAtMs: 0, | |
| 71 | apiKeyExpiresAtMs: 4_000_000_000_000, | |
| 72 | email: "cursor@example.com", | |
| 73 | }); | |
| 74 | return { apiKey: "instance-browser-key", apiKeyExpiresAtMs: 4_000_000_000_000 }; | |
| 75 | }); | |
| 76 | const me = vi.spyOn(Cursor, "me").mockResolvedValue({ | |
| 77 | apiKeyName: "T3 Code", | |
| 78 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 79 | userEmail: "cursor@example.com", | |
| 80 | }); | |
| 81 | const models = vi | |
| 82 | .spyOn(Cursor.models, "list") | |
| 83 | .mockResolvedValue([{ id: "auto", displayName: "Auto" }]); | |
| 84 | yield* Effect.addFinalizer(() => | |
| 85 | Effect.sync(() => { | |
| 86 | login.mockRestore(); | |
| 87 | me.mockRestore(); | |
| 88 | models.mockRestore(); | |
| 89 | }), | |
| 90 | ); | |
| 91 | const input = { | |
| 92 | instanceId: ProviderInstanceId.make("cursor-browser-persisted"), | |
| 93 | displayName: "Personal Cursor", | |
| 94 | enabled: true, | |
| 95 | environment: [{ name: "CURSOR_API_KEY", value: "", sensitive: true }], | |
| 96 | config: CursorDriver.defaultConfig(), | |
| 97 | }; | |
| 98 | const openedKeys: Array<string | undefined> = []; | |
| 99 | let closed = 0; | |
| 100 | const instance = yield* CursorDriver.create(input).pipe( | |
| 101 | Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { | |
| 102 | assertComplete: Effect.void, | |
| 103 | open: (request) => | |
| 104 | Effect.sync(() => { | |
| 105 | openedKeys.push(request.options.apiKey); | |
| 106 | return { | |
| 107 | agentId: "browser-auth-agent", | |
| 108 | listMessages: Effect.succeed([]), | |
| 109 | send: () => Effect.die("This test only opens a session"), | |
| 110 | close: Effect.sync(() => { | |
| 111 | closed += 1; | |
| 112 | }), | |
| 113 | }; | |
| 114 | }), | |
| 115 | }), | |
| 116 | ); | |
| 117 | expect((yield* instance.snapshot.refresh).auth.status).toBe("unauthenticated"); | |
| 118 | yield* instance.auth!.start("client"); | |
| 119 | yield* instance.auth!.subscribe("client").pipe( | |
| 120 | Stream.filter((state) => state.phase === "succeeded"), | |
| 121 | Stream.runHead, | |
| 122 | Effect.map(Option.getOrThrow), | |
| 123 | ); | |
| 124 | expect(me).toHaveBeenCalledWith({ apiKey: "instance-browser-key" }); | |
| 125 | expect(yield* instance.snapshot.getSnapshot).toMatchObject({ | |
| 126 | status: "ready", | |
| 127 | auth: { | |
| 128 | status: "authenticated", | |
| 129 | type: "browser", | |
| 130 | canLogout: true, | |
| 131 | email: "cursor@example.com", | |
| 132 | }, | |
| 133 | setup: { canAuthenticate: true, canInstall: false }, | |
| 134 | }); | |
| 135 | const threadId = ThreadId.make("cursor-browser-thread"); | |
| 136 | const modelSelection = { instanceId: input.instanceId, model: "auto" }; | |
| 137 | const runtimePolicy = ProviderAdapterV2RuntimePolicy.make({ | |
| 138 | runtimeMode: "full-access", | |
| 139 | interactionMode: "default", | |
| 140 | cwd: process.cwd(), | |
| 141 | }); | |
| 142 | const runtime = yield* instance.orchestrationAdapter.openSession({ | |
| 143 | threadId, | |
| 144 | providerSessionId: ProviderSessionId.make("cursor-browser-session"), | |
| 145 | modelSelection, | |
| 146 | runtimePolicy, | |
| 147 | }); | |
| 148 | yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); | |
| 149 | expect(openedKeys).toEqual(["instance-browser-key"]); | |
| 150 | expect(closed).toBe(0); | |
| 151 | const recreated = yield* CursorDriver.create(input); | |
| 152 | expect((yield* recreated.snapshot.refresh).auth.status).toBe("authenticated"); | |
| 153 | yield* instance.auth!.logout(Effect.void); | |
| 154 | expect(closed).toBe(1); | |
| 155 | expect((yield* instance.snapshot.getSnapshot).auth.status).toBe("unauthenticated"); | |
| 156 | expect((yield* recreated.snapshot.refresh).auth.status).toBe("unauthenticated"); | |
| 157 | expect( | |
| 158 | (yield* Effect.exit(runtime.ensureThread({ threadId, modelSelection, runtimePolicy }))) | |
| 159 | ._tag, | |
| 160 | ).toBe("Failure"); | |
| 161 | expect(openedKeys).toEqual(["instance-browser-key"]); | |
| 162 | expect(closed).toBe(1); | |
| 163 | }).pipe(Effect.scoped), | |
| 164 | ); | |
| 165 | ||
| 166 | it.effect("keeps the bundled SDK manual-only without probing or updating cursor-agent", () => | |
| 167 | Effect.gen(function* () { | |
| 168 | const instance = yield* CursorDriver.create({ | |
| 169 | instanceId: ProviderInstanceId.make("cursor-sdk"), | |
| 170 | displayName: "Cursor test", | |
| 171 | enabled: false, | |
| 172 | environment: [], | |
| 173 | config: CursorDriver.defaultConfig(), | |
| 174 | }); | |
| 175 | expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); | |
| 176 | expect((yield* instance.snapshot.refresh).status).toBe("disabled"); | |
| 177 | }).pipe( | |
| 178 | Effect.provideService( | |
| 179 | ChildProcessSpawner.ChildProcessSpawner, | |
| 180 | ChildProcessSpawner.make(() => Effect.die("SDK maintenance must not spawn a process")), | |
| 181 | ), | |
| 182 | Effect.scoped, | |
| 183 | ), | |
| 184 | ); | |
| 185 | }); |
apps/server/src/provider/Drivers/CursorDriver.ts deleted-291| ... | ... | @@ -1,291 +0,0 @@ |
| 1 | /** | |
| 2 | * CursorDriver — `ProviderDriver` for the Cursor Agent SDK runtime. | |
| 3 | * | |
| 4 | * Provider status, model discovery, orchestration, and text generation use the | |
| 5 | * official Cursor SDK with an instance browser login or CURSOR_API_KEY. | |
| 6 | * | |
| 7 | * @module provider/Drivers/CursorDriver | |
| 8 | */ | |
| 9 | import { CursorSettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Crypto from "effect/Crypto"; | |
| 12 | import * as FileSystem from "effect/FileSystem"; | |
| 13 | import * as Path from "effect/Path"; | |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | import { HttpClient } from "effect/http"; | |
| 16 | import { readCursorUsageLimits } from "../cursorUsageLimits.ts"; | |
| 17 | ||
| 18 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; | |
| 19 | import * as ServerConfig from "../../config.ts"; | |
| 20 | import * as ServerSettings from "../../serverSettings.ts"; | |
| 21 | import { makeCursorTextGeneration } from "../../textGeneration/CursorTextGeneration.ts"; | |
| 22 | import { | |
| 23 | CursorAdapterV2Driver, | |
| 24 | type CursorAdapterV2DriverEnv, | |
| 25 | } from "../../orchestration-v2/Adapters/CursorAdapterV2.ts"; | |
| 26 | import { ProviderDriverError } from "../Errors.ts"; | |
| 27 | import { | |
| 28 | buildInitialCursorProviderSnapshot, | |
| 29 | checkCursorProviderStatus, | |
| 30 | } from "../CursorProvider.ts"; | |
| 31 | import * as CursorSdkCatalog from "../CursorSdkCatalog.ts"; | |
| 32 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 33 | import { | |
| 34 | defaultProviderContinuationIdentity, | |
| 35 | type ProviderDriver, | |
| 36 | type ProviderInstance, | |
| 37 | } from "@t3tools/provider-core/server/driver"; | |
| 38 | import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; | |
| 39 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 40 | import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 41 | import { | |
| 42 | haveProviderSnapshotSettingsChanged, | |
| 43 | makeProviderSnapshotSettingsSource, | |
| 44 | type ProviderSnapshotSettings, | |
| 45 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 46 | import { probeCursorSkills } from "./CursorSkills.ts"; | |
| 47 | import { makeCursorAuth } from "../CursorAuth.ts"; | |
| 48 | import * as CursorCredentialStore from "../CursorCredentialStore.ts"; | |
| 49 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | |
| 50 | import * as CursorAgentSdk from "../../orchestration-v2/Adapters/CursorAgentSdk.ts"; | |
| 51 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); | |
| 52 | const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError); | |
| 53 | ||
| 54 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); | |
| 55 | const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ | |
| 56 | provider: DRIVER_KIND, | |
| 57 | packageName: null, | |
| 58 | }); | |
| 59 | ||
| 60 | export type CursorDriverEnv = | |
| 61 | | CursorAdapterV2DriverEnv | |
| 62 | | Crypto.Crypto | |
| 63 | | FileSystem.FileSystem | |
| 64 | | Path.Path | |
| 65 | | HttpClient.HttpClient | |
| 66 | | ProviderHost | |
| 67 | | ServerConfig.ServerConfig | |
| 68 | | ServerSecretStore.ServerSecretStore | |
| 69 | | ServerSettings.ServerSettingsService; | |
| 70 | ||
| 71 | export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | |
| 72 | driverKind: DRIVER_KIND, | |
| 73 | metadata: { | |
| 74 | displayName: "Cursor", | |
| 75 | supportsMultipleInstances: true, | |
| 76 | }, | |
| 77 | configSchema: CursorSettings, | |
| 78 | defaultConfig: (): CursorSettings => decodeCursorSettings({}), | |
| 79 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | |
| 80 | Effect.gen(function* () { | |
| 81 | const serverSettings = yield* ServerSettings.ServerSettingsService; | |
| 82 | const host = yield* ProviderHost; | |
| 83 | const fileSystem = yield* FileSystem.FileSystem; | |
| 84 | const path = yield* Path.Path; | |
| 85 | const httpClient = yield* HttpClient.HttpClient; | |
| 86 | const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 87 | const processEnv = mergeProviderInstanceEnvironment(environment); | |
| 88 | const continuationIdentity = defaultProviderContinuationIdentity({ | |
| 89 | driverKind: DRIVER_KIND, | |
| 90 | instanceId, | |
| 91 | }); | |
| 92 | const stampIdentity = withInstanceIdentity({ | |
| 93 | instanceId, | |
| 94 | driverKind: DRIVER_KIND, | |
| 95 | displayName: displayName ?? "Cursor", | |
| 96 | accentColor, | |
| 97 | continuationGroupKey: continuationIdentity.continuationKey, | |
| 98 | }); | |
| 99 | const effectiveConfig = { ...config, enabled } satisfies CursorSettings; | |
| 100 | const credentials = yield* CursorCredentialStore.makeCursorCredentialStore( | |
| 101 | instanceId, | |
| 102 | path.join( | |
| 103 | (yield* ServerConfig.ServerConfig).stateDir, | |
| 104 | "provider-auth", | |
| 105 | encodeURIComponent(instanceId), | |
| 106 | "cursor.json", | |
| 107 | ), | |
| 108 | ).pipe( | |
| 109 | Effect.mapError( | |
| 110 | (cause) => | |
| 111 | new ProviderDriverError({ | |
| 112 | driver: DRIVER_KIND, | |
| 113 | instanceId, | |
| 114 | detail: "Could not open the Cursor credential store.", | |
| 115 | cause, | |
| 116 | }), | |
| 117 | ), | |
| 118 | ); | |
| 119 | const auth = yield* makeCursorAuth({ | |
| 120 | instanceId, | |
| 121 | displayName: displayName ?? "Cursor", | |
| 122 | enabled, | |
| 123 | ...(processEnv.CURSOR_API_KEY ? { apiKey: processEnv.CURSOR_API_KEY } : {}), | |
| 124 | store: credentials.store, | |
| 125 | credentialBinding: credentials.binding, | |
| 126 | onChanged: (signedIn): Effect.Effect<void, ProviderSetupError> => | |
| 127 | snapshot.refresh.pipe( | |
| 128 | Effect.flatMap((provider) => | |
| 129 | !signedIn || provider.auth.status === "authenticated" | |
| 130 | ? Effect.void | |
| 131 | : Effect.fail( | |
| 132 | new ProviderSetupError({ | |
| 133 | instanceId, | |
| 134 | operation: "start", | |
| 135 | detail: provider.message ?? "Could not verify the Cursor sign-in. Try again.", | |
| 136 | }), | |
| 137 | ), | |
| 138 | ), | |
| 139 | ), | |
| 140 | }); | |
| 141 | const stampSnapshot: typeof stampIdentity = (draft) => | |
| 142 | stampIdentity({ | |
| 143 | ...draft, | |
| 144 | setup: { canAuthenticate: !auth.usesApiKey, canInstall: false }, | |
| 145 | auth: { | |
| 146 | ...draft.auth, | |
| 147 | canLogout: !auth.usesApiKey, | |
| 148 | }, | |
| 149 | }); | |
| 150 | ||
| 151 | const orchestrationAdapter = yield* CursorAdapterV2Driver.create({ | |
| 152 | instanceId, | |
| 153 | displayName, | |
| 154 | accentColor, | |
| 155 | environment, | |
| 156 | enabled, | |
| 157 | config, | |
| 158 | }).pipe( | |
| 159 | Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { | |
| 160 | ...sdkRunner, | |
| 161 | open: (input) => | |
| 162 | auth.requireApiKey.pipe( | |
| 163 | Effect.flatMap((apiKey) => | |
| 164 | Effect.acquireRelease( | |
| 165 | sdkRunner | |
| 166 | .open({ ...input, options: { ...input.options, apiKey } }) | |
| 167 | .pipe( | |
| 168 | Effect.flatMap((session) => | |
| 169 | Effect.cached(session.close).pipe( | |
| 170 | Effect.map((close) => ({ ...session, close })), | |
| 171 | ), | |
| 172 | ), | |
| 173 | ), | |
| 174 | (session) => session.close.pipe(Effect.ignore), | |
| 175 | ), | |
| 176 | ), | |
| 177 | auth.withAccess, | |
| 178 | Effect.mapError((cause) => | |
| 179 | isSdkRunnerError(cause) | |
| 180 | ? cause | |
| 181 | : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }), | |
| 182 | ), | |
| 183 | ), | |
| 184 | }), | |
| 185 | Effect.mapError( | |
| 186 | (cause) => | |
| 187 | new ProviderDriverError({ | |
| 188 | driver: DRIVER_KIND, | |
| 189 | instanceId, | |
| 190 | detail: "Failed to build Cursor orchestration adapter.", | |
| 191 | cause, | |
| 192 | }), | |
| 193 | ), | |
| 194 | ); | |
| 195 | const textGeneration = yield* makeCursorTextGeneration( | |
| 196 | effectiveConfig, | |
| 197 | processEnv, | |
| 198 | auth.requireApiKey, | |
| 199 | auth.withAccess, | |
| 200 | ); | |
| 201 | ||
| 202 | const checkProvider = auth.readApiKey.pipe( | |
| 203 | Effect.orElseSucceed(() => undefined), | |
| 204 | Effect.flatMap((apiKey) => | |
| 205 | checkCursorProviderStatus( | |
| 206 | effectiveConfig, | |
| 207 | { | |
| 208 | ...processEnv, | |
| 209 | CURSOR_API_KEY: apiKey, | |
| 210 | }, | |
| 211 | auth.usesApiKey ? "api-key" : "browser", | |
| 212 | ).pipe( | |
| 213 | Effect.flatMap((snapshot) => | |
| 214 | effectiveConfig.enabled && | |
| 215 | snapshot.installed && | |
| 216 | snapshot.auth.status === "authenticated" | |
| 217 | ? serverSettings.getSettings.pipe( | |
| 218 | Effect.flatMap((settings) => | |
| 219 | readCursorUsageLimits( | |
| 220 | effectiveConfig, | |
| 221 | { ...processEnv, CURSOR_API_KEY: apiKey }, | |
| 222 | settings.cursorKeychainUsageEnabled, | |
| 223 | ), | |
| 224 | ), | |
| 225 | Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), | |
| 226 | ) | |
| 227 | : Effect.succeed(snapshot), | |
| 228 | ), | |
| 229 | ), | |
| 230 | ), | |
| 231 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 232 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 233 | Effect.provideService(Path.Path, path), | |
| 234 | Effect.map(stampSnapshot), | |
| 235 | Effect.provide(CursorSdkCatalog.layer), | |
| 236 | ); | |
| 237 | ||
| 238 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); | |
| 239 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CursorSettings>>({ | |
| 240 | resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), | |
| 241 | getSettings: snapshotSettings.getSettings, | |
| 242 | streamSettings: snapshotSettings.streamSettings, | |
| 243 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | |
| 244 | initialSnapshot: (settings) => | |
| 245 | buildInitialCursorProviderSnapshot(settings.provider).pipe(Effect.map(stampSnapshot)), | |
| 246 | checkProvider, | |
| 247 | }).pipe( | |
| 248 | Effect.mapError( | |
| 249 | (cause) => | |
| 250 | new ProviderDriverError({ | |
| 251 | driver: DRIVER_KIND, | |
| 252 | instanceId, | |
| 253 | detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`, | |
| 254 | cause, | |
| 255 | }), | |
| 256 | ), | |
| 257 | ); | |
| 258 | ||
| 259 | return { | |
| 260 | instanceId, | |
| 261 | driverKind: DRIVER_KIND, | |
| 262 | continuationIdentity, | |
| 263 | displayName, | |
| 264 | accentColor, | |
| 265 | enabled, | |
| 266 | auth: auth.controller, | |
| 267 | snapshot, | |
| 268 | snapshotForCwd: (cwd) => | |
| 269 | !effectiveConfig.enabled | |
| 270 | ? snapshot.getSnapshot | |
| 271 | : Effect.all([ | |
| 272 | snapshot.getSnapshot, | |
| 273 | probeCursorSkills(cwd, processEnv).pipe( | |
| 274 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 275 | Effect.provideService(Path.Path, path), | |
| 276 | Effect.mapError( | |
| 277 | (cause) => | |
| 278 | new ProviderDriverError({ | |
| 279 | driver: DRIVER_KIND, | |
| 280 | instanceId, | |
| 281 | detail: `Failed to discover Cursor skills for '${cwd}'`, | |
| 282 | cause, | |
| 283 | }), | |
| 284 | ), | |
| 285 | ), | |
| 286 | ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), | |
| 287 | orchestrationAdapter, | |
| 288 | textGeneration, | |
| 289 | } satisfies ProviderInstance; | |
| 290 | }), | |
| 291 | }; |
apps/server/src/provider/Drivers/CursorSkills.test.ts deleted-186| ... | ... | @@ -1,186 +0,0 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import type * as Scope from "effect/Scope"; | |
| 8 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | |
| 9 | ||
| 10 | import { | |
| 11 | discoverCursorSkills, | |
| 12 | hasCursorSkillMention, | |
| 13 | probeCursorSkills, | |
| 14 | rewriteCursorSkillMentions, | |
| 15 | } from "./CursorSkills.ts"; | |
| 16 | ||
| 17 | const runNode = <A, E>( | |
| 18 | effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>, | |
| 19 | ) => effect.pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise); | |
| 20 | ||
| 21 | describe("Cursor skills", () => { | |
| 22 | it("discovers recursive project skills with project precedence", async () => | |
| 23 | await runNode( | |
| 24 | Effect.gen(function* () { | |
| 25 | const fileSystem = yield* FileSystem.FileSystem; | |
| 26 | const path = yield* Path.Path; | |
| 27 | const userHome = yield* fileSystem | |
| 28 | .makeTempDirectoryScoped({ | |
| 29 | directory: NodeOS.tmpdir(), | |
| 30 | prefix: "cursor-skills-home-", | |
| 31 | }) | |
| 32 | .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); | |
| 33 | const workspace = yield* fileSystem | |
| 34 | .makeTempDirectoryScoped({ | |
| 35 | directory: NodeOS.tmpdir(), | |
| 36 | prefix: "cursor-skills-workspace-", | |
| 37 | }) | |
| 38 | .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); | |
| 39 | const writeSkill = Effect.fn("writeCursorSkill")(function* ( | |
| 40 | root: string, | |
| 41 | name: string, | |
| 42 | contents: string, | |
| 43 | ) { | |
| 44 | const skillDirectory = path.join(root, name); | |
| 45 | yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); | |
| 46 | yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents); | |
| 47 | }); | |
| 48 | ||
| 49 | yield* writeSkill( | |
| 50 | path.join(userHome, ".cursor", "skills"), | |
| 51 | "review", | |
| 52 | "---\ndescription: user review\n---\n", | |
| 53 | ); | |
| 54 | yield* writeSkill( | |
| 55 | path.join(workspace, ".agents", "skills", "nested"), | |
| 56 | "review", | |
| 57 | "---\nname: Review changes\ndescription: project review\n---\n", | |
| 58 | ); | |
| 59 | yield* writeSkill( | |
| 60 | path.join(workspace, ".cursor", "skills"), | |
| 61 | "internal", | |
| 62 | "---\nuser-invocable: false\n---\n", | |
| 63 | ); | |
| 64 | yield* writeSkill( | |
| 65 | path.join(workspace, ".cursor", "skills"), | |
| 66 | "oversized", | |
| 67 | "x".repeat(1_000_001), | |
| 68 | ); | |
| 69 | yield* fileSystem.makeDirectory(path.join(userHome, ".codex"), { recursive: true }); | |
| 70 | yield* fileSystem.writeFileString( | |
| 71 | path.join(userHome, ".codex", "skills"), | |
| 72 | "not a directory", | |
| 73 | ); | |
| 74 | ||
| 75 | const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); | |
| 76 | expect(skills).toEqual([ | |
| 77 | { | |
| 78 | name: "internal", | |
| 79 | path: path.join(workspace, ".cursor", "skills", "internal", "SKILL.md"), | |
| 80 | scope: "project", | |
| 81 | enabled: true, | |
| 82 | userInvocable: false, | |
| 83 | }, | |
| 84 | { | |
| 85 | name: "oversized", | |
| 86 | path: path.join(workspace, ".cursor", "skills", "oversized", "SKILL.md"), | |
| 87 | scope: "project", | |
| 88 | enabled: true, | |
| 89 | }, | |
| 90 | { | |
| 91 | name: "review", | |
| 92 | displayName: "Review changes", | |
| 93 | description: "project review", | |
| 94 | path: path.join(workspace, ".agents", "skills", "nested", "review", "SKILL.md"), | |
| 95 | scope: "project", | |
| 96 | enabled: true, | |
| 97 | }, | |
| 98 | ]); | |
| 99 | expect( | |
| 100 | (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, | |
| 101 | ).toBe("Failure"); | |
| 102 | }), | |
| 103 | )); | |
| 104 | ||
| 105 | it.skipIf(!symlinksSupported)( | |
| 106 | "treats a symlinked skill outside the root as a package boundary", | |
| 107 | async () => | |
| 108 | await runNode( | |
| 109 | Effect.gen(function* () { | |
| 110 | const fileSystem = yield* FileSystem.FileSystem; | |
| 111 | const path = yield* Path.Path; | |
| 112 | const userHome = yield* fileSystem.makeTempDirectoryScoped({ | |
| 113 | directory: NodeOS.tmpdir(), | |
| 114 | prefix: "cursor-skills-home-", | |
| 115 | }); | |
| 116 | const workspace = yield* fileSystem | |
| 117 | .makeTempDirectoryScoped({ | |
| 118 | directory: NodeOS.tmpdir(), | |
| 119 | prefix: "cursor-skills-workspace-", | |
| 120 | }) | |
| 121 | .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); | |
| 122 | const library = yield* fileSystem.makeTempDirectoryScoped({ | |
| 123 | directory: NodeOS.tmpdir(), | |
| 124 | prefix: "cursor-skills-library-", | |
| 125 | }); | |
| 126 | const writeSkill = Effect.fn("writeCursorSkill")(function* ( | |
| 127 | directory: string, | |
| 128 | contents: string, | |
| 129 | ) { | |
| 130 | yield* fileSystem.makeDirectory(directory, { recursive: true }); | |
| 131 | yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents); | |
| 132 | }); | |
| 133 | ||
| 134 | // A skill package managed in a config repo and installed by symlink. | |
| 135 | // Its own SKILL.md must be discovered under the link name, but nothing | |
| 136 | // below the target may be walked. | |
| 137 | yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n"); | |
| 138 | yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n"); | |
| 139 | const root = path.join(workspace, ".cursor", "skills"); | |
| 140 | yield* fileSystem.makeDirectory(root, { recursive: true }); | |
| 141 | yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review")); | |
| 142 | ||
| 143 | const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); | |
| 144 | expect(skills).toEqual([ | |
| 145 | { | |
| 146 | name: "review", | |
| 147 | description: "shared", | |
| 148 | path: path.join(root, "review", "SKILL.md"), | |
| 149 | scope: "project", | |
| 150 | enabled: true, | |
| 151 | }, | |
| 152 | ]); | |
| 153 | expect( | |
| 154 | (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, | |
| 155 | ).toBe("Success"); | |
| 156 | }), | |
| 157 | ), | |
| 158 | ); | |
| 159 | ||
| 160 | it("rewrites only discovered skill mentions into Cursor slash invocations", () => { | |
| 161 | expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true); | |
| 162 | expect(hasCursorSkillMention("please $review this")).toBe(true); | |
| 163 | expect( | |
| 164 | rewriteCursorSkillMentions("use $review, keep $HOME and 5$review", new Set(["review"])), | |
| 165 | ).toBe("use $review, keep $HOME and 5$review"); | |
| 166 | expect(rewriteCursorSkillMentions("please $review this", new Set(["review"]))).toBe( | |
| 167 | "please /review this", | |
| 168 | ); | |
| 169 | }); | |
| 170 | it("detects and invokes digit-leading Cursor skills without rewriting money", () => { | |
| 171 | const names = new Set(["2spec", "20k", "100M", "1e6"]); | |
| 172 | expect(hasCursorSkillMention("use $2spec here")).toBe(true); | |
| 173 | expect(hasCursorSkillMention("use $2spec here")).toBe(true); | |
| 174 | expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here"); | |
| 175 | expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here"); | |
| 176 | for (const text of [ | |
| 177 | "pay $20 tomorrow", | |
| 178 | "budget $20k here", | |
| 179 | "cost $100M total", | |
| 180 | "limit $1e6 here", | |
| 181 | ]) { | |
| 182 | expect(hasCursorSkillMention(text)).toBe(false); | |
| 183 | expect(rewriteCursorSkillMentions(text, names)).toBe(text); | |
| 184 | } | |
| 185 | }); | |
| 186 | }); |
apps/server/src/provider/Drivers/CursorSkills.ts deleted-290| ... | ... | @@ -1,290 +0,0 @@ |
| 1 | /** | |
| 2 | * CursorSkills — workspace-aware discovery and native invocation for Cursor. | |
| 3 | * | |
| 4 | * Cursor discovers Agent Skills recursively from user and project roots but | |
| 5 | * its ACP command catalog only appears after opening a real session. Scanning | |
| 6 | * the same roots avoids starting an agent and its MCP servers just to populate | |
| 7 | * a composer menu. | |
| 8 | * | |
| 9 | * @module provider/Drivers/CursorSkills | |
| 10 | */ | |
| 11 | import * as NodeOS from "node:os"; | |
| 12 | ||
| 13 | import type { ServerProviderSkill } from "@t3tools/contracts"; | |
| 14 | import * as ByteSize from "effect/ByteSize"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as FileSystem from "effect/FileSystem"; | |
| 17 | import * as Path from "effect/Path"; | |
| 18 | import * as PlatformError from "effect/PlatformError"; | |
| 19 | import * as Schema from "effect/Schema"; | |
| 20 | import { parse as parseYamlDocument } from "yaml"; | |
| 21 | ||
| 22 | const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; | |
| 23 | const SKILL_MENTION_PATTERN = | |
| 24 | /(^|\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; | |
| 25 | const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u"); | |
| 26 | const MAX_SKILL_DEPTH = 10; | |
| 27 | const MAX_SKILL_BYTES = ByteSize.bytes(1_000_000); | |
| 28 | const MAX_SKILL_SCAN_ENTRIES = 10_000; | |
| 29 | const MAX_SKILL_SCAN_BYTES = ByteSize.bytes(8_000_000); | |
| 30 | ||
| 31 | interface CursorSkillFrontmatter { | |
| 32 | readonly description?: string; | |
| 33 | readonly displayName?: string; | |
| 34 | readonly userInvocationOnly?: boolean; | |
| 35 | readonly userInvocable?: boolean; | |
| 36 | readonly cliVisible: boolean; | |
| 37 | } | |
| 38 | ||
| 39 | interface CursorSkillScanBudget { | |
| 40 | remainingEntries: number; | |
| 41 | remainingBytes: bigint; | |
| 42 | exhausted: boolean; | |
| 43 | incomplete: boolean; | |
| 44 | } | |
| 45 | ||
| 46 | class CursorSkillsProbeError extends Schema.TaggedError<CursorSkillsProbeError>()( | |
| 47 | "CursorSkillsProbeError", | |
| 48 | { | |
| 49 | reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]), | |
| 50 | cwd: Schema.optional(Schema.String), | |
| 51 | }, | |
| 52 | ) { | |
| 53 | override get message(): string { | |
| 54 | const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; | |
| 55 | return `Cursor skill discovery${location} was incomplete (${this.reason}).`; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | const orUndefined = <A, R>( | |
| 60 | effect: Effect.Effect<A, PlatformError.PlatformError, R>, | |
| 61 | budget?: CursorSkillScanBudget, | |
| 62 | ): Effect.Effect<A | undefined, never, R> => | |
| 63 | effect.pipe( | |
| 64 | Effect.map((value): A | undefined => value), | |
| 65 | Effect.catchTags({ | |
| 66 | PlatformError: (error) => { | |
| 67 | if (error.reason._tag !== "NotFound" && budget) budget.incomplete = true; | |
| 68 | return Effect.void.pipe(Effect.as(undefined)); | |
| 69 | }, | |
| 70 | }), | |
| 71 | ); | |
| 72 | ||
| 73 | function parseFrontmatterBoolean(value: unknown): boolean | undefined { | |
| 74 | if (typeof value === "boolean") return value; | |
| 75 | if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined; | |
| 76 | if (typeof value !== "string") return undefined; | |
| 77 | switch (value.trim().toLowerCase()) { | |
| 78 | case "true": | |
| 79 | case "yes": | |
| 80 | case "on": | |
| 81 | return true; | |
| 82 | case "false": | |
| 83 | case "no": | |
| 84 | case "off": | |
| 85 | return false; | |
| 86 | default: | |
| 87 | return undefined; | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | function parseSkillFrontmatter(contents: string): CursorSkillFrontmatter | undefined { | |
| 92 | const match = FRONTMATTER_PATTERN.exec(contents); | |
| 93 | if (!match) return { cliVisible: true }; | |
| 94 | ||
| 95 | let parsed: unknown; | |
| 96 | try { | |
| 97 | parsed = parseYamlDocument(match[1] ?? ""); | |
| 98 | } catch { | |
| 99 | return undefined; | |
| 100 | } | |
| 101 | if (typeof parsed !== "object" || parsed === null) return undefined; | |
| 102 | ||
| 103 | const record = parsed as Record<string, unknown>; | |
| 104 | const metadata = | |
| 105 | typeof record.metadata === "object" && record.metadata !== null | |
| 106 | ? (record.metadata as Record<string, unknown>) | |
| 107 | : undefined; | |
| 108 | const rawSurfaces = metadata?.surfaces; | |
| 109 | const surfaces = Array.isArray(rawSurfaces) | |
| 110 | ? rawSurfaces.filter((surface): surface is string => typeof surface === "string") | |
| 111 | : typeof rawSurfaces === "string" | |
| 112 | ? rawSurfaces.split(",") | |
| 113 | : []; | |
| 114 | const description = typeof record.description === "string" ? record.description.trim() : ""; | |
| 115 | const displayName = typeof record.name === "string" ? record.name.trim() : ""; | |
| 116 | return { | |
| 117 | cliVisible: | |
| 118 | surfaces.length === 0 || surfaces.some((surface) => surface.trim().toLowerCase() === "cli"), | |
| 119 | ...(description ? { description } : {}), | |
| 120 | ...(displayName ? { displayName } : {}), | |
| 121 | ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true | |
| 122 | ? { userInvocationOnly: true } | |
| 123 | : {}), | |
| 124 | ...(parseFrontmatterBoolean(record["user-invocable"]) === false | |
| 125 | ? { userInvocable: false } | |
| 126 | : {}), | |
| 127 | }; | |
| 128 | } | |
| 129 | ||
| 130 | const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (input: { | |
| 131 | readonly directory: string; | |
| 132 | readonly scope: "user" | "project"; | |
| 133 | readonly budget: CursorSkillScanBudget; | |
| 134 | }): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> { | |
| 135 | const fileSystem = yield* FileSystem.FileSystem; | |
| 136 | const path = yield* Path.Path; | |
| 137 | const skills: ServerProviderSkill[] = []; | |
| 138 | if (input.budget.exhausted) return skills; | |
| 139 | const rootDirectory = yield* orUndefined(fileSystem.realPath(input.directory), input.budget); | |
| 140 | if (!rootDirectory) return skills; | |
| 141 | const visitedDirectories = new Set<string>(); | |
| 142 | ||
| 143 | const visit = Effect.fn("visitCursorSkillDirectory")(function* ( | |
| 144 | directory: string, | |
| 145 | depth: number, | |
| 146 | ): Effect.fn.Return<void, never> { | |
| 147 | if (input.budget.exhausted) return; | |
| 148 | const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget); | |
| 149 | if (!resolvedDirectory) { | |
| 150 | return; | |
| 151 | } | |
| 152 | if (visitedDirectories.has(resolvedDirectory)) { | |
| 153 | return; | |
| 154 | } | |
| 155 | visitedDirectories.add(resolvedDirectory); | |
| 156 | // A symlink whose target lives outside the root is a skill package | |
| 157 | // boundary: read its own SKILL.md so linked skill libraries show up, but | |
| 158 | // never walk the target tree. | |
| 159 | const insideRoot = | |
| 160 | resolvedDirectory === rootDirectory || | |
| 161 | resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`); | |
| 162 | ||
| 163 | const skillPath = path.join(directory, "SKILL.md"); | |
| 164 | const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); | |
| 165 | if (skillInfo?.type === "File") { | |
| 166 | let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true }; | |
| 167 | if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) { | |
| 168 | const contents = yield* orUndefined(fileSystem.readFileString(skillPath)); | |
| 169 | if (contents !== undefined) { | |
| 170 | input.budget.remainingBytes -= skillInfo.size; | |
| 171 | frontmatter = parseSkillFrontmatter(contents); | |
| 172 | } | |
| 173 | } | |
| 174 | const name = path.basename(directory).trim(); | |
| 175 | if (frontmatter?.cliVisible && name) { | |
| 176 | skills.push({ | |
| 177 | name, | |
| 178 | path: skillPath, | |
| 179 | scope: input.scope, | |
| 180 | enabled: true, | |
| 181 | ...(frontmatter.displayName && frontmatter.displayName !== name | |
| 182 | ? { displayName: frontmatter.displayName } | |
| 183 | : {}), | |
| 184 | ...(frontmatter.description ? { description: frontmatter.description } : {}), | |
| 185 | ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}), | |
| 186 | ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}), | |
| 187 | }); | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | if (!insideRoot) { | |
| 192 | return; | |
| 193 | } | |
| 194 | const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget); | |
| 195 | if (!entries) { | |
| 196 | return; | |
| 197 | } | |
| 198 | for (const entry of [...entries].sort()) { | |
| 199 | if (input.budget.remainingEntries === 0) { | |
| 200 | input.budget.exhausted = true; | |
| 201 | return; | |
| 202 | } | |
| 203 | input.budget.remainingEntries -= 1; | |
| 204 | const child = path.join(directory, entry); | |
| 205 | const info = yield* orUndefined(fileSystem.stat(child), input.budget); | |
| 206 | if (info?.type !== "Directory") continue; | |
| 207 | if (depth >= MAX_SKILL_DEPTH) { | |
| 208 | input.budget.exhausted = true; | |
| 209 | return; | |
| 210 | } | |
| 211 | yield* visit(child, depth + 1); | |
| 212 | } | |
| 213 | }); | |
| 214 | ||
| 215 | yield* visit(rootDirectory, 0); | |
| 216 | return skills; | |
| 217 | }); | |
| 218 | ||
| 219 | const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( | |
| 220 | cwd?: string, | |
| 221 | environment: NodeJS.ProcessEnv = process.env, | |
| 222 | ) { | |
| 223 | const path = yield* Path.Path; | |
| 224 | const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); | |
| 225 | const rootsBelow = (base: string, scope: "user" | "project") => [ | |
| 226 | { directory: path.join(base, ".cursor", "skills"), scope }, | |
| 227 | { directory: path.join(base, ".agents", "skills"), scope }, | |
| 228 | { directory: path.join(base, ".codex", "skills"), scope }, | |
| 229 | { directory: path.join(base, ".claude", "skills"), scope }, | |
| 230 | ]; | |
| 231 | const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; | |
| 232 | ||
| 233 | const skillsByName = new Map<string, ServerProviderSkill>(); | |
| 234 | const budget: CursorSkillScanBudget = { | |
| 235 | remainingEntries: MAX_SKILL_SCAN_ENTRIES, | |
| 236 | remainingBytes: MAX_SKILL_SCAN_BYTES, | |
| 237 | exhausted: false, | |
| 238 | incomplete: false, | |
| 239 | }; | |
| 240 | for (const root of roots) { | |
| 241 | if (budget.exhausted) break; | |
| 242 | const skills = yield* discoverSkillsInRoot({ ...root, budget }); | |
| 243 | for (const skill of skills) { | |
| 244 | if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill); | |
| 245 | } | |
| 246 | } | |
| 247 | return { | |
| 248 | skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)), | |
| 249 | failureReason: budget.exhausted | |
| 250 | ? ("scan-budget-exhausted" as const) | |
| 251 | : budget.incomplete | |
| 252 | ? ("filesystem-error" as const) | |
| 253 | : undefined, | |
| 254 | }; | |
| 255 | }); | |
| 256 | ||
| 257 | export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* ( | |
| 258 | cwd?: string, | |
| 259 | environment: NodeJS.ProcessEnv = process.env, | |
| 260 | ) { | |
| 261 | return (yield* inspectCursorSkills(cwd, environment)).skills; | |
| 262 | }); | |
| 263 | ||
| 264 | export const probeCursorSkills = Effect.fn("probeCursorSkills")(function* ( | |
| 265 | cwd?: string, | |
| 266 | environment: NodeJS.ProcessEnv = process.env, | |
| 267 | ) { | |
| 268 | const inspection = yield* inspectCursorSkills(cwd, environment); | |
| 269 | if (inspection.failureReason) { | |
| 270 | return yield* new CursorSkillsProbeError({ | |
| 271 | reason: inspection.failureReason, | |
| 272 | ...(cwd ? { cwd } : {}), | |
| 273 | }); | |
| 274 | } | |
| 275 | return inspection.skills; | |
| 276 | }); | |
| 277 | ||
| 278 | /** Cursor invokes Agent Skills with `/name`; T3 composers insert `$name`. */ | |
| 279 | export function hasCursorSkillMention(prompt: string): boolean { | |
| 280 | return HAS_SKILL_MENTION_PATTERN.test(prompt); | |
| 281 | } | |
| 282 | ||
| 283 | export function rewriteCursorSkillMentions( | |
| 284 | prompt: string, | |
| 285 | skillNames: ReadonlySet<string>, | |
| 286 | ): string { | |
| 287 | return prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) => | |
| 288 | skillNames.has(name) ? `${prefix}/${name}` : match, | |
| 289 | ); | |
| 290 | } |
apps/server/src/provider/Drivers/GrokDriver.test.ts+5-1| ... | ... | @@ -17,6 +17,7 @@ import { GrokDriver } from "./GrokDriver.ts"; |
| 17 | 17 | |
| 18 | 18 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 19 | 19 | import * as ProviderHostLive from "../ProviderHostLive.ts"; |
| 20 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; | |
| 20 | 21 | |
| 21 | 22 | const layerDeps = ServerConfig.layerTest(process.cwd(), { |
| 22 | 23 | prefix: "t3-grok-driver-update-", |
| ... | ... | @@ -42,7 +43,10 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { |
| 42 | 43 | ), |
| 43 | 44 | ), |
| 44 | 45 | ); |
| 45 | const layerTest = ProviderHostLive.layer.pipe(Layer.provideMerge(layerDeps)); | |
| 46 | const layerTest = ProviderHostLive.layer.pipe( | |
| 47 | Layer.provideMerge(ServerSecretStore.layer), | |
| 48 | Layer.provideMerge(layerDeps), | |
| 49 | ); | |
| 46 | 50 | |
| 47 | 51 | const noSpawner = ChildProcessSpawner.make(() => |
| 48 | 52 | Effect.die("Disabled Grok must not spawn a process"), |
apps/server/src/provider/ProviderHostLive.ts+30| ... | ... | @@ -5,13 +5,17 @@ |
| 5 | 5 | * @module provider/ProviderHostLive |
| 6 | 6 | */ |
| 7 | 7 | import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; |
| 8 | import { ProviderCredentialError } from "@t3tools/provider-core/server/errors"; | |
| 9 | import * as Crypto from "effect/Crypto"; | |
| 8 | 10 | import * as Effect from "effect/Effect"; |
| 9 | 11 | import * as Layer from "effect/Layer"; |
| 10 | 12 | |
| 11 | 13 | import { resolveAttachmentPath } from "../attachmentStore.ts"; |
| 14 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; | |
| 12 | 15 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 13 | 16 | import * as ServerConfig from "../config.ts"; |
| 14 | 17 | import * as ServerSettings from "../serverSettings.ts"; |
| 18 | import * as ProviderCredentialStore from "./ProviderCredentialStore.ts"; | |
| 15 | 19 | |
| 16 | 20 | export const layer = Layer.effect( |
| 17 | 21 | ProviderHost, |
| ... | ... | @@ -19,6 +23,8 @@ export const layer = Layer.effect( |
| 19 | 23 | const config = yield* ServerConfig.ServerConfig; |
| 20 | 24 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 21 | 25 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; |
| 26 | const secrets = yield* ServerSecretStore.ServerSecretStore; | |
| 27 | const crypto = yield* Crypto.Crypto; | |
| 22 | 28 | return ProviderHost.of({ |
| 23 | 29 | paths: { |
| 24 | 30 | cwd: config.cwd, |
| ... | ... | @@ -34,6 +40,30 @@ export const layer = Layer.effect( |
| 34 | 40 | shouldRunBackgroundWork: backgroundPolicy.shouldRunScopeWork, |
| 35 | 41 | resolveAttachmentPath: (attachment) => |
| 36 | 42 | resolveAttachmentPath({ attachmentsDir: config.attachmentsDir, attachment }), |
| 43 | credentials: (namespace, bindingId) => | |
| 44 | ProviderCredentialStore.make(namespace, bindingId).pipe( | |
| 45 | Effect.map((store) => ({ | |
| 46 | binding: store.binding, | |
| 47 | get: store.get.pipe( | |
| 48 | Effect.mapError((cause) => new ProviderCredentialError({ operation: "get", cause })), | |
| 49 | ), | |
| 50 | set: (credentials: Uint8Array) => | |
| 51 | store | |
| 52 | .set(credentials) | |
| 53 | .pipe( | |
| 54 | Effect.mapError( | |
| 55 | (cause) => new ProviderCredentialError({ operation: "set", cause }), | |
| 56 | ), | |
| 57 | ), | |
| 58 | remove: store.remove.pipe( | |
| 59 | Effect.mapError( | |
| 60 | (cause) => new ProviderCredentialError({ operation: "remove", cause }), | |
| 61 | ), | |
| 62 | ), | |
| 63 | })), | |
| 64 | Effect.provideService(ServerSecretStore.ServerSecretStore, secrets), | |
| 65 | Effect.provideService(Crypto.Crypto, crypto), | |
| 66 | ), | |
| 37 | 67 | }); |
| 38 | 68 | }), |
| 39 | 69 | ); |
apps/server/src/provider/ProviderInstanceRegistry.test.ts+2-2| ... | ... | @@ -31,12 +31,12 @@ import { |
| 31 | 31 | EnvironmentId, |
| 32 | 32 | type ClaudeSettings, |
| 33 | 33 | type CodexSettings, |
| 34 | type CursorSettings, | |
| 35 | 34 | type GrokSettings, |
| 36 | 35 | ProviderDriverKind, |
| 37 | 36 | type ProviderInstanceConfigMap, |
| 38 | 37 | ProviderInstanceId, |
| 39 | 38 | } from "@t3tools/contracts"; |
| 39 | import type { CursorSettings } from "@t3tools/provider-cursor/settings"; | |
| 40 | 40 | import { HostProcessPlatform, isHostWindows } from "@t3tools/shared/hostProcess"; |
| 41 | 41 | import * as DateTime from "effect/DateTime"; |
| 42 | 42 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -53,7 +53,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 53 | 53 | import * as ServerSettings from "../serverSettings.ts"; |
| 54 | 54 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; |
| 55 | 55 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; |
| 56 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; | |
| 56 | import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; | |
| 57 | 57 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; |
| 58 | 58 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; |
| 59 | 59 | import type { OpenCodeSettings } from "@t3tools/provider-opencode/settings"; |
apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts+1-1| ... | ... | @@ -2,7 +2,7 @@ import * as Layer from "effect/Layer"; |
| 2 | 2 | |
| 3 | 3 | import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; |
| 4 | 4 | import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 5 | import * as CursorAgentSdk from "../orchestration-v2/Adapters/CursorAgentSdk.ts"; | |
| 5 | import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; | |
| 6 | 6 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 7 | 7 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; |
| 8 | 8 |
apps/server/src/provider/acp/CursorTransportFailure.test.ts deleted-60| ... | ... | @@ -1,60 +0,0 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { CursorTransportFailure } from "./CursorTransportFailure.ts"; | |
| 4 | ||
| 5 | const diagnostic = "Error: RetriableError: WritableIterable is closed"; | |
| 6 | ||
| 7 | function failureFor(chunks: string[]) { | |
| 8 | const reply = new CursorTransportFailure(); | |
| 9 | for (const chunk of chunks) reply.push(chunk); | |
| 10 | return reply.failure; | |
| 11 | } | |
| 12 | ||
| 13 | describe("CursorTransportFailure", () => { | |
| 14 | it.each([ | |
| 15 | diagnostic, | |
| 16 | "Error: ConnectError: [unavailable] transport closed", | |
| 17 | "Error: ConnectError: [aborted] aborted", | |
| 18 | "Error: ConnectError: [deadline_exceeded] timed out", | |
| 19 | "Something went wrong communicating with the server. Please try again.", | |
| 20 | ])("recognizes a terminal diagnostic: %s", (message) => { | |
| 21 | expect(failureFor([message])).toBe(message); | |
| 22 | expect(failureFor([...message])).toBe(message); | |
| 23 | }); | |
| 24 | ||
| 25 | it("recognizes a standalone failure with a stack trace", () => { | |
| 26 | expect(failureFor(["\n", diagnostic, "\n at send (cli.js:1:2)\n\n"])).toBe(diagnostic); | |
| 27 | }); | |
| 28 | ||
| 29 | it.each([ | |
| 30 | "The error was " + diagnostic, | |
| 31 | "This is an example of a transport error:\n" + diagnostic, | |
| 32 | "I inspected the files.\n" + diagnostic, | |
| 33 | "> " + diagnostic, | |
| 34 | " " + diagnostic, | |
| 35 | "```text\n" + diagnostic + "\n```", | |
| 36 | "~~~\n" + diagnostic + "\n~~~", | |
| 37 | diagnostic + "\nThis is an example of a transport error.", | |
| 38 | "Error: ConnectError: [unauthenticated] sign in", | |
| 39 | "Error: ConnectError: [permission_denied] subscription required", | |
| 40 | "Error: HTTP 500 from the application being debugged", | |
| 41 | "Error: RetriableError: [internal] Failed to run step, exceeded max retries", | |
| 42 | "Error: RetriableError: [internal] Failed to run step, exceeded max retries\n at step (cli.js:1:2)", | |
| 43 | ])("preserves prose, code and non-transport errors: %s", (message) => { | |
| 44 | expect(failureFor([...message])).toBeUndefined(); | |
| 45 | }); | |
| 46 | ||
| 47 | it("tracks fences across long lines without retaining the answer", () => { | |
| 48 | expect(failureFor(["```\n", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); | |
| 49 | expect(failureFor(["```", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); | |
| 50 | expect(failureFor(["x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); | |
| 51 | }); | |
| 52 | ||
| 53 | it("does not retain a previous failure after new output", () => { | |
| 54 | const reply = new CursorTransportFailure(); | |
| 55 | reply.push(diagnostic); | |
| 56 | expect(reply.failure).toBe(diagnostic); | |
| 57 | reply.push("\nRecovered successfully."); | |
| 58 | expect(reply.failure).toBeUndefined(); | |
| 59 | }); | |
| 60 | }); |
apps/server/src/provider/acp/CursorTransportFailure.ts deleted-52| ... | ... | @@ -1,52 +0,0 @@ |
| 1 | const maxLineLength = 4096; | |
| 2 | // Cursor also uses RetriableError for agent-loop failures; preserve those diagnostics. | |
| 3 | const transportError = | |
| 4 | /^Error: (?:RetriableError: (?!\[internal\]).+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; | |
| 5 | const serverError = "Something went wrong communicating with the server. Please try again."; | |
| 6 | ||
| 7 | interface ReplyState { | |
| 8 | disqualified: boolean; | |
| 9 | failure: string | undefined; | |
| 10 | } | |
| 11 | ||
| 12 | function consumeLine(state: ReplyState, line: string) { | |
| 13 | if (state.disqualified) return; | |
| 14 | const text = line.trimEnd(); | |
| 15 | if (transportError.test(text) || text === serverError) { | |
| 16 | state.failure = text; | |
| 17 | } else if (text.trim() !== "" && !(state.failure && /^\s+at\s/.test(text))) { | |
| 18 | // An explanation or code sample can quote the same diagnostic. Only | |
| 19 | // classify an assistant item consisting entirely of a transport dump. | |
| 20 | state.disqualified = true; | |
| 21 | state.failure = undefined; | |
| 22 | } | |
| 23 | } | |
| 24 | ||
| 25 | /** Tracks a standalone Cursor diagnostic without retaining an entire streamed answer. */ | |
| 26 | export class CursorTransportFailure { | |
| 27 | private state: ReplyState = { disqualified: false, failure: undefined }; | |
| 28 | private line = ""; | |
| 29 | ||
| 30 | push(text: string) { | |
| 31 | for (const [index, part] of text.split("\n").entries()) { | |
| 32 | if (this.state.disqualified) return; | |
| 33 | if (index > 0) { | |
| 34 | consumeLine(this.state, this.line); | |
| 35 | this.line = ""; | |
| 36 | } | |
| 37 | if (this.line.length + part.length > maxLineLength) { | |
| 38 | this.state.disqualified = true; | |
| 39 | this.state.failure = undefined; | |
| 40 | this.line = ""; | |
| 41 | return; | |
| 42 | } | |
| 43 | this.line += part; | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | get failure() { | |
| 48 | const state = { ...this.state }; | |
| 49 | consumeLine(state, this.line); | |
| 50 | return state.failure; | |
| 51 | } | |
| 52 | } |
apps/server/src/provider/builtInDrivers.ts+1-1| ... | ... | @@ -24,7 +24,7 @@ import { AcpRegistryDriver, type AcpRegistryDriverEnv } from "./Drivers/AcpRegis |
| 24 | 24 | import { AntigravityDriver, type AntigravityDriverEnv } from "./Drivers/AntigravityDriver.ts"; |
| 25 | 25 | import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; |
| 26 | 26 | import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; |
| 27 | import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; | |
| 27 | import { CursorDriver, type CursorDriverEnv } from "@t3tools/provider-cursor/server"; | |
| 28 | 28 | import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; |
| 29 | 29 | import { OpenCodeDriver, type OpenCodeDriverEnv } from "@t3tools/provider-opencode/server"; |
| 30 | 30 | import { MuseDriver, type MuseDriverEnv } from "@t3tools/provider-muse/server"; |
apps/server/src/provider/cursorKeychainToken.test.ts deleted-46| ... | ... | @@ -1,46 +0,0 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { | |
| 4 | CursorKeychainTimeoutError, | |
| 5 | makeCachedCursorAccessTokenReader, | |
| 6 | } from "./cursorKeychainToken.ts"; | |
| 7 | ||
| 8 | describe("Cursor Keychain reader", () => { | |
| 9 | it("shares concurrent reads and rechecks after the cache expires", async () => { | |
| 10 | let reads = 0; | |
| 11 | let time = 0; | |
| 12 | const read = makeCachedCursorAccessTokenReader( | |
| 13 | async () => { | |
| 14 | reads++; | |
| 15 | return `token-${reads}`; | |
| 16 | }, | |
| 17 | () => time, | |
| 18 | ); | |
| 19 | assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]); | |
| 20 | assert.strictEqual(await read(), "token-1"); | |
| 21 | assert.strictEqual(reads, 1); | |
| 22 | time = 5 * 60_000; | |
| 23 | assert.strictEqual(await read(), "token-2"); | |
| 24 | }); | |
| 25 | ||
| 26 | it("gives up on an unanswered prompt and reuses it on the next read", async () => { | |
| 27 | let reads = 0; | |
| 28 | let allow: (token: string) => void = () => {}; | |
| 29 | const read = makeCachedCursorAccessTokenReader( | |
| 30 | () => { | |
| 31 | reads++; | |
| 32 | return new Promise((resolve) => { | |
| 33 | allow = resolve; | |
| 34 | }); | |
| 35 | }, | |
| 36 | () => 0, | |
| 37 | 1, | |
| 38 | ); | |
| 39 | const error = await read().catch((cause: unknown) => cause); | |
| 40 | assert.instanceOf(error, CursorKeychainTimeoutError); | |
| 41 | const retry = read(); | |
| 42 | allow("token"); | |
| 43 | assert.strictEqual(await retry, "token"); | |
| 44 | assert.strictEqual(reads, 1); | |
| 45 | }); | |
| 46 | }); |
apps/server/src/provider/cursorKeychainToken.ts deleted-55| ... | ... | @@ -1,55 +0,0 @@ |
| 1 | import * as NodeModule from "node:module"; | |
| 2 | ||
| 3 | const CACHE_MS = 5 * 60_000; | |
| 4 | ||
| 5 | const requireForKeyring = NodeModule.createRequire(import.meta.url); | |
| 6 | ||
| 7 | /** Rejected when nobody answers the macOS Keychain prompt in time. */ | |
| 8 | export class CursorKeychainTimeoutError extends Error { | |
| 9 | constructor() { | |
| 10 | super("Timed out waiting for Keychain access."); | |
| 11 | } | |
| 12 | } | |
| 13 | ||
| 14 | /** | |
| 15 | * Share one Keychain request across usage history and limits in this server process. | |
| 16 | * | |
| 17 | * macOS shows the access prompt on the server's own screen, which a remote | |
| 18 | * client cannot answer, so callers give up after `timeoutMs`. The read stays in | |
| 19 | * flight: the next call reuses it instead of stacking a second prompt, and picks | |
| 20 | * up the token once someone allows access. | |
| 21 | */ | |
| 22 | export function makeCachedCursorAccessTokenReader( | |
| 23 | read: () => Promise<string | null>, | |
| 24 | now: () => number = Date.now, | |
| 25 | timeoutMs = 30_000, | |
| 26 | ): () => Promise<string | null> { | |
| 27 | let cached: { token: string; until: number } | null = null; | |
| 28 | let pending: Promise<string | null> | null = null; | |
| 29 | return () => { | |
| 30 | if (cached && cached.until > now()) return Promise.resolve(cached.token); | |
| 31 | pending ??= read() | |
| 32 | .then((token) => { | |
| 33 | cached = token ? { token, until: now() + CACHE_MS } : null; | |
| 34 | return token; | |
| 35 | }) | |
| 36 | .finally(() => { | |
| 37 | pending = null; | |
| 38 | }); | |
| 39 | const deadline = AbortSignal.timeout(timeoutMs); | |
| 40 | return Promise.race([ | |
| 41 | pending, | |
| 42 | new Promise<never>((_, reject) => { | |
| 43 | deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), { | |
| 44 | once: true, | |
| 45 | }); | |
| 46 | }), | |
| 47 | ]); | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | /** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ | |
| 52 | export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { | |
| 53 | const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); | |
| 54 | return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; | |
| 55 | }); |
apps/server/src/provider/cursorSdk.test.ts deleted-193| ... | ... | @@ -1,193 +0,0 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- The probe is a plain Node child; this deadline kills it if it hangs. | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | const cursorSdkUrl = new URL("./cursorSdk.ts", import.meta.url).href; | |
| 6 | ||
| 7 | // Loads cursorSdk.ts in a fresh process. @cursor/sdk is stubbed so the guard | |
| 8 | // can be tested without the real package. The vitest worker already has its | |
| 9 | // own unhandledRejection listener, which would hide the process-exit behavior. | |
| 10 | const probeProgram = ` | |
| 11 | import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { registerHooks } from "node:module"; | |
| 15 | import { pathToFileURL } from "node:url"; | |
| 16 | import { spawn } from "node:child_process"; | |
| 17 | ||
| 18 | const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-")); | |
| 19 | const stub = join(dir, "stub.cjs"); | |
| 20 | writeFileSync( | |
| 21 | stub, | |
| 22 | "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };", | |
| 23 | ); | |
| 24 | registerHooks({ | |
| 25 | resolve(specifier, context, next) { | |
| 26 | if (specifier === "@cursor/sdk") { | |
| 27 | return { url: pathToFileURL(stub).href, shortCircuit: true }; | |
| 28 | } | |
| 29 | return next(specifier, context); | |
| 30 | }, | |
| 31 | }); | |
| 32 | ||
| 33 | const { isCursorShellSpawnFailure } = await import(${JSON.stringify(cursorSdkUrl)}); | |
| 34 | const mode = process.argv[1]; | |
| 35 | const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid); | |
| 36 | ||
| 37 | if (mode === "predicate") { | |
| 38 | const cursorShell = Object.assign(new Error("spawn /bin/zsh ENOENT"), { | |
| 39 | code: "ENOENT", | |
| 40 | syscall: "spawn /bin/zsh", | |
| 41 | path: "/bin/zsh", | |
| 42 | spawnargs: ["-c", "dump_zsh_state >&4", "--", "true"], | |
| 43 | }); | |
| 44 | const bashShell = Object.assign(new Error("spawn bash ENOENT"), { | |
| 45 | code: "ENOENT", | |
| 46 | syscall: "spawn bash", | |
| 47 | spawnargs: ["-c", "dump_bash_state >&4"], | |
| 48 | }); | |
| 49 | const sandboxRestore = Object.assign(new Error("spawn /bin/zsh ENOENT"), { | |
| 50 | code: "ENOENT", | |
| 51 | syscall: "spawn /bin/zsh", | |
| 52 | spawnargs: ["-c", "builtin eval \\"\${__CURSOR_SANDBOX_ENV_RESTORE:-}\\""], | |
| 53 | }); | |
| 54 | const gitSpawn = Object.assign(new Error("spawn git ENOENT"), { | |
| 55 | code: "ENOENT", | |
| 56 | syscall: "spawn git", | |
| 57 | path: "git", | |
| 58 | spawnargs: ["status"], | |
| 59 | }); | |
| 60 | const plainZsh = Object.assign(new Error("spawn /bin/zsh ENOENT"), { | |
| 61 | code: "ENOENT", | |
| 62 | syscall: "spawn /bin/zsh", | |
| 63 | path: "/bin/zsh", | |
| 64 | spawnargs: ["-lc", "true"], | |
| 65 | }); | |
| 66 | console.log( | |
| 67 | JSON.stringify({ | |
| 68 | cursorShell: isCursorShellSpawnFailure(cursorShell), | |
| 69 | bashShell: isCursorShellSpawnFailure(bashShell), | |
| 70 | sandboxRestore: isCursorShellSpawnFailure(sandboxRestore), | |
| 71 | gitSpawn: isCursorShellSpawnFailure(gitSpawn), | |
| 72 | plainZsh: isCursorShellSpawnFailure(plainZsh), | |
| 73 | open: isCursorShellSpawnFailure( | |
| 74 | Object.assign(new Error("open failed"), { code: "ENOENT", syscall: "open" }), | |
| 75 | ), | |
| 76 | plain: isCursorShellSpawnFailure(new Error("boom")), | |
| 77 | string: isCursorShellSpawnFailure("spawn ENOENT"), | |
| 78 | }), | |
| 79 | ); | |
| 80 | process.exit(0); | |
| 81 | } | |
| 82 | ||
| 83 | if (mode === "cursor-shell") { | |
| 84 | const child = spawn("/bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"], { | |
| 85 | cwd: missingCwd, | |
| 86 | }); | |
| 87 | child.on("error", (error) => { | |
| 88 | Promise.reject(error); | |
| 89 | }); | |
| 90 | setTimeout(() => process.exit(0), 500); | |
| 91 | } else if (mode === "other-spawn") { | |
| 92 | Promise.reject( | |
| 93 | Object.assign(new Error("spawn git ENOENT"), { | |
| 94 | code: "ENOENT", | |
| 95 | syscall: "spawn git", | |
| 96 | path: "git", | |
| 97 | spawnargs: ["status"], | |
| 98 | }), | |
| 99 | ); | |
| 100 | setTimeout(() => process.exit(0), 500); | |
| 101 | } else if (mode === "other-rejection") { | |
| 102 | Promise.reject(new Error("boom")); | |
| 103 | setTimeout(() => process.exit(0), 500); | |
| 104 | } else if (mode === "spawn-without-listener") { | |
| 105 | spawn(process.execPath, ["-e", "process.exit(0)"], { cwd: missingCwd }); | |
| 106 | setTimeout(() => process.exit(0), 500); | |
| 107 | } else { | |
| 108 | console.error("Unknown cursor shell spawn guard probe: " + mode); | |
| 109 | process.exit(2); | |
| 110 | } | |
| 111 | `; | |
| 112 | ||
| 113 | function runProbe(mode: string): Promise<{ code: number | null; stdout: string; stderr: string }> { | |
| 114 | return new Promise((resolve, reject) => { | |
| 115 | const child = NodeChildProcess.spawn( | |
| 116 | process.execPath, | |
| 117 | [ | |
| 118 | "--experimental-strip-types", | |
| 119 | "--disable-warning=ExperimentalWarning", | |
| 120 | "--input-type=module", | |
| 121 | "-e", | |
| 122 | probeProgram, | |
| 123 | mode, | |
| 124 | ], | |
| 125 | { stdio: ["ignore", "pipe", "pipe"] }, | |
| 126 | ); | |
| 127 | let stdout = ""; | |
| 128 | let stderr = ""; | |
| 129 | child.stdout.setEncoding("utf8"); | |
| 130 | child.stderr.setEncoding("utf8"); | |
| 131 | child.stdout.on("data", (chunk: string) => { | |
| 132 | stdout += chunk; | |
| 133 | }); | |
| 134 | child.stderr.on("data", (chunk: string) => { | |
| 135 | stderr += chunk; | |
| 136 | }); | |
| 137 | const timer = setTimeout(() => { | |
| 138 | child.kill(); | |
| 139 | reject(new Error(`probe ${mode} timed out`)); | |
| 140 | }, 5_000); | |
| 141 | child.on("error", reject); | |
| 142 | child.on("exit", (code) => { | |
| 143 | clearTimeout(timer); | |
| 144 | resolve({ code, stdout, stderr }); | |
| 145 | }); | |
| 146 | }); | |
| 147 | } | |
| 148 | ||
| 149 | describe("isCursorShellSpawnFailure", () => { | |
| 150 | it("matches only Cursor's shell wrapper", async () => { | |
| 151 | const result = await runProbe("predicate"); | |
| 152 | expect(result.code).toBe(0); | |
| 153 | expect(JSON.parse(result.stdout)).toEqual({ | |
| 154 | cursorShell: true, | |
| 155 | bashShell: true, | |
| 156 | sandboxRestore: true, | |
| 157 | gitSpawn: false, | |
| 158 | plainZsh: false, | |
| 159 | open: false, | |
| 160 | plain: false, | |
| 161 | string: false, | |
| 162 | }); | |
| 163 | }); | |
| 164 | }); | |
| 165 | ||
| 166 | describe("Cursor shell spawn guard", () => { | |
| 167 | it("keeps the process alive when Cursor's shell spawn rejects", async () => { | |
| 168 | const result = await runProbe("cursor-shell"); | |
| 169 | expect(result.code).toBe(0); | |
| 170 | expect(result.stderr).toContain("The server will keep running."); | |
| 171 | expect(result.stderr).toContain("ENOENT"); | |
| 172 | }); | |
| 173 | ||
| 174 | it("still exits when a different spawn failure is unhandled", async () => { | |
| 175 | const result = await runProbe("other-spawn"); | |
| 176 | expect(result.code).toBe(1); | |
| 177 | expect(result.stderr).toContain("spawn git ENOENT"); | |
| 178 | expect(result.stderr).not.toContain("The server will keep running."); | |
| 179 | }); | |
| 180 | ||
| 181 | it("still exits on unrelated unhandled rejections", async () => { | |
| 182 | const result = await runProbe("other-rejection"); | |
| 183 | expect(result.code).toBe(1); | |
| 184 | expect(result.stderr).toContain("boom"); | |
| 185 | }); | |
| 186 | ||
| 187 | it("leaves a spawn with no error listener fatal", async () => { | |
| 188 | const result = await runProbe("spawn-without-listener"); | |
| 189 | expect(result.code).toBe(1); | |
| 190 | expect(result.stderr).toContain("Unhandled"); | |
| 191 | expect(result.stderr).not.toContain("The server will keep running."); | |
| 192 | }); | |
| 193 | }); |
apps/server/src/provider/cursorSdk.ts deleted-77| ... | ... | @@ -1,77 +0,0 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- Installed before the SDK loads, outside an Effect runtime. stderr must match Node's default unhandled-rejection print. | |
| 2 | import * as NodeModule from "node:module"; | |
| 3 | ||
| 4 | /** | |
| 5 | * The Cursor Agent SDK runs in this process and spawns a shell for tool | |
| 6 | * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`, | |
| 7 | * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory | |
| 8 | * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error, | |
| 9 | * then rejects an internal promise nothing awaits. Node exits on that | |
| 10 | * unhandled rejection and takes the server with it. | |
| 11 | * | |
| 12 | * This listener ignores only that rejection. Every other unhandled rejection | |
| 13 | * still prints and exits when this is the process's only listener, matching | |
| 14 | * Node's default. `child_process.spawn` itself is left alone, so git, | |
| 15 | * terminals, and other providers keep their own error handling. | |
| 16 | */ | |
| 17 | const CURSOR_SHELL_SPAWN_MARKERS = [ | |
| 18 | "dump_zsh_state", | |
| 19 | "dump_bash_state", | |
| 20 | "__CURSOR_SANDBOX_ENV_RESTORE", | |
| 21 | ] as const; | |
| 22 | ||
| 23 | export function isCursorShellSpawnFailure(reason: unknown): boolean { | |
| 24 | if (typeof reason !== "object" || reason === null) { | |
| 25 | return false; | |
| 26 | } | |
| 27 | const syscall = Reflect.get(reason, "syscall"); | |
| 28 | const code = Reflect.get(reason, "code"); | |
| 29 | const spawnargs = Reflect.get(reason, "spawnargs"); | |
| 30 | if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") { | |
| 31 | return false; | |
| 32 | } | |
| 33 | if (!Array.isArray(spawnargs)) { | |
| 34 | return false; | |
| 35 | } | |
| 36 | return spawnargs.some( | |
| 37 | (arg) => | |
| 38 | typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)), | |
| 39 | ); | |
| 40 | } | |
| 41 | ||
| 42 | let cursorShellSpawnGuardInstalled = false; | |
| 43 | ||
| 44 | function installCursorShellSpawnGuard(): void { | |
| 45 | if (cursorShellSpawnGuardInstalled) { | |
| 46 | return; | |
| 47 | } | |
| 48 | cursorShellSpawnGuardInstalled = true; | |
| 49 | ||
| 50 | process.on("unhandledRejection", (reason) => { | |
| 51 | if (isCursorShellSpawnFailure(reason)) { | |
| 52 | console.error("Cursor shell spawn failed. The server will keep running.", reason); | |
| 53 | return; | |
| 54 | } | |
| 55 | // Another handler registered besides this one owns the decision. | |
| 56 | if (process.listenerCount("unhandledRejection") > 1) { | |
| 57 | return; | |
| 58 | } | |
| 59 | console.error(reason); | |
| 60 | process.exit(1); | |
| 61 | }); | |
| 62 | } | |
| 63 | ||
| 64 | // Arm before the SDK loads. Its shell tools spawn from this process. | |
| 65 | installCursorShellSpawnGuard(); | |
| 66 | ||
| 67 | // Cursor's Webpack chunks and local helpers must stay beside the SDK entry. | |
| 68 | // createRequire also loads that disk-backed package from a Node SEA executable. | |
| 69 | const requireCursorSdk = NodeModule.createRequire(import.meta.url); | |
| 70 | export const { | |
| 71 | Agent, | |
| 72 | AuthenticationError, | |
| 73 | createAgentPlatform, | |
| 74 | Cursor, | |
| 75 | CursorSdkError, | |
| 76 | InMemoryCredentialStore, | |
| 77 | } = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk"); |
apps/server/src/provider/cursorSdkModel.ts deleted-50| ... | ... | @@ -1,50 +0,0 @@ |
| 1 | import type { ModelSelection as CursorSdkModelSelection, ModelParameterValue } from "@cursor/sdk"; | |
| 2 | import type { ModelSelection } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | const CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION: Readonly<Record<string, string>> = { | |
| 5 | context: "contextWindow", | |
| 6 | fast: "fastMode", | |
| 7 | }; | |
| 8 | ||
| 9 | const PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER: Readonly<Record<string, string>> = { | |
| 10 | contextWindow: "context", | |
| 11 | fastMode: "fast", | |
| 12 | }; | |
| 13 | ||
| 14 | export function cursorSdkProviderOptionId(parameterId: string): string { | |
| 15 | return CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION[parameterId] ?? parameterId; | |
| 16 | } | |
| 17 | ||
| 18 | function cursorSdkParameterId(providerOptionId: string): string { | |
| 19 | return PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER[providerOptionId] ?? providerOptionId; | |
| 20 | } | |
| 21 | ||
| 22 | export function cursorSdkParameterPriority(parameterId: string): number { | |
| 23 | switch (parameterId) { | |
| 24 | case "effort": | |
| 25 | case "reasoning": | |
| 26 | return 0; | |
| 27 | case "context": | |
| 28 | return 1; | |
| 29 | case "fast": | |
| 30 | return 2; | |
| 31 | case "thinking": | |
| 32 | return 3; | |
| 33 | default: | |
| 34 | return 4; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | export function cursorSdkModelSelection(modelSelection: ModelSelection): CursorSdkModelSelection { | |
| 39 | return { | |
| 40 | id: modelSelection.model === "auto" ? "default" : modelSelection.model, | |
| 41 | ...(modelSelection.options === undefined || modelSelection.options.length === 0 | |
| 42 | ? {} | |
| 43 | : { | |
| 44 | params: modelSelection.options.map((option): ModelParameterValue => ({ | |
| 45 | id: cursorSdkParameterId(option.id), | |
| 46 | value: String(option.value), | |
| 47 | })), | |
| 48 | }), | |
| 49 | }; | |
| 50 | } |
apps/server/src/provider/cursorUsageLimits.test.ts deleted-264| ... | ... | @@ -1,264 +0,0 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { NodeServices } from "@effect/platform-node"; | |
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 8 | import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./cursorUsageLimits.ts"; | |
| 9 | ||
| 10 | const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) => | |
| 11 | effect.pipe(Effect.provide(NodeServices.layer)); | |
| 12 | ||
| 13 | describe("Cursor usage limits", () => { | |
| 14 | const checkedAt = "2026-09-16T00:00:00.000Z"; | |
| 15 | ||
| 16 | it("uses the advertised percentages and billing-cycle reset", () => { | |
| 17 | const limits = cursorUsageResponseToLimits( | |
| 18 | { | |
| 19 | billingCycleEnd: "1789876386000", | |
| 20 | planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, | |
| 21 | }, | |
| 22 | checkedAt, | |
| 23 | ); | |
| 24 | expect(limits.windows).toEqual( | |
| 25 | expect.arrayContaining([ | |
| 26 | { | |
| 27 | id: "totalPercentUsed", | |
| 28 | kind: "monthly", | |
| 29 | label: "Overall", | |
| 30 | usedPercent: 72.4, | |
| 31 | resetsAt: "2026-09-20T03:53:06.000Z", | |
| 32 | }, | |
| 33 | { | |
| 34 | id: "autoPercentUsed", | |
| 35 | kind: "monthly", | |
| 36 | label: "Cursor Models", | |
| 37 | usedPercent: 69.5, | |
| 38 | resetsAt: "2026-09-20T03:53:06.000Z", | |
| 39 | }, | |
| 40 | { | |
| 41 | id: "apiPercentUsed", | |
| 42 | kind: "monthly", | |
| 43 | label: "Other Models", | |
| 44 | usedPercent: 100, | |
| 45 | resetsAt: "2026-09-20T03:53:06.000Z", | |
| 46 | }, | |
| 47 | ]), | |
| 48 | ); | |
| 49 | }); | |
| 50 | ||
| 51 | it("does not invent unused allowance for absent buckets", () => { | |
| 52 | expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( | |
| 53 | "unsupported", | |
| 54 | ); | |
| 55 | expect( | |
| 56 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, | |
| 57 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); | |
| 58 | expect( | |
| 59 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, | |
| 60 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); | |
| 61 | }); | |
| 62 | ||
| 63 | it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => | |
| 64 | Effect.gen(function* () { | |
| 65 | yield* withNodeServices( | |
| 66 | Effect.gen(function* () { | |
| 67 | const fs = yield* FileSystem.FileSystem; | |
| 68 | const path = yield* Path.Path; | |
| 69 | const directory = yield* fs.makeTempDirectoryScoped(); | |
| 70 | yield* fs.makeDirectory(path.join(directory, "cursor")); | |
| 71 | yield* fs.writeFileString( | |
| 72 | path.join(directory, "cursor", "auth.json"), | |
| 73 | '{"accessToken":"instance-token"}', | |
| 74 | ); | |
| 75 | const client = HttpClient.make((request) => { | |
| 76 | expect(request.url).toBe( | |
| 77 | "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", | |
| 78 | ); | |
| 79 | expect(request.method).toBe("POST"); | |
| 80 | expect(request.headers.authorization).toBe("Bearer instance-token"); | |
| 81 | expect(request.headers["connect-protocol-version"]).toBe("1"); | |
| 82 | return Effect.succeed( | |
| 83 | HttpClientResponse.fromWeb( | |
| 84 | request, | |
| 85 | Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), | |
| 86 | ), | |
| 87 | ); | |
| 88 | }); | |
| 89 | yield* fs.makeDirectory(path.join(directory, ".cursor")); | |
| 90 | yield* fs.writeFileString( | |
| 91 | path.join(directory, ".cursor", "auth.json"), | |
| 92 | '{"accessToken":"instance-token"}', | |
| 93 | ); | |
| 94 | for (const platform of ["linux", "darwin"] as const) { | |
| 95 | const limits = yield* readCursorUsageLimits( | |
| 96 | { apiEndpoint: "https://cursor.example/" }, | |
| 97 | { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, | |
| 98 | ).pipe( | |
| 99 | Effect.provideService(HostProcessPlatform, platform), | |
| 100 | Effect.provideService(HttpClient.HttpClient, client), | |
| 101 | ); | |
| 102 | expect(limits.windows[0]?.usedPercent).toBe(42); | |
| 103 | } | |
| 104 | }).pipe(Effect.scoped), | |
| 105 | ); | |
| 106 | }), | |
| 107 | ); | |
| 108 | ||
| 109 | it.effect( | |
| 110 | "never reads stale files for keychain or memory logins, but accepts an explicit auth token", | |
| 111 | () => | |
| 112 | Effect.gen(function* () { | |
| 113 | for (const platform of ["linux", "darwin"] as const) { | |
| 114 | for (const token of [undefined, "explicit-token"]) { | |
| 115 | const limits = yield* withNodeServices( | |
| 116 | readCursorUsageLimits( | |
| 117 | { apiEndpoint: "" }, | |
| 118 | { | |
| 119 | AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", | |
| 120 | ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), | |
| 121 | }, | |
| 122 | false, | |
| 123 | async () => { | |
| 124 | throw new Error("must not read Keychain before opt-in"); | |
| 125 | }, | |
| 126 | ).pipe( | |
| 127 | Effect.provideService(HostProcessPlatform, platform), | |
| 128 | Effect.provideService( | |
| 129 | FileSystem.FileSystem, | |
| 130 | FileSystem.makeNoop({ | |
| 131 | readFileString: () => Effect.die("must not read an unrelated credential file"), | |
| 132 | }), | |
| 133 | ), | |
| 134 | Effect.provideService( | |
| 135 | HttpClient.HttpClient, | |
| 136 | HttpClient.make((request) => { | |
| 137 | expect(token).toBe("explicit-token"); | |
| 138 | expect(request.headers.authorization).toBe("Bearer explicit-token"); | |
| 139 | return Effect.succeed( | |
| 140 | HttpClientResponse.fromWeb( | |
| 141 | request, | |
| 142 | Response.json({ planUsage: { totalPercentUsed: 10 } }), | |
| 143 | ), | |
| 144 | ); | |
| 145 | }), | |
| 146 | ), | |
| 147 | ), | |
| 148 | ); | |
| 149 | if (token) expect(limits.windows[0]?.usedPercent).toBe(10); | |
| 150 | else expect(limits.unavailable?.reason).toBe("unsupported"); | |
| 151 | } | |
| 152 | } | |
| 153 | }), | |
| 154 | ); | |
| 155 | ||
| 156 | it.effect("reports failed requests without exposing credentials or response bodies", () => | |
| 157 | Effect.gen(function* () { | |
| 158 | const limits = yield* withNodeServices( | |
| 159 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( | |
| 160 | Effect.provideService( | |
| 161 | HttpClient.HttpClient, | |
| 162 | HttpClient.make((request) => | |
| 163 | Effect.succeed( | |
| 164 | HttpClientResponse.fromWeb( | |
| 165 | request, | |
| 166 | new Response("private response", { status: 401 }), | |
| 167 | ), | |
| 168 | ), | |
| 169 | ), | |
| 170 | ), | |
| 171 | ), | |
| 172 | ); | |
| 173 | expect(limits.unavailable).toEqual({ | |
| 174 | reason: "probeFailed", | |
| 175 | message: "Cursor could not read usage limits.", | |
| 176 | }); | |
| 177 | }), | |
| 178 | ); | |
| 179 | ||
| 180 | it.effect("does not use a stored login for an explicit API key", () => | |
| 181 | Effect.gen(function* () { | |
| 182 | const limits = yield* withNodeServices( | |
| 183 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( | |
| 184 | Effect.provideService( | |
| 185 | HttpClient.HttpClient, | |
| 186 | HttpClient.make(() => Effect.die("must not request usage")), | |
| 187 | ), | |
| 188 | ), | |
| 189 | ); | |
| 190 | expect(limits.unavailable?.reason).toBe("unsupported"); | |
| 191 | }), | |
| 192 | ); | |
| 193 | ||
| 194 | it.effect("reads the default macOS Cursor login from Keychain for limits", () => | |
| 195 | Effect.gen(function* () { | |
| 196 | const limits = yield* withNodeServices( | |
| 197 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( | |
| 198 | Effect.provideService(HostProcessPlatform, "darwin"), | |
| 199 | Effect.provideService( | |
| 200 | FileSystem.FileSystem, | |
| 201 | FileSystem.makeNoop({ | |
| 202 | readFileString: () => Effect.die("must not read a stale credential file"), | |
| 203 | }), | |
| 204 | ), | |
| 205 | Effect.provideService( | |
| 206 | HttpClient.HttpClient, | |
| 207 | HttpClient.make((request) => { | |
| 208 | expect(request.headers.authorization).toBe("Bearer keychain-token"); | |
| 209 | return Effect.succeed( | |
| 210 | HttpClientResponse.fromWeb( | |
| 211 | request, | |
| 212 | Response.json({ planUsage: { totalPercentUsed: 42 } }), | |
| 213 | ), | |
| 214 | ); | |
| 215 | }), | |
| 216 | ), | |
| 217 | ), | |
| 218 | ); | |
| 219 | expect(limits.windows[0]?.usedPercent).toBe(42); | |
| 220 | }), | |
| 221 | ); | |
| 222 | ||
| 223 | it.effect("reports a Keychain initialization failure without failing the provider refresh", () => | |
| 224 | Effect.gen(function* () { | |
| 225 | const limits = yield* withNodeServices( | |
| 226 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { | |
| 227 | throw new Error("Keychain initialization failed"); | |
| 228 | }).pipe( | |
| 229 | Effect.provideService(HostProcessPlatform, "darwin"), | |
| 230 | Effect.provideService( | |
| 231 | HttpClient.HttpClient, | |
| 232 | HttpClient.make(() => Effect.die("must not request limits without a login")), | |
| 233 | ), | |
| 234 | ), | |
| 235 | ); | |
| 236 | expect(limits.unavailable?.reason).toBe("probeFailed"); | |
| 237 | }), | |
| 238 | ); | |
| 239 | ||
| 240 | it.effect("does not read Keychain or send its token to a custom endpoint", () => | |
| 241 | Effect.gen(function* () { | |
| 242 | for (const [apiEndpoint, environment] of [ | |
| 243 | ["http://localhost:3000", {}], | |
| 244 | ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], | |
| 245 | ["https://cursor-proxy.example", {}], | |
| 246 | ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], | |
| 247 | ] as const) { | |
| 248 | const limits = yield* withNodeServices( | |
| 249 | readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { | |
| 250 | throw new Error("must not read Keychain for a custom endpoint"); | |
| 251 | }).pipe( | |
| 252 | Effect.provideService(HostProcessPlatform, "darwin"), | |
| 253 | Effect.provideService( | |
| 254 | HttpClient.HttpClient, | |
| 255 | HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), | |
| 256 | ), | |
| 257 | ), | |
| 258 | ); | |
| 259 | expect(limits.unavailable?.reason).toBe("unsupported"); | |
| 260 | expect(limits.unavailable?.message).toContain("default Cursor endpoint"); | |
| 261 | } | |
| 262 | }), | |
| 263 | ); | |
| 264 | }); |
apps/server/src/provider/cursorUsageLimits.ts deleted-151| ... | ... | @@ -1,151 +0,0 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | import type { CursorSettings, ServerProviderUsageWindow } from "@t3tools/contracts"; | |
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; | |
| 5 | import * as DateTime from "effect/DateTime"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as FileSystem from "effect/FileSystem"; | |
| 8 | import * as Option from "effect/Option"; | |
| 9 | import * as Path from "effect/Path"; | |
| 10 | import * as Schema from "effect/Schema"; | |
| 11 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 12 | import { | |
| 13 | clampPercent, | |
| 14 | makeUnavailableUsageLimits, | |
| 15 | makeUsageLimits, | |
| 16 | } from "@t3tools/provider-core/server/usageLimits"; | |
| 17 | import { readMacCursorAccessToken } from "./cursorKeychainToken.ts"; | |
| 18 | ||
| 19 | const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); | |
| 20 | const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; | |
| 21 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); | |
| 22 | const CursorUsageResponse = Schema.Struct({ | |
| 23 | billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), | |
| 24 | planUsage: Schema.optional( | |
| 25 | Schema.Struct({ | |
| 26 | totalPercentUsed: Schema.optional(Schema.Number), | |
| 27 | autoPercentUsed: Schema.optional(Schema.Number), | |
| 28 | apiPercentUsed: Schema.optional(Schema.Number), | |
| 29 | }), | |
| 30 | ), | |
| 31 | }); | |
| 32 | ||
| 33 | /** Cursor's dashboard percentages include bonus usage; spend / limit does not. */ | |
| 34 | export function cursorUsageResponseToLimits( | |
| 35 | response: typeof CursorUsageResponse.Type, | |
| 36 | checkedAt: string, | |
| 37 | ) { | |
| 38 | const reset = DateTime.make(Number(response.billingCycleEnd)); | |
| 39 | const resetsAt = | |
| 40 | Number(response.billingCycleEnd) > 0 && Option.isSome(reset) | |
| 41 | ? DateTime.formatIso(reset.value) | |
| 42 | : undefined; | |
| 43 | const windows: ServerProviderUsageWindow[] = []; | |
| 44 | if (response.planUsage) { | |
| 45 | for (const { id, label } of CURSOR_USAGE_WINDOWS) { | |
| 46 | const usedPercent = response.planUsage[id]; | |
| 47 | if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; | |
| 48 | windows.push({ | |
| 49 | id, | |
| 50 | kind: "monthly", | |
| 51 | label, | |
| 52 | usedPercent: clampPercent(usedPercent), | |
| 53 | ...(resetsAt ? { resetsAt } : {}), | |
| 54 | }); | |
| 55 | } | |
| 56 | } | |
| 57 | return windows.length > 0 | |
| 58 | ? makeUsageLimits({ checkedAt, windows }) | |
| 59 | : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 60 | } | |
| 61 | ||
| 62 | export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( | |
| 63 | settings: Pick<CursorSettings, "apiEndpoint">, | |
| 64 | environment: NodeJS.ProcessEnv = process.env, | |
| 65 | allowKeychain = false, | |
| 66 | keychainToken: () => Promise<string | null> = readMacCursorAccessToken, | |
| 67 | ) { | |
| 68 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 69 | return yield* Effect.gen(function* () { | |
| 70 | const fs = yield* FileSystem.FileSystem; | |
| 71 | const path = yield* Path.Path; | |
| 72 | const platform = yield* HostProcessPlatform; | |
| 73 | const endpoint = ( | |
| 74 | settings.apiEndpoint?.trim() || | |
| 75 | environment.CURSOR_API_ENDPOINT?.trim() || | |
| 76 | DEFAULT_CURSOR_API_ENDPOINT | |
| 77 | ).replace(/\/$/, ""); | |
| 78 | let token = environment.CURSOR_AUTH_TOKEN?.trim(); | |
| 79 | // An explicit API key can name a different account from the stored login. | |
| 80 | if (!token && environment.CURSOR_API_KEY?.trim()) { | |
| 81 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 82 | } | |
| 83 | const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; | |
| 84 | if (!token && credentialStore === "memory") { | |
| 85 | return makeUnavailableUsageLimits({ | |
| 86 | checkedAt, | |
| 87 | reason: "unsupported", | |
| 88 | message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", | |
| 89 | }); | |
| 90 | } | |
| 91 | if (!token && platform === "darwin" && credentialStore !== "file") { | |
| 92 | if (!allowKeychain) { | |
| 93 | return makeUnavailableUsageLimits({ | |
| 94 | checkedAt, | |
| 95 | reason: "unsupported", | |
| 96 | message: "Enable Cursor account usage in T3 Code to read its Keychain login.", | |
| 97 | }); | |
| 98 | } | |
| 99 | if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { | |
| 100 | return makeUnavailableUsageLimits({ | |
| 101 | checkedAt, | |
| 102 | reason: "unsupported", | |
| 103 | message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", | |
| 104 | }); | |
| 105 | } | |
| 106 | token = (yield* Effect.tryPromise(keychainToken))?.trim(); | |
| 107 | } else if (!token) { | |
| 108 | const home = | |
| 109 | (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); | |
| 110 | const directory = | |
| 111 | platform === "win32" | |
| 112 | ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") | |
| 113 | : platform === "darwin" | |
| 114 | ? path.join(home, ".cursor") | |
| 115 | : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); | |
| 116 | const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( | |
| 117 | Effect.catchTags({ | |
| 118 | PlatformError: (error) => | |
| 119 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | |
| 120 | }), | |
| 121 | Effect.flatMap(decodeCredentials), | |
| 122 | ); | |
| 123 | token = credentials.accessToken?.trim(); | |
| 124 | } | |
| 125 | if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 126 | const client = yield* HttpClient.HttpClient; | |
| 127 | const response = yield* client.execute( | |
| 128 | HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( | |
| 129 | HttpClientRequest.bearerToken(token), | |
| 130 | HttpClientRequest.setHeaders({ | |
| 131 | "connect-protocol-version": "1", | |
| 132 | "x-cursor-client-type": "cli", | |
| 133 | }), | |
| 134 | HttpClientRequest.bodyJsonUnsafe({}), | |
| 135 | ), | |
| 136 | ); | |
| 137 | const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( | |
| 138 | yield* HttpClientResponse.filterStatusOk(response), | |
| 139 | ); | |
| 140 | return cursorUsageResponseToLimits(body, checkedAt); | |
| 141 | }).pipe( | |
| 142 | Effect.timeout("10 seconds"), | |
| 143 | Effect.orElseSucceed(() => | |
| 144 | makeUnavailableUsageLimits({ | |
| 145 | checkedAt, | |
| 146 | reason: "probeFailed", | |
| 147 | message: "Cursor could not read usage limits.", | |
| 148 | }), | |
| 149 | ), | |
| 150 | ); | |
| 151 | }); |
apps/server/src/textGeneration/CursorTextGeneration.test.ts deleted-415| ... | ... | @@ -1,415 +0,0 @@ |
| 1 | import type { RunResult } from "@cursor/sdk"; | |
| 2 | import { CursorSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; | |
| 3 | import { describe, expect, it } from "@effect/vitest"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Fiber from "effect/Fiber"; | |
| 7 | import * as TestClock from "effect/testing/TestClock"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 10 | import { beforeEach, vi } from "vite-plus/test"; | |
| 11 | ||
| 12 | import { makeCursorTextGeneration } from "./CursorTextGeneration.ts"; | |
| 13 | ||
| 14 | const cursorSdkMock = vi.hoisted(() => ({ | |
| 15 | create: vi.fn<(options: unknown) => Promise<unknown>>(), | |
| 16 | send: vi.fn<(prompt: string) => Promise<unknown>>(), | |
| 17 | close: vi.fn(), | |
| 18 | cancel: vi.fn(async () => {}), | |
| 19 | prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({ | |
| 20 | id: "run-cursor-text-generation-test", | |
| 21 | status: "finished", | |
| 22 | result: | |
| 23 | '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}', | |
| 24 | })), | |
| 25 | })); | |
| 26 | ||
| 27 | vi.mock("../provider/cursorSdk.ts", () => ({ Agent: { create: cursorSdkMock.create } })); | |
| 28 | ||
| 29 | let hasCustomPolicy = false; | |
| 30 | const layerFs = FileSystem.layerNoop({ | |
| 31 | exists: () => Effect.succeed(hasCustomPolicy), | |
| 32 | makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"), | |
| 33 | }); | |
| 34 | ||
| 35 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); | |
| 36 | const cursorSettings = decodeCursorSettings({ enabled: true }); | |
| 37 | ||
| 38 | beforeEach(() => { | |
| 39 | hasCustomPolicy = false; | |
| 40 | cursorSdkMock.create.mockReset(); | |
| 41 | cursorSdkMock.send.mockReset(); | |
| 42 | cursorSdkMock.create.mockImplementation(async (options) => { | |
| 43 | cursorSdkMock.send.mockImplementation(async (prompt) => ({ | |
| 44 | status: "running", | |
| 45 | cancel: cursorSdkMock.cancel, | |
| 46 | wait: () => cursorSdkMock.prompt(prompt, options), | |
| 47 | })); | |
| 48 | return { | |
| 49 | close: cursorSdkMock.close, | |
| 50 | [Symbol.asyncDispose]: async () => { | |
| 51 | cursorSdkMock.close(); | |
| 52 | }, | |
| 53 | send: cursorSdkMock.send, | |
| 54 | }; | |
| 55 | }); | |
| 56 | cursorSdkMock.close.mockClear(); | |
| 57 | cursorSdkMock.cancel.mockClear(); | |
| 58 | cursorSdkMock.prompt.mockReset(); | |
| 59 | cursorSdkMock.prompt.mockResolvedValue({ | |
| 60 | id: "run-cursor-text-generation-test", | |
| 61 | status: "finished", | |
| 62 | result: | |
| 63 | '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}', | |
| 64 | }); | |
| 65 | }); | |
| 66 | ||
| 67 | describe("CursorTextGeneration", () => { | |
| 68 | it.effect("resolves the browser credential for every request after an account change", () => | |
| 69 | Effect.gen(function* () { | |
| 70 | let apiKey = "first-browser-key"; | |
| 71 | const generation = yield* makeCursorTextGeneration( | |
| 72 | cursorSettings, | |
| 73 | {}, | |
| 74 | Effect.sync(() => apiKey), | |
| 75 | ); | |
| 76 | const input = { | |
| 77 | cwd: process.cwd(), | |
| 78 | branch: "feature/cursor", | |
| 79 | stagedSummary: "M file.ts", | |
| 80 | stagedPatch: "diff", | |
| 81 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "auto"), | |
| 82 | }; | |
| 83 | yield* generation.generateCommitMessage(input); | |
| 84 | expect(cursorSdkMock.create).toHaveBeenLastCalledWith( | |
| 85 | expect.objectContaining({ apiKey: "first-browser-key" }), | |
| 86 | ); | |
| 87 | apiKey = "second-browser-key"; | |
| 88 | yield* generation.generateCommitMessage(input); | |
| 89 | expect(cursorSdkMock.create).toHaveBeenLastCalledWith( | |
| 90 | expect.objectContaining({ apiKey: "second-browser-key" }), | |
| 91 | ); | |
| 92 | }).pipe(Effect.provide(layerFs)), | |
| 93 | ); | |
| 94 | ||
| 95 | it.effect("uses the Cursor SDK prompt API with model parameters and API key", () => | |
| 96 | Effect.gen(function* () { | |
| 97 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { | |
| 98 | CURSOR_API_KEY: "test-cursor-key", | |
| 99 | }); | |
| 100 | ||
| 101 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 102 | cwd: process.cwd(), | |
| 103 | branch: "feature/cursor-text-generation", | |
| 104 | stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", | |
| 105 | stagedPatch: | |
| 106 | "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", | |
| 107 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ | |
| 108 | { id: "thinking", value: "high" }, | |
| 109 | { id: "contextWindow", value: "1m" }, | |
| 110 | { id: "fastMode", value: true }, | |
| 111 | ]), | |
| 112 | }); | |
| 113 | ||
| 114 | expect(generated.subject).toBe("Add generated commit message"); | |
| 115 | expect(generated.body).toBe("- verify cursor sdk text generation"); | |
| 116 | ||
| 117 | expect(cursorSdkMock.prompt).toHaveBeenCalledTimes(1); | |
| 118 | const [prompt, options] = ( | |
| 119 | cursorSdkMock.prompt.mock.calls as unknown as Array<[string, unknown]> | |
| 120 | )[0]!; | |
| 121 | expect(prompt).toContain("Staged patch:"); | |
| 122 | expect(options).toEqual({ | |
| 123 | apiKey: "test-cursor-key", | |
| 124 | mode: "plan", | |
| 125 | model: { | |
| 126 | id: "gpt-5.4", | |
| 127 | params: [ | |
| 128 | { id: "thinking", value: "high" }, | |
| 129 | { id: "context", value: "1m" }, | |
| 130 | { id: "fast", value: "true" }, | |
| 131 | ], | |
| 132 | }, | |
| 133 | local: { | |
| 134 | cwd: "/isolated-text-generation", | |
| 135 | autoReview: false, | |
| 136 | sandboxOptions: { enabled: true }, | |
| 137 | settingSources: [], | |
| 138 | enableAgentRetries: true, | |
| 139 | }, | |
| 140 | }); | |
| 141 | }).pipe(Effect.provide(layerFs)), | |
| 142 | ); | |
| 143 | ||
| 144 | it.effect("continues in the temp directory when the SDK cannot sandbox", () => | |
| 145 | Effect.gen(function* () { | |
| 146 | cursorSdkMock.create.mockImplementationOnce(async () => { | |
| 147 | throw new Error( | |
| 148 | "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.", | |
| 149 | ); | |
| 150 | }); | |
| 151 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { | |
| 152 | CURSOR_API_KEY: "test-cursor-key", | |
| 153 | }); | |
| 154 | ||
| 155 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 156 | cwd: process.cwd(), | |
| 157 | branch: "feature/cursor-text-generation", | |
| 158 | stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", | |
| 159 | stagedPatch: "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts", | |
| 160 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), | |
| 161 | }); | |
| 162 | ||
| 163 | expect(generated.subject).toBe("Add generated commit message"); | |
| 164 | expect(cursorSdkMock.create).toHaveBeenCalledTimes(2); | |
| 165 | expect(cursorSdkMock.create.mock.calls[0]?.[0]).toMatchObject({ | |
| 166 | local: { sandboxOptions: { enabled: true } }, | |
| 167 | }); | |
| 168 | expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({ | |
| 169 | local: { | |
| 170 | cwd: "/isolated-text-generation", | |
| 171 | autoReview: false, | |
| 172 | sandboxOptions: { enabled: false }, | |
| 173 | settingSources: [], | |
| 174 | enableAgentRetries: true, | |
| 175 | }, | |
| 176 | }); | |
| 177 | }).pipe(Effect.provide(layerFs)), | |
| 178 | ); | |
| 179 | ||
| 180 | it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () => | |
| 181 | Effect.gen(function* () { | |
| 182 | cursorSdkMock.create.mockImplementationOnce(async () => { | |
| 183 | throw new Error("Cursor SDK network down"); | |
| 184 | }); | |
| 185 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { | |
| 186 | CURSOR_API_KEY: "test-cursor-key", | |
| 187 | }); | |
| 188 | ||
| 189 | const error = yield* Effect.flip( | |
| 190 | textGeneration.generateCommitMessage({ | |
| 191 | cwd: process.cwd(), | |
| 192 | branch: "feature/cursor-text-generation", | |
| 193 | stagedSummary: "M README.md", | |
| 194 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 195 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), | |
| 196 | }), | |
| 197 | ); | |
| 198 | ||
| 199 | expect(error.detail).toBe("Cursor SDK text generation failed."); | |
| 200 | expect(cursorSdkMock.create).toHaveBeenCalledTimes(1); | |
| 201 | }).pipe(Effect.provide(layerFs)), | |
| 202 | ); | |
| 203 | ||
| 204 | it.effect("accepts json objects with extra assistant text around them", () => | |
| 205 | Effect.gen(function* () { | |
| 206 | cursorSdkMock.prompt.mockResolvedValueOnce({ | |
| 207 | id: "run-cursor-text-generation-test", | |
| 208 | status: "finished", | |
| 209 | result: | |
| 210 | 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', | |
| 211 | }); | |
| 212 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { | |
| 213 | CURSOR_API_KEY: "test-cursor-key", | |
| 214 | }); | |
| 215 | ||
| 216 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 217 | cwd: process.cwd(), | |
| 218 | branch: "feature/cursor-noisy-json", | |
| 219 | stagedSummary: "M README.md", | |
| 220 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 221 | modelSelection: { | |
| 222 | instanceId: ProviderInstanceId.make("cursor"), | |
| 223 | model: "composer-2", | |
| 224 | }, | |
| 225 | }); | |
| 226 | ||
| 227 | expect(generated.subject).toBe("Update README dummy comment with attribution and date"); | |
| 228 | expect(generated.body).toBe(""); | |
| 229 | }).pipe(Effect.provide(layerFs)), | |
| 230 | ); | |
| 231 | ||
| 232 | it.effect("generates thread titles through Cursor SDK text generation", () => | |
| 233 | Effect.gen(function* () { | |
| 234 | cursorSdkMock.prompt.mockResolvedValueOnce({ | |
| 235 | id: "run-cursor-title-generation-test", | |
| 236 | status: "finished", | |
| 237 | result: '{"title":"\\"Trim reconnect spinner status after resume.\\""}', | |
| 238 | }); | |
| 239 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { | |
| 240 | CURSOR_API_KEY: "test-cursor-key", | |
| 241 | }); | |
| 242 | ||
| 243 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 244 | cwd: process.cwd(), | |
| 245 | message: "Fix the reconnect spinner after a resumed session.", | |
| 246 | modelSelection: { | |
| 247 | instanceId: ProviderInstanceId.make("cursor"), | |
| 248 | model: "composer-2", | |
| 249 | }, | |
| 250 | }); | |
| 251 | ||
| 252 | expect(generated.title).toBe("Trim reconnect spinner status after resume."); | |
| 253 | }).pipe(Effect.provide(layerFs)), | |
| 254 | ); | |
| 255 | ||
| 256 | it.effect.each(["error", "cancelled"] as const)( | |
| 257 | "rejects a %s Cursor SDK run that includes valid title JSON", | |
| 258 | (status) => | |
| 259 | Effect.gen(function* () { | |
| 260 | const promptResult = { | |
| 261 | id: "run-cursor-partial-title-test", | |
| 262 | status, | |
| 263 | result: '{"title":"Partial title from a failed run."}', | |
| 264 | } satisfies RunResult; | |
| 265 | cursorSdkMock.prompt.mockResolvedValueOnce(promptResult); | |
| 266 | const generation = yield* makeCursorTextGeneration(cursorSettings, { | |
| 267 | CURSOR_API_KEY: "test-cursor-key", | |
| 268 | }); | |
| 269 | const failure = yield* Effect.flip( | |
| 270 | generation.generateThreadTitle({ | |
| 271 | cwd: process.cwd(), | |
| 272 | message: "Fix the reconnect spinner after a resumed session.", | |
| 273 | modelSelection: { | |
| 274 | instanceId: ProviderInstanceId.make("cursor"), | |
| 275 | model: "composer-2", | |
| 276 | }, | |
| 277 | }), | |
| 278 | ); | |
| 279 | expect(failure).toBeInstanceOf(TextGenerationError); | |
| 280 | expect(failure.operation).toBe("generateThreadTitle"); | |
| 281 | expect(failure.detail).toBe( | |
| 282 | status === "cancelled" | |
| 283 | ? "Cursor SDK request was cancelled." | |
| 284 | : "Cursor SDK request finished with an error.", | |
| 285 | ); | |
| 286 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); | |
| 287 | }).pipe(Effect.provide(layerFs)), | |
| 288 | ); | |
| 289 | ||
| 290 | it.effect("fails closed when ambient sandbox policy can expand write access", () => | |
| 291 | Effect.gen(function* () { | |
| 292 | hasCustomPolicy = true; | |
| 293 | const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" }); | |
| 294 | const failure = yield* Effect.flip( | |
| 295 | generation.generateThreadTitle({ | |
| 296 | cwd: "/real-workspace", | |
| 297 | message: "Title", | |
| 298 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, | |
| 299 | }), | |
| 300 | ); | |
| 301 | expect(failure.detail).toContain("custom ~/.cursor/sandbox.json"); | |
| 302 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); | |
| 303 | }).pipe(Effect.provide(layerFs)), | |
| 304 | ); | |
| 305 | ||
| 306 | it.effect("cancels the native run when text generation times out", () => | |
| 307 | Effect.gen(function* () { | |
| 308 | let started!: () => void; | |
| 309 | const called = new Promise<void>((resolve) => { | |
| 310 | started = resolve; | |
| 311 | }); | |
| 312 | cursorSdkMock.prompt.mockImplementationOnce(() => { | |
| 313 | started(); | |
| 314 | return new Promise(() => {}); | |
| 315 | }); | |
| 316 | const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" }); | |
| 317 | const result = yield* generation | |
| 318 | .generateThreadTitle({ | |
| 319 | cwd: "/real-workspace", | |
| 320 | message: "Title", | |
| 321 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, | |
| 322 | }) | |
| 323 | .pipe(Effect.flip, Effect.forkScoped); | |
| 324 | yield* Effect.promise(() => called); | |
| 325 | yield* TestClock.adjust("180 seconds"); | |
| 326 | expect((yield* Fiber.join(result)).detail).toContain("timed out"); | |
| 327 | expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); | |
| 328 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); | |
| 329 | }).pipe(Effect.provide(layerFs), Effect.scoped), | |
| 330 | ); | |
| 331 | ||
| 332 | it.effect.each(["create", "send"] as const)( | |
| 333 | "times out pending %s and releases its late SDK resource", | |
| 334 | (phase) => | |
| 335 | Effect.gen(function* () { | |
| 336 | let started!: () => void; | |
| 337 | const called = new Promise<void>((resolve) => { | |
| 338 | started = resolve; | |
| 339 | }); | |
| 340 | let resolveLate!: (resource: unknown) => void; | |
| 341 | const pending = new Promise<unknown>((resolve) => { | |
| 342 | resolveLate = resolve; | |
| 343 | }); | |
| 344 | const wait = vi.fn(); | |
| 345 | const agent = { | |
| 346 | close: cursorSdkMock.close, | |
| 347 | [Symbol.asyncDispose]: async () => { | |
| 348 | cursorSdkMock.close(); | |
| 349 | }, | |
| 350 | send: async () => { | |
| 351 | started(); | |
| 352 | return pending; | |
| 353 | }, | |
| 354 | }; | |
| 355 | cursorSdkMock.create.mockImplementation(async () => { | |
| 356 | if (phase === "create") { | |
| 357 | started(); | |
| 358 | return pending; | |
| 359 | } | |
| 360 | return agent; | |
| 361 | }); | |
| 362 | const generation = yield* makeCursorTextGeneration(cursorSettings, { | |
| 363 | CURSOR_API_KEY: "key", | |
| 364 | }); | |
| 365 | const result = yield* generation | |
| 366 | .generateThreadTitle({ | |
| 367 | cwd: "/real-workspace", | |
| 368 | message: "Title", | |
| 369 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, | |
| 370 | }) | |
| 371 | .pipe(Effect.flip, Effect.forkScoped); | |
| 372 | yield* Effect.promise(() => called); | |
| 373 | yield* TestClock.adjust("180 seconds"); | |
| 374 | expect((yield* Fiber.join(result)).detail).toContain("timed out"); | |
| 375 | let cleanup!: () => void; | |
| 376 | const cleaned = new Promise<void>((resolve) => { | |
| 377 | cleanup = resolve; | |
| 378 | }); | |
| 379 | if (phase === "create") cursorSdkMock.close.mockImplementationOnce(cleanup); | |
| 380 | else | |
| 381 | cursorSdkMock.cancel.mockImplementationOnce(async () => { | |
| 382 | cleanup(); | |
| 383 | }); | |
| 384 | resolveLate( | |
| 385 | phase === "create" ? agent : { status: "running", cancel: cursorSdkMock.cancel, wait }, | |
| 386 | ); | |
| 387 | yield* Effect.promise(() => cleaned); | |
| 388 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); | |
| 389 | expect(wait).not.toHaveBeenCalled(); | |
| 390 | if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); | |
| 391 | }).pipe(Effect.provide(layerFs), Effect.scoped), | |
| 392 | ); | |
| 393 | ||
| 394 | it.effect("requires CURSOR_API_KEY before calling the SDK", () => | |
| 395 | Effect.gen(function* () { | |
| 396 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {}); | |
| 397 | ||
| 398 | const error = yield* Effect.flip( | |
| 399 | textGeneration.generateCommitMessage({ | |
| 400 | cwd: process.cwd(), | |
| 401 | branch: "feature/cursor-api-key", | |
| 402 | stagedSummary: "M README.md", | |
| 403 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 404 | modelSelection: { | |
| 405 | instanceId: ProviderInstanceId.make("cursor"), | |
| 406 | model: "composer-2", | |
| 407 | }, | |
| 408 | }), | |
| 409 | ); | |
| 410 | ||
| 411 | expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings."); | |
| 412 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); | |
| 413 | }).pipe(Effect.provide(layerFs)), | |
| 414 | ); | |
| 415 | }); |
apps/server/src/textGeneration/CursorTextGeneration.ts deleted-204| ... | ... | @@ -1,204 +0,0 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | ||
| 4 | import type { AgentOptions, RunResult } from "@cursor/sdk"; | |
| 5 | import { Agent } from "../provider/cursorSdk.ts"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | ||
| 10 | import { | |
| 11 | type CursorSettings, | |
| 12 | type ProviderSetupError, | |
| 13 | TextGenerationError, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | ||
| 16 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | |
| 17 | import { cursorSdkModelSelection } from "../provider/cursorSdkModel.ts"; | |
| 18 | import type { CursorAuth } from "../provider/CursorAuth.ts"; | |
| 19 | ||
| 20 | const CURSOR_TIMEOUT_MS = 180_000; | |
| 21 | ||
| 22 | const isTextGenerationError = Schema.is(TextGenerationError); | |
| 23 | ||
| 24 | function cursorSdkResultDetail(result: RunResult): string { | |
| 25 | switch (result.status) { | |
| 26 | case "cancelled": | |
| 27 | return "Cursor SDK request was cancelled."; | |
| 28 | case "error": | |
| 29 | return "Cursor SDK request finished with an error."; | |
| 30 | case "finished": | |
| 31 | return "Cursor SDK returned empty output."; | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | /** | |
| 36 | * The SDK throws this when `sandboxOptions.enabled` is set and local sandboxing | |
| 37 | * is unavailable. That happens on hosts that cannot launch `cursorsandbox`, and | |
| 38 | * also after an unsandboxed run caches "unsupported" for the process. | |
| 39 | */ | |
| 40 | function cursorSandboxUnsupported(cause: unknown): boolean { | |
| 41 | const seen = new Set<object>(); | |
| 42 | let current = cause; | |
| 43 | while (typeof current === "object" && current !== null && !seen.has(current)) { | |
| 44 | seen.add(current); | |
| 45 | if (current instanceof Error && current.message.includes("sandboxing is not supported")) { | |
| 46 | return true; | |
| 47 | } | |
| 48 | current = Reflect.get(current, "cause"); | |
| 49 | } | |
| 50 | return false; | |
| 51 | } | |
| 52 | ||
| 53 | /** | |
| 54 | * Build a Cursor text-generation closure bound to a specific `CursorSettings` | |
| 55 | * payload. See `makeCodexAdapter` for the overall per-instance rationale. | |
| 56 | */ | |
| 57 | export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( | |
| 58 | cursorSettings: CursorSettings, | |
| 59 | environment?: NodeJS.ProcessEnv, | |
| 60 | resolveApiKey?: Effect.Effect<string, ProviderSetupError>, | |
| 61 | withAccess?: CursorAuth["withAccess"], | |
| 62 | ) { | |
| 63 | const fs = yield* FileSystem.FileSystem; | |
| 64 | const resolvedEnvironment = environment ?? process.env; | |
| 65 | ||
| 66 | const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) => | |
| 67 | Effect.gen(function* () { | |
| 68 | if (!cursorSettings.enabled) { | |
| 69 | return yield* new TextGenerationError({ | |
| 70 | operation, | |
| 71 | detail: "Cursor is disabled in T3 Code settings.", | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | const apiKey = resolveApiKey | |
| 76 | ? yield* resolveApiKey | |
| 77 | : resolvedEnvironment.CURSOR_API_KEY?.trim(); | |
| 78 | if (!apiKey) { | |
| 79 | return yield* new TextGenerationError({ | |
| 80 | operation, | |
| 81 | detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | |
| 82 | }); | |
| 83 | } | |
| 84 | ||
| 85 | return apiKey; | |
| 86 | }); | |
| 87 | ||
| 88 | // Ignores `cwd`: the agent runs in an empty temp directory, away from the project. | |
| 89 | const runCursorJson: TextGenerationOperations.Runner = (input) => { | |
| 90 | const { operation, prompt, modelSelection } = input; | |
| 91 | return Effect.gen(function* () { | |
| 92 | const apiKey = yield* resolveCursorApiKey(operation); | |
| 93 | // The SDK loads sandbox.json independently of settingSources and lets it | |
| 94 | // expand the writable paths. Its public API cannot override that policy. | |
| 95 | if (yield* fs.exists(`${NodeOS.homedir()}/.cursor/sandbox.json`)) { | |
| 96 | return yield* new TextGenerationError({ | |
| 97 | operation, | |
| 98 | detail: | |
| 99 | "Cursor text generation cannot enforce workspace isolation with a custom ~/.cursor/sandbox.json. Use another text-generation provider.", | |
| 100 | }); | |
| 101 | } | |
| 102 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" }); | |
| 103 | const agentOptions = { | |
| 104 | apiKey, | |
| 105 | mode: "plan", | |
| 106 | model: cursorSdkModelSelection(modelSelection), | |
| 107 | local: { | |
| 108 | cwd, | |
| 109 | autoReview: false, | |
| 110 | sandboxOptions: { enabled: true }, | |
| 111 | settingSources: [], | |
| 112 | enableAgentRetries: true, | |
| 113 | }, | |
| 114 | } satisfies AgentOptions; | |
| 115 | const createCursorAgent = (sandboxEnabled: boolean) => | |
| 116 | Effect.tryPromise((signal) => | |
| 117 | Agent.create( | |
| 118 | sandboxEnabled | |
| 119 | ? agentOptions | |
| 120 | : { | |
| 121 | ...agentOptions, | |
| 122 | local: { | |
| 123 | ...agentOptions.local, | |
| 124 | sandboxOptions: { enabled: false }, | |
| 125 | }, | |
| 126 | }, | |
| 127 | ).then((agent) => { | |
| 128 | if (signal.aborted) agent.close(); | |
| 129 | return agent; | |
| 130 | }), | |
| 131 | ); | |
| 132 | ||
| 133 | const request = Effect.gen(function* () { | |
| 134 | // Prefer the sandbox. When the SDK refuses it, the empty temp directory | |
| 135 | // and empty setting sources still keep this run off the user's project. | |
| 136 | const agent = yield* Effect.acquireRelease( | |
| 137 | createCursorAgent(true).pipe( | |
| 138 | Effect.catchIf(cursorSandboxUnsupported, () => createCursorAgent(false)), | |
| 139 | ), | |
| 140 | (agent) => | |
| 141 | Effect.tryPromise(() => agent[Symbol.asyncDispose]()).pipe( | |
| 142 | Effect.timeout("5 seconds"), | |
| 143 | Effect.ignore({ log: true }), | |
| 144 | ), | |
| 145 | { interruptible: true }, | |
| 146 | ); | |
| 147 | const run = yield* Effect.tryPromise((signal) => | |
| 148 | agent.send(prompt).then((run) => { | |
| 149 | if (signal.aborted) void run.cancel().catch(() => undefined); | |
| 150 | return run; | |
| 151 | }), | |
| 152 | ); | |
| 153 | yield* Effect.addFinalizer(() => | |
| 154 | run.status === "running" | |
| 155 | ? Effect.tryPromise(() => run.cancel()).pipe( | |
| 156 | Effect.timeout("5 seconds"), | |
| 157 | Effect.ignore({ log: true }), | |
| 158 | ) | |
| 159 | : Effect.void, | |
| 160 | ); | |
| 161 | return yield* Effect.tryPromise(() => run.wait()); | |
| 162 | }).pipe(Effect.scoped); | |
| 163 | const promptResult = yield* request.pipe( | |
| 164 | Effect.timeoutOption(CURSOR_TIMEOUT_MS), | |
| 165 | Effect.flatMap( | |
| 166 | Option.match({ | |
| 167 | onNone: () => | |
| 168 | Effect.fail( | |
| 169 | new TextGenerationError({ | |
| 170 | operation, | |
| 171 | detail: "Cursor SDK request timed out.", | |
| 172 | }), | |
| 173 | ), | |
| 174 | onSome: (value) => Effect.succeed(value), | |
| 175 | }), | |
| 176 | ), | |
| 177 | ); | |
| 178 | ||
| 179 | const rawResult = promptResult.result?.trim() ?? ""; | |
| 180 | if (promptResult.status !== "finished" || !rawResult) { | |
| 181 | return yield* new TextGenerationError({ | |
| 182 | operation, | |
| 183 | detail: cursorSdkResultDetail(promptResult), | |
| 184 | }); | |
| 185 | } | |
| 186 | ||
| 187 | return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult); | |
| 188 | }).pipe( | |
| 189 | (effect) => (withAccess ? withAccess(effect) : effect), | |
| 190 | Effect.scoped, | |
| 191 | Effect.mapError((cause) => | |
| 192 | isTextGenerationError(cause) | |
| 193 | ? cause | |
| 194 | : new TextGenerationError({ | |
| 195 | operation, | |
| 196 | detail: "Cursor SDK text generation failed.", | |
| 197 | cause, | |
| 198 | }), | |
| 199 | ), | |
| 200 | ); | |
| 201 | }; | |
| 202 | ||
| 203 | return TextGenerationOperations.fromRunner("CursorTextGeneration", runCursorJson); | |
| 204 | }); |
apps/server/src/usage/cursorUsageReader.test.ts+1-1| ... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | import { CursorKeychainTimeoutError } from "../provider/cursorKeychainToken.ts"; | |
| 3 | import { CursorKeychainTimeoutError } from "@t3tools/provider-cursor/server"; | |
| 4 | 4 | import { readCursorAccountUsage } from "./cursorUsageReader.ts"; |
| 5 | 5 | |
| 6 | 6 | describe("readCursorAccountUsage", () => { |
apps/server/src/usage/cursorUsageReader.ts+1-1| ... | ... | @@ -12,7 +12,7 @@ import type { UsageRecord } from "./usageTranscripts.ts"; |
| 12 | 12 | import { |
| 13 | 13 | CursorKeychainTimeoutError, |
| 14 | 14 | readMacCursorAccessToken, |
| 15 | } from "../provider/cursorKeychainToken.ts"; | |
| 15 | } from "@t3tools/provider-cursor/server"; | |
| 16 | 16 | |
| 17 | 17 | function object(value: unknown): Record<string, unknown> { |
| 18 | 18 | return typeof value === "object" && value !== null && !Array.isArray(value) |
apps/web/package.json+1| ... | ... | @@ -29,6 +29,7 @@ |
| 29 | 29 | "@t3tools/client-runtime": "workspace:*", |
| 30 | 30 | "@t3tools/contracts": "workspace:*", |
| 31 | 31 | "@t3tools/provider-core": "workspace:*", |
| 32 | "@t3tools/provider-cursor": "workspace:*", | |
| 32 | 33 | "@t3tools/provider-muse": "workspace:*", |
| 33 | 34 | "@t3tools/provider-opencode": "workspace:*", |
| 34 | 35 | "@t3tools/provider-pi": "workspace:*", |
apps/web/src/components/chat/ProviderInstanceIcon.tsx+1-3| ... | ... | @@ -3,7 +3,7 @@ import { type CSSProperties, memo } from "react"; |
| 3 | 3 | import { providerInstanceInitials } from "@t3tools/client-runtime/state/provider-instance-display"; |
| 4 | 4 | |
| 5 | 5 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 6 | import { AntigravityIcon, ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI } from "../Icons"; | |
| 6 | import { AntigravityIcon, ClaudeAI, GrokIcon, Icon, OpenAI } from "../Icons"; | |
| 7 | 7 | |
| 8 | 8 | import { cn } from "~/lib/utils"; |
| 9 | 9 | import { providerClients } from "../settings/providerDriverMeta"; |
| ... | ... | @@ -17,7 +17,6 @@ import { |
| 17 | 17 | const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { |
| 18 | 18 | [ProviderDriverKind.make("codex")]: OpenAI, |
| 19 | 19 | [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, |
| 20 | [ProviderDriverKind.make("cursor")]: CursorIcon, | |
| 21 | 20 | [ProviderDriverKind.make("grok")]: GrokIcon, |
| 22 | 21 | [ProviderDriverKind.make("antigravity")]: AntigravityIcon, |
| 23 | 22 | }; |
| ... | ... | @@ -25,7 +24,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial<Record<ProviderDriverKind, Icon>> = { |
| 25 | 24 | const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial<Record<ProviderDriverKind, string>> = { |
| 26 | 25 | [ProviderDriverKind.make("codex")]: "text-black dark:text-white", |
| 27 | 26 | [ProviderDriverKind.make("claudeAgent")]: "text-[#d97757]", |
| 28 | [ProviderDriverKind.make("cursor")]: "text-[#26251E] dark:text-[#EDECEC]", | |
| 29 | 27 | [ProviderDriverKind.make("grok")]: "text-[#0F0F0F] dark:text-[#F5F5F5]", |
| 30 | 28 | [ProviderDriverKind.make("antigravity")]: "text-[#5b87bf]", |
| 31 | 29 | }; |
apps/web/src/components/settings/providerDriverMeta.ts+2-15| ... | ... | @@ -3,11 +3,11 @@ import { |
| 3 | 3 | AntigravitySettings, |
| 4 | 4 | ClaudeSettings, |
| 5 | 5 | CodexSettings, |
| 6 | CursorSettings, | |
| 7 | 6 | GrokSettings, |
| 8 | 7 | ProviderDriverKind, |
| 9 | 8 | } from "@t3tools/contracts"; |
| 10 | 9 | import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; |
| 10 | import { cursorClient } from "@t3tools/provider-cursor/client"; | |
| 11 | 11 | import { museClient } from "@t3tools/provider-muse/client"; |
| 12 | 12 | import { openCodeClient } from "@t3tools/provider-opencode/client"; |
| 13 | 13 | import { piClient } from "@t3tools/provider-pi/client"; |
| ... | ... | @@ -24,20 +24,7 @@ export const providerClients = makeProviderClientRegistry([ |
| 24 | 24 | label: "Claude", |
| 25 | 25 | settingsSchema: ClaudeSettings, |
| 26 | 26 | }, |
| 27 | { | |
| 28 | driverKind: ProviderDriverKind.make("cursor"), | |
| 29 | label: "Cursor", | |
| 30 | settingsSchema: CursorSettings, | |
| 31 | environmentFields: [ | |
| 32 | { | |
| 33 | name: "CURSOR_API_KEY", | |
| 34 | label: "Cursor API key", | |
| 35 | description: "Optional. Overrides browser sign-in for this provider.", | |
| 36 | placeholder: "Paste API key", | |
| 37 | sensitive: true, | |
| 38 | }, | |
| 39 | ], | |
| 40 | }, | |
| 27 | cursorClient, | |
| 41 | 28 | { |
| 42 | 29 | driverKind: ProviderDriverKind.make("grok"), |
| 43 | 30 | label: "Grok", |
docs/internals/adding-a-provider.md+1-1| ... | ... | @@ -115,7 +115,7 @@ A provider lives in its own `packages/provider-<name>` package, with |
| 115 | 115 | Settings live in `providerInstances`; new providers are off by default. |
| 116 | 116 | - **Package boundaries.** Server code imports only `@t3tools/provider-core/server/*`, contracts, |
| 117 | 117 | and shared. It reaches the server through `ProviderHost` (paths, settings, background demand, |
| 118 | attachments), never through `apps/server`. Tests use `@t3tools/provider-testing`. | |
| 118 | attachments, stored credentials), never through `apps/server`. Tests use `@t3tools/provider-testing`. | |
| 119 | 119 | - **Server registration:** the driver's entry in |
| 120 | 120 | [`builtInDrivers.ts`](../../apps/server/src/provider/builtInDrivers.ts) and the adapter driver's in |
| 121 | 121 | [`builtInProviderAdapterDrivers.ts`](../../apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts). |
knip.jsonc+3-1| ... | ... | @@ -36,7 +36,9 @@ |
| 36 | 36 | ], |
| 37 | 37 | // Keep the transitive Effect runtime pinned for standalone npm installs. |
| 38 | 38 | // The Vite+ web build prerequisite. |
| 39 | "ignoreDependencies": ["@effect/platform-node-shared", "@t3tools/web"], | |
| 39 | // Native and disk-loaded provider packages stay external to the bundle; the | |
| 40 | // CLI archive and desktop sidecar stage them from this manifest. | |
| 41 | "ignoreDependencies": ["@effect/platform-node-shared", "@t3tools/web", "@napi-rs/keyring"], | |
| 40 | 42 | }, |
| 41 | 43 | "apps/desktop": { |
| 42 | 44 | // Electron loads these bundles by filename rather than importing them. |
package.json+1-1| ... | ... | @@ -28,7 +28,7 @@ |
| 28 | 28 | "tc": "vp run -r --concurrency-limit 2 typecheck", |
| 29 | 29 | "lint": "vp lint --report-unused-disable-directives", |
| 30 | 30 | "knip": "knip --preprocessor ./scripts/knip-schemas.ts", |
| 31 | "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", | |
| 31 | "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", | |
| 32 | 32 | "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", |
| 33 | 33 | "lint:mobile": "node scripts/mobile-native-static-check.ts", |
| 34 | 34 | "test": "vp run -r test", |
packages/contracts/src/settings.ts-26| ... | ... | @@ -707,32 +707,6 @@ export const ClaudeSettings = makeProviderSettingsSchema( |
| 707 | 707 | ); |
| 708 | 708 | export type ClaudeSettings = typeof ClaudeSettings.Type; |
| 709 | 709 | |
| 710 | export const CursorSettings = makeProviderSettingsSchema( | |
| 711 | { | |
| 712 | // Off by default like Grok and OpenCode. Users opt in from Settings. | |
| 713 | enabled: Schema.Boolean.pipe( | |
| 714 | Schema.withDecodingDefault(Effect.succeed(false)), | |
| 715 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 716 | ), | |
| 717 | // Keep V1's CLI configuration when V2 rewrites the shared settings file. | |
| 718 | // V2's Cursor SDK does not use these fields. | |
| 719 | binaryPath: Schema.optionalKey(TrimmedString).pipe( | |
| 720 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 721 | ), | |
| 722 | apiEndpoint: Schema.optionalKey(TrimmedString).pipe( | |
| 723 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 724 | ), | |
| 725 | customModels: Schema.Array(CustomModelSetting).pipe( | |
| 726 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 727 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 728 | ), | |
| 729 | }, | |
| 730 | { | |
| 731 | order: [], | |
| 732 | }, | |
| 733 | ); | |
| 734 | export type CursorSettings = typeof CursorSettings.Type; | |
| 735 | ||
| 736 | 710 | export const GrokSettings = makeProviderSettingsSchema( |
| 737 | 711 | { |
| 738 | 712 | // Off by default (like Cursor and OpenCode): the binding is not yet |
packages/provider-core/src/server/ProviderHost.ts+17| ... | ... | @@ -17,9 +17,12 @@ import type { |
| 17 | 17 | } from "@t3tools/contracts"; |
| 18 | 18 | import * as Context from "effect/Context"; |
| 19 | 19 | import type * as Effect from "effect/Effect"; |
| 20 | import type * as Option from "effect/Option"; | |
| 20 | 21 | import type * as Scope from "effect/Scope"; |
| 21 | 22 | import type * as Stream from "effect/Stream"; |
| 22 | 23 | |
| 24 | import type { ProviderCredentialError } from "./errors.ts"; | |
| 25 | ||
| 23 | 26 | export interface ProviderHostPaths { |
| 24 | 27 | /** Directory the server was started in; the fallback cwd for sessions without a project. */ |
| 25 | 28 | readonly cwd: string; |
| ... | ... | @@ -31,6 +34,15 @@ export interface ProviderHostPaths { |
| 31 | 34 | readonly providerStatusCacheDir: string; |
| 32 | 35 | } |
| 33 | 36 | |
| 37 | /** One opaque credential blob in the server's secret store. Only the provider decodes it. */ | |
| 38 | export interface ProviderCredentials { | |
| 39 | /** Instances whose bindings are equal share these credentials. */ | |
| 40 | readonly binding: { readonly owner: "t3"; readonly key: string }; | |
| 41 | readonly get: Effect.Effect<Option.Option<Uint8Array>, ProviderCredentialError>; | |
| 42 | readonly set: (credentials: Uint8Array) => Effect.Effect<void, ProviderCredentialError>; | |
| 43 | readonly remove: Effect.Effect<void, ProviderCredentialError>; | |
| 44 | } | |
| 45 | ||
| 34 | 46 | export interface ProviderHostShape { |
| 35 | 47 | readonly paths: ProviderHostPaths; |
| 36 | 48 | readonly settings: { |
| ... | ... | @@ -47,6 +59,11 @@ export interface ProviderHostShape { |
| 47 | 59 | * resolve inside the attachments directory. |
| 48 | 60 | */ |
| 49 | 61 | readonly resolveAttachmentPath: (attachment: ChatAttachment) => string | null; |
| 62 | /** The credentials stored for `bindingId`, usually an instance id, under a provider-chosen namespace. */ | |
| 63 | readonly credentials: ( | |
| 64 | namespace: string, | |
| 65 | bindingId: string, | |
| 66 | ) => Effect.Effect<ProviderCredentials>; | |
| 50 | 67 | } |
| 51 | 68 | |
| 52 | 69 | export class ProviderHost extends Context.Service<ProviderHost, ProviderHostShape>()( |
packages/provider-core/src/server/errors.ts+13| ... | ... | @@ -19,3 +19,16 @@ export class ProviderDriverError extends Schema.TaggedError<ProviderDriverError> |
| 19 | 19 | return `Provider driver '${this.driver}' failed to create instance '${this.instanceId}': ${this.detail}`; |
| 20 | 20 | } |
| 21 | 21 | } |
| 22 | ||
| 23 | /** Reading or writing a provider's stored credentials failed. */ | |
| 24 | export class ProviderCredentialError extends Schema.TaggedError<ProviderCredentialError>()( | |
| 25 | "ProviderCredentialError", | |
| 26 | { | |
| 27 | operation: Schema.Literals(["get", "set", "remove"]), | |
| 28 | cause: Schema.Defect(), | |
| 29 | }, | |
| 30 | ) { | |
| 31 | override get message(): string { | |
| 32 | return `Could not ${this.operation} stored provider credentials.`; | |
| 33 | } | |
| 34 | } |
packages/provider-core/src/server/managedProvider.test.ts+1| ... | ... | @@ -117,6 +117,7 @@ function layerProviderHost(input: { |
| 117 | 117 | }, |
| 118 | 118 | shouldRunBackgroundWork: () => Effect.succeed(input.runBackgroundWork), |
| 119 | 119 | resolveAttachmentPath: () => null, |
| 120 | credentials: () => Effect.die("unused"), | |
| 120 | 121 | }), |
| 121 | 122 | ); |
| 122 | 123 | } |
packages/provider-cursor/package.json created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | { | |
| 2 | "name": "@t3tools/provider-cursor", | |
| 3 | "private": true, | |
| 4 | "type": "module", | |
| 5 | "exports": { | |
| 6 | "./client": { | |
| 7 | "types": "./src/client.ts", | |
| 8 | "import": "./src/client.ts" | |
| 9 | }, | |
| 10 | "./settings": { | |
| 11 | "types": "./src/settings.ts", | |
| 12 | "import": "./src/settings.ts" | |
| 13 | }, | |
| 14 | "./server": { | |
| 15 | "types": "./src/server.ts", | |
| 16 | "import": "./src/server.ts" | |
| 17 | }, | |
| 18 | "./testing": { | |
| 19 | "types": "./src/testing.ts", | |
| 20 | "import": "./src/testing.ts" | |
| 21 | }, | |
| 22 | "./server/CursorAgentSdk": { | |
| 23 | "types": "./src/server/CursorAgentSdk.ts", | |
| 24 | "import": "./src/server/CursorAgentSdk.ts" | |
| 25 | } | |
| 26 | }, | |
| 27 | "scripts": { | |
| 28 | "typecheck": "tsc --noEmit", | |
| 29 | "test": "vp test run --config ../../vite.config.ts --dir ." | |
| 30 | }, | |
| 31 | "dependencies": { | |
| 32 | "@cursor/sdk": "1.0.35", | |
| 33 | "@napi-rs/keyring": "^1.3.0", | |
| 34 | "@t3tools/contracts": "workspace:*", | |
| 35 | "@t3tools/provider-core": "workspace:*", | |
| 36 | "@t3tools/shared": "workspace:*", | |
| 37 | "effect": "catalog:", | |
| 38 | "yaml": "catalog:" | |
| 39 | }, | |
| 40 | "devDependencies": { | |
| 41 | "@effect/platform-node": "catalog:", | |
| 42 | "@effect/vitest": "catalog:", | |
| 43 | "@t3tools/provider-testing": "workspace:*", | |
| 44 | "@types/node": "catalog:", | |
| 45 | "vite-plus": "catalog:" | |
| 46 | } | |
| 47 | } |
packages/provider-cursor/src/client.ts created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | /** | |
| 2 | * Cursor's client definition: settings form, label, sign-in environment | |
| 3 | * field, and glyph. Browser- and React Native-safe. | |
| 4 | * | |
| 5 | * @module provider-cursor/client | |
| 6 | */ | |
| 7 | import { ProviderDriverKind } from "@t3tools/contracts"; | |
| 8 | import { defineProviderClient } from "@t3tools/provider-core/client"; | |
| 9 | ||
| 10 | import { CursorSettings } from "./settings.ts"; | |
| 11 | ||
| 12 | export const cursorClient = defineProviderClient({ | |
| 13 | driverKind: ProviderDriverKind.make("cursor"), | |
| 14 | label: "Cursor", | |
| 15 | settingsSchema: CursorSettings, | |
| 16 | environmentFields: [ | |
| 17 | { | |
| 18 | name: "CURSOR_API_KEY", | |
| 19 | label: "Cursor API key", | |
| 20 | description: "Optional. Overrides browser sign-in for this provider.", | |
| 21 | placeholder: "Paste API key", | |
| 22 | sensitive: true, | |
| 23 | }, | |
| 24 | ], | |
| 25 | icon: { | |
| 26 | viewBox: "0 0 466.73 532.09", | |
| 27 | fill: { light: "#26251E", dark: "#EDECEC" }, | |
| 28 | paths: [ | |
| 29 | { | |
| 30 | 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", | |
| 31 | }, | |
| 32 | ], | |
| 33 | }, | |
| 34 | }); |
packages/provider-cursor/src/server.ts created+10| ... | ... | @@ -0,0 +1,10 @@ |
| 1 | /** | |
| 2 | * Cursor's server entry: the driver the server registers, its adapter | |
| 3 | * driver, the SDK runner layer the server provides once, and the keychain | |
| 4 | * token reader the usage scanner shares. | |
| 5 | * | |
| 6 | * @module provider-cursor/server | |
| 7 | */ | |
| 8 | export { CursorDriver, type CursorDriverEnv } from "./server/driver.ts"; | |
| 9 | export { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./server/adapter.ts"; | |
| 10 | export { CursorKeychainTimeoutError, readMacCursorAccessToken } from "./server/keychainToken.ts"; |
packages/provider-cursor/src/server/CursorAgentSdk.test.ts created+304| ... | ... | @@ -0,0 +1,304 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import { vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | |
| 9 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | |
| 10 | ||
| 11 | const cursorSdkMock = vi.hoisted(() => { | |
| 12 | const closeExecutionOrder: Array<string> = []; | |
| 13 | const runOperations: Array<string> = []; | |
| 14 | let agentCloseFailure: unknown; | |
| 15 | let staleRunActive = false; | |
| 16 | const runWait = vi.fn(async () => ({ | |
| 17 | id: "run-cursor-agent-sdk-test", | |
| 18 | requestId: "request-cursor-agent-sdk-test", | |
| 19 | status: "finished", | |
| 20 | result: "ok", | |
| 21 | model: { id: "default" }, | |
| 22 | durationMs: 1, | |
| 23 | })); | |
| 24 | const runCancel = vi.fn(async () => {}); | |
| 25 | const agentClose = vi.fn(() => { | |
| 26 | closeExecutionOrder.push("sdk:agent.close"); | |
| 27 | if (agentCloseFailure !== undefined) { | |
| 28 | throw agentCloseFailure; | |
| 29 | } | |
| 30 | }); | |
| 31 | const send = vi.fn( | |
| 32 | async ( | |
| 33 | _message: unknown, | |
| 34 | options: { readonly onDelta?: (input: { readonly update: unknown }) => Promise<void> }, | |
| 35 | ) => { | |
| 36 | runOperations.push("run.start"); | |
| 37 | if (staleRunActive) { | |
| 38 | throw new Error("Agent agent-cursor-agent-sdk-test already has active run"); | |
| 39 | } | |
| 40 | await options.onDelta?.({ | |
| 41 | update: { | |
| 42 | type: "assistant-message-chunk", | |
| 43 | text: "hello", | |
| 44 | }, | |
| 45 | }); | |
| 46 | return { | |
| 47 | id: "run-cursor-agent-sdk-test", | |
| 48 | agentId: "agent-cursor-agent-sdk-test", | |
| 49 | wait: runWait, | |
| 50 | cancel: runCancel, | |
| 51 | }; | |
| 52 | }, | |
| 53 | ); | |
| 54 | const create = vi.fn(async () => ({ | |
| 55 | agentId: "agent-cursor-agent-sdk-test", | |
| 56 | send, | |
| 57 | close: agentClose, | |
| 58 | })); | |
| 59 | const resume = vi.fn(async () => ({ | |
| 60 | agentId: "agent-cursor-agent-sdk-test", | |
| 61 | send, | |
| 62 | close: agentClose, | |
| 63 | })); | |
| 64 | const listRuns = vi.fn(async () => { | |
| 65 | runOperations.push("agent.listRuns"); | |
| 66 | return { | |
| 67 | items: staleRunActive ? [{ id: "run-stale", status: "running" }] : [], | |
| 68 | }; | |
| 69 | }); | |
| 70 | const cancelRun = vi.fn(async () => { | |
| 71 | runOperations.push("agent.cancelRun"); | |
| 72 | staleRunActive = false; | |
| 73 | }); | |
| 74 | ||
| 75 | return { | |
| 76 | agentClose, | |
| 77 | cancelRun, | |
| 78 | closeExecutionOrder, | |
| 79 | create, | |
| 80 | listRuns, | |
| 81 | resume, | |
| 82 | runOperations, | |
| 83 | runCancel, | |
| 84 | runWait, | |
| 85 | send, | |
| 86 | setAgentCloseFailure: (failure: unknown) => { | |
| 87 | agentCloseFailure = failure; | |
| 88 | }, | |
| 89 | setStaleRunActive: (active: boolean) => { | |
| 90 | staleRunActive = active; | |
| 91 | }, | |
| 92 | }; | |
| 93 | }); | |
| 94 | ||
| 95 | vi.mock("./sdk.ts", () => ({ | |
| 96 | Agent: { | |
| 97 | create: cursorSdkMock.create, | |
| 98 | resume: cursorSdkMock.resume, | |
| 99 | listRuns: cursorSdkMock.listRuns, | |
| 100 | cancelRun: cursorSdkMock.cancelRun, | |
| 101 | messages: { | |
| 102 | list: vi.fn(async () => []), | |
| 103 | }, | |
| 104 | }, | |
| 105 | })); | |
| 106 | ||
| 107 | const layerTest = CursorAgentSdk.layer.pipe( | |
| 108 | Layer.provide( | |
| 109 | Layer.succeed(ProviderEventLoggers.ProviderEventLoggers, { | |
| 110 | native: { | |
| 111 | filePath: "cursor-agent-sdk-test.log", | |
| 112 | write: (event: unknown) => | |
| 113 | Effect.sync(() => { | |
| 114 | if ( | |
| 115 | typeof event === "object" && | |
| 116 | event !== null && | |
| 117 | Reflect.get(Reflect.get(event, "event"), "payload")?.type === "agent.close" | |
| 118 | ) { | |
| 119 | cursorSdkMock.closeExecutionOrder.push("protocol:agent.close"); | |
| 120 | } | |
| 121 | }), | |
| 122 | close: () => Effect.void, | |
| 123 | }, | |
| 124 | canonical: undefined, | |
| 125 | }), | |
| 126 | ), | |
| 127 | Layer.provide(NodeServices.layer), | |
| 128 | ); | |
| 129 | ||
| 130 | describe("CursorAgentSdkRunner", () => { | |
| 131 | it.effect("leaves a resumed agent alone when its next send succeeds", () => | |
| 132 | Effect.gen(function* () { | |
| 133 | cursorSdkMock.runOperations.length = 0; | |
| 134 | cursorSdkMock.setStaleRunActive(false); | |
| 135 | ||
| 136 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 137 | const session = yield* runner.open({ | |
| 138 | operation: "resume", | |
| 139 | agentId: "agent-cursor-agent-sdk-test", | |
| 140 | options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } }, | |
| 141 | threadId: ThreadId.make("thread-cursor-agent-sdk-normal-resume-test"), | |
| 142 | providerSessionId: ProviderSessionId.make( | |
| 143 | "provider-session-cursor-agent-sdk-normal-resume-test", | |
| 144 | ), | |
| 145 | }); | |
| 146 | const run = yield* session.send({ message: "continue" }); | |
| 147 | yield* run.wait; | |
| 148 | ||
| 149 | assert.deepStrictEqual(cursorSdkMock.runOperations, ["run.start"]); | |
| 150 | }).pipe(Effect.provide(layerTest)), | |
| 151 | ); | |
| 152 | ||
| 153 | it.effect("retries a resumed send after cancelling its abandoned local run", () => | |
| 154 | Effect.gen(function* () { | |
| 155 | cursorSdkMock.runOperations.length = 0; | |
| 156 | cursorSdkMock.listRuns.mockClear(); | |
| 157 | cursorSdkMock.cancelRun.mockClear(); | |
| 158 | cursorSdkMock.setStaleRunActive(true); | |
| 159 | ||
| 160 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 161 | const session = yield* runner.open({ | |
| 162 | operation: "resume", | |
| 163 | agentId: "agent-cursor-agent-sdk-test", | |
| 164 | options: { model: { id: "default" }, mode: "agent", local: { cwd: process.cwd() } }, | |
| 165 | threadId: ThreadId.make("thread-cursor-agent-sdk-stale-run-test"), | |
| 166 | providerSessionId: ProviderSessionId.make( | |
| 167 | "provider-session-cursor-agent-sdk-stale-run-test", | |
| 168 | ), | |
| 169 | }); | |
| 170 | assert.deepStrictEqual(cursorSdkMock.runOperations, []); | |
| 171 | const run = yield* session.send({ message: "continue" }); | |
| 172 | yield* run.wait; | |
| 173 | ||
| 174 | assert.deepStrictEqual(cursorSdkMock.runOperations, [ | |
| 175 | "run.start", | |
| 176 | "agent.listRuns", | |
| 177 | "agent.cancelRun", | |
| 178 | "run.start", | |
| 179 | ]); | |
| 180 | assert.deepStrictEqual(cursorSdkMock.listRuns.mock.calls[0], [ | |
| 181 | "agent-cursor-agent-sdk-test", | |
| 182 | { runtime: "local", cwd: process.cwd(), limit: 1 }, | |
| 183 | ]); | |
| 184 | assert.deepStrictEqual(cursorSdkMock.cancelRun.mock.calls[0], [ | |
| 185 | "run-stale", | |
| 186 | { | |
| 187 | runtime: "local", | |
| 188 | cwd: process.cwd(), | |
| 189 | }, | |
| 190 | ]); | |
| 191 | }).pipe(Effect.provide(layerTest)), | |
| 192 | ); | |
| 193 | ||
| 194 | it.effect("logs agent.close before invoking the Cursor SDK", () => | |
| 195 | Effect.gen(function* () { | |
| 196 | cursorSdkMock.agentClose.mockClear(); | |
| 197 | cursorSdkMock.closeExecutionOrder.length = 0; | |
| 198 | cursorSdkMock.setAgentCloseFailure(undefined); | |
| 199 | ||
| 200 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 201 | const session = yield* runner.open({ | |
| 202 | operation: "create", | |
| 203 | options: { | |
| 204 | model: { id: "default" }, | |
| 205 | mode: "agent", | |
| 206 | local: { | |
| 207 | cwd: process.cwd(), | |
| 208 | }, | |
| 209 | }, | |
| 210 | threadId: ThreadId.make("thread-cursor-agent-sdk-close-order-test"), | |
| 211 | providerSessionId: ProviderSessionId.make( | |
| 212 | "provider-session-cursor-agent-sdk-close-order-test", | |
| 213 | ), | |
| 214 | }); | |
| 215 | ||
| 216 | cursorSdkMock.closeExecutionOrder.length = 0; | |
| 217 | yield* session.close; | |
| 218 | ||
| 219 | assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [ | |
| 220 | "protocol:agent.close", | |
| 221 | "sdk:agent.close", | |
| 222 | ]); | |
| 223 | assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1); | |
| 224 | }).pipe(Effect.provide(layerTest)), | |
| 225 | ); | |
| 226 | ||
| 227 | it.effect("logs agent.close even when the Cursor SDK close fails", () => | |
| 228 | Effect.gen(function* () { | |
| 229 | cursorSdkMock.agentClose.mockClear(); | |
| 230 | cursorSdkMock.closeExecutionOrder.length = 0; | |
| 231 | const closeFailure = new Error("agent close failed"); | |
| 232 | cursorSdkMock.setAgentCloseFailure(closeFailure); | |
| 233 | ||
| 234 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 235 | const session = yield* runner.open({ | |
| 236 | operation: "create", | |
| 237 | options: { | |
| 238 | model: { id: "default" }, | |
| 239 | mode: "agent", | |
| 240 | local: { | |
| 241 | cwd: process.cwd(), | |
| 242 | }, | |
| 243 | }, | |
| 244 | threadId: ThreadId.make("thread-cursor-agent-sdk-close-failure-test"), | |
| 245 | providerSessionId: ProviderSessionId.make( | |
| 246 | "provider-session-cursor-agent-sdk-close-failure-test", | |
| 247 | ), | |
| 248 | }); | |
| 249 | ||
| 250 | cursorSdkMock.closeExecutionOrder.length = 0; | |
| 251 | const error = yield* Effect.flip(session.close); | |
| 252 | ||
| 253 | assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError); | |
| 254 | assert.equal(error.method, "agent.close"); | |
| 255 | assert.strictEqual(error.cause, closeFailure); | |
| 256 | assert.deepStrictEqual(cursorSdkMock.closeExecutionOrder, [ | |
| 257 | "protocol:agent.close", | |
| 258 | "sdk:agent.close", | |
| 259 | ]); | |
| 260 | assert.equal(cursorSdkMock.agentClose.mock.calls.length, 1); | |
| 261 | cursorSdkMock.setAgentCloseFailure(undefined); | |
| 262 | }).pipe(Effect.provide(layerTest)), | |
| 263 | ); | |
| 264 | ||
| 265 | it.effect("surfaces interaction callback failures through run.wait", () => | |
| 266 | Effect.gen(function* () { | |
| 267 | cursorSdkMock.create.mockClear(); | |
| 268 | cursorSdkMock.runWait.mockClear(); | |
| 269 | cursorSdkMock.send.mockClear(); | |
| 270 | ||
| 271 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 272 | const session = yield* runner.open({ | |
| 273 | operation: "create", | |
| 274 | options: { | |
| 275 | model: { id: "default" }, | |
| 276 | mode: "agent", | |
| 277 | local: { | |
| 278 | cwd: process.cwd(), | |
| 279 | autoReview: false, | |
| 280 | sandboxOptions: { enabled: false }, | |
| 281 | enableAgentRetries: true, | |
| 282 | }, | |
| 283 | }, | |
| 284 | threadId: ThreadId.make("thread-cursor-agent-sdk-test"), | |
| 285 | providerSessionId: ProviderSessionId.make("provider-session-cursor-agent-sdk-test"), | |
| 286 | }); | |
| 287 | ||
| 288 | const callbackFailure = new Error("delta callback failed"); | |
| 289 | const run = yield* session.send({ | |
| 290 | message: "hello", | |
| 291 | onDelta: () => Effect.fail(callbackFailure), | |
| 292 | }); | |
| 293 | ||
| 294 | const error = yield* Effect.flip(run.wait); | |
| 295 | ||
| 296 | assert.instanceOf(error, CursorAgentSdk.CursorAgentSdkRunnerError); | |
| 297 | assert.equal(error.method, "run.wait"); | |
| 298 | assert.strictEqual(error.cause, callbackFailure); | |
| 299 | assert.equal(cursorSdkMock.create.mock.calls.length, 1); | |
| 300 | assert.equal(cursorSdkMock.send.mock.calls.length, 1); | |
| 301 | assert.equal(cursorSdkMock.runWait.mock.calls.length, 1); | |
| 302 | }).pipe(Effect.provide(layerTest)), | |
| 303 | ); | |
| 304 | }); |
packages/provider-cursor/src/server/CursorAgentSdk.ts created+611| ... | ... | @@ -0,0 +1,611 @@ |
| 1 | import type { | |
| 2 | AgentMessage, | |
| 3 | AgentOptions, | |
| 4 | InteractionUpdate, | |
| 5 | RunResult, | |
| 6 | SDKUserMessage, | |
| 7 | SendOptions, | |
| 8 | } from "@cursor/sdk"; | |
| 9 | import { | |
| 10 | type OrchestrationV2ProviderSession, | |
| 11 | ProviderDriverKind, | |
| 12 | type ThreadId, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import * as Context from "effect/Context"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Schema from "effect/Schema"; | |
| 18 | ||
| 19 | import { Agent, createAgentPlatform } from "./sdk.ts"; | |
| 20 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | |
| 21 | ||
| 22 | export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; | |
| 23 | export const CURSOR_PROVIDER = ProviderDriverKind.make("cursor"); | |
| 24 | ||
| 25 | export class CursorAgentSdkRunnerError extends Schema.TaggedError<CursorAgentSdkRunnerError>()( | |
| 26 | "CursorAgentSdkRunnerError", | |
| 27 | { | |
| 28 | method: Schema.String, | |
| 29 | cause: Schema.Defect(), | |
| 30 | }, | |
| 31 | ) { | |
| 32 | override get message(): string { | |
| 33 | return `Cursor Agent SDK ${this.method} failed.`; | |
| 34 | } | |
| 35 | } | |
| 36 | ||
| 37 | const isCursorAgentSdkRunnerError = Schema.is(CursorAgentSdkRunnerError); | |
| 38 | ||
| 39 | export interface CursorAgentSdkOpenInput { | |
| 40 | readonly operation: "create" | "resume"; | |
| 41 | readonly agentId?: string; | |
| 42 | readonly options: AgentOptions; | |
| 43 | readonly threadId: ThreadId; | |
| 44 | readonly providerSessionId: OrchestrationV2ProviderSession["id"]; | |
| 45 | } | |
| 46 | ||
| 47 | export interface CursorAgentSdkSendInput<Error> { | |
| 48 | readonly message: string | SDKUserMessage; | |
| 49 | readonly options?: Omit<SendOptions, "onDelta">; | |
| 50 | readonly onDelta?: (update: InteractionUpdate) => Effect.Effect<void, Error>; | |
| 51 | } | |
| 52 | ||
| 53 | export interface CursorAgentSdkRun { | |
| 54 | readonly runId: string; | |
| 55 | readonly agentId: string; | |
| 56 | readonly wait: Effect.Effect<RunResult, CursorAgentSdkRunnerError>; | |
| 57 | readonly cancel: Effect.Effect<void, CursorAgentSdkRunnerError>; | |
| 58 | } | |
| 59 | ||
| 60 | export interface CursorAgentSdkSession { | |
| 61 | readonly agentId: string; | |
| 62 | readonly send: <Error>( | |
| 63 | input: CursorAgentSdkSendInput<Error>, | |
| 64 | ) => Effect.Effect<CursorAgentSdkRun, CursorAgentSdkRunnerError>; | |
| 65 | readonly listMessages: Effect.Effect<ReadonlyArray<AgentMessage>, CursorAgentSdkRunnerError>; | |
| 66 | readonly close: Effect.Effect<void, CursorAgentSdkRunnerError>; | |
| 67 | } | |
| 68 | ||
| 69 | export interface CursorAgentSdkRunnerShape { | |
| 70 | readonly open: ( | |
| 71 | input: CursorAgentSdkOpenInput, | |
| 72 | ) => Effect.Effect<CursorAgentSdkSession, CursorAgentSdkRunnerError>; | |
| 73 | readonly assertComplete: Effect.Effect<void, CursorAgentSdkRunnerError>; | |
| 74 | } | |
| 75 | ||
| 76 | export class CursorAgentSdkRunner extends Context.Service< | |
| 77 | CursorAgentSdkRunner, | |
| 78 | CursorAgentSdkRunnerShape | |
| 79 | >()("@t3tools/provider-cursor/server/CursorAgentSdk/CursorAgentSdkRunner") {} | |
| 80 | ||
| 81 | export interface CursorAgentSdkLoggedAgentOptions { | |
| 82 | readonly model?: AgentOptions["model"]; | |
| 83 | readonly hasName?: boolean; | |
| 84 | readonly mode?: AgentOptions["mode"]; | |
| 85 | readonly local?: { | |
| 86 | readonly hasCwd?: boolean; | |
| 87 | readonly autoReview?: boolean; | |
| 88 | readonly settingSources?: AgentOptions["local"] extends infer Local | |
| 89 | ? Local extends { readonly settingSources?: infer Sources } | |
| 90 | ? Sources | |
| 91 | : never | |
| 92 | : never; | |
| 93 | readonly sandboxEnabled?: boolean; | |
| 94 | readonly enableAgentRetries?: boolean; | |
| 95 | readonly hasCustomTools?: boolean; | |
| 96 | }; | |
| 97 | readonly agents?: ReadonlyArray<string>; | |
| 98 | } | |
| 99 | ||
| 100 | export interface CursorAgentSdkLoggedSendOptions { | |
| 101 | readonly model?: SendOptions["model"]; | |
| 102 | readonly mode?: SendOptions["mode"]; | |
| 103 | readonly local?: { | |
| 104 | readonly force?: boolean; | |
| 105 | readonly hasCustomTools?: boolean; | |
| 106 | }; | |
| 107 | readonly idempotencyKey?: string; | |
| 108 | } | |
| 109 | ||
| 110 | export type CursorAgentSdkProtocolLogEvent = | |
| 111 | | { | |
| 112 | readonly direction: "outgoing"; | |
| 113 | readonly stage: "decoded"; | |
| 114 | readonly payload: { | |
| 115 | readonly type: "agent.open"; | |
| 116 | readonly operation: CursorAgentSdkOpenInput["operation"]; | |
| 117 | readonly agentId?: string; | |
| 118 | readonly options: CursorAgentSdkLoggedAgentOptions; | |
| 119 | }; | |
| 120 | } | |
| 121 | | { | |
| 122 | readonly direction: "incoming"; | |
| 123 | readonly stage: "decoded"; | |
| 124 | readonly payload: { | |
| 125 | readonly type: "agent.opened"; | |
| 126 | readonly agentId: string; | |
| 127 | }; | |
| 128 | } | |
| 129 | | { | |
| 130 | readonly direction: "outgoing"; | |
| 131 | readonly stage: "decoded"; | |
| 132 | readonly payload: { | |
| 133 | readonly type: "run.start"; | |
| 134 | readonly message: string | SDKUserMessage; | |
| 135 | readonly options: CursorAgentSdkLoggedSendOptions; | |
| 136 | }; | |
| 137 | } | |
| 138 | | { | |
| 139 | readonly direction: "incoming"; | |
| 140 | readonly stage: "decoded"; | |
| 141 | readonly payload: { | |
| 142 | readonly type: "run.started"; | |
| 143 | readonly runId: string; | |
| 144 | readonly agentId: string; | |
| 145 | }; | |
| 146 | } | |
| 147 | | { | |
| 148 | readonly direction: "incoming"; | |
| 149 | readonly stage: "decoded"; | |
| 150 | readonly payload: { | |
| 151 | readonly type: "interaction.update"; | |
| 152 | readonly runId: string; | |
| 153 | readonly update: InteractionUpdate; | |
| 154 | }; | |
| 155 | } | |
| 156 | | { | |
| 157 | readonly direction: "incoming"; | |
| 158 | readonly stage: "decoded"; | |
| 159 | readonly payload: { | |
| 160 | readonly type: "run.completed"; | |
| 161 | readonly result: RunResult; | |
| 162 | }; | |
| 163 | } | |
| 164 | | { | |
| 165 | readonly direction: "outgoing"; | |
| 166 | readonly stage: "decoded"; | |
| 167 | readonly payload: { | |
| 168 | readonly type: "run.cancel"; | |
| 169 | readonly runId: string; | |
| 170 | }; | |
| 171 | } | |
| 172 | | { | |
| 173 | readonly direction: "outgoing"; | |
| 174 | readonly stage: "decoded"; | |
| 175 | readonly payload: { | |
| 176 | readonly type: "agent.messages.list"; | |
| 177 | readonly agentId: string; | |
| 178 | }; | |
| 179 | } | |
| 180 | | { | |
| 181 | readonly direction: "incoming"; | |
| 182 | readonly stage: "decoded"; | |
| 183 | readonly payload: { | |
| 184 | readonly type: "agent.messages"; | |
| 185 | readonly agentId: string; | |
| 186 | readonly messages: ReadonlyArray<AgentMessage>; | |
| 187 | }; | |
| 188 | } | |
| 189 | | { | |
| 190 | readonly direction: "outgoing"; | |
| 191 | readonly stage: "decoded"; | |
| 192 | readonly payload: { | |
| 193 | readonly type: "agent.close"; | |
| 194 | readonly agentId: string; | |
| 195 | }; | |
| 196 | }; | |
| 197 | ||
| 198 | export type CursorAgentSdkProtocolLogger = ( | |
| 199 | event: CursorAgentSdkProtocolLogEvent, | |
| 200 | ) => Effect.Effect<void>; | |
| 201 | ||
| 202 | function runnerError(cause: unknown, method: string): CursorAgentSdkRunnerError { | |
| 203 | return isCursorAgentSdkRunnerError(cause) | |
| 204 | ? cause | |
| 205 | : new CursorAgentSdkRunnerError({ method, cause }); | |
| 206 | } | |
| 207 | ||
| 208 | function isActiveRunConflict(cause: unknown): boolean { | |
| 209 | return cause instanceof Error && /already has active run/i.test(cause.message); | |
| 210 | } | |
| 211 | ||
| 212 | export function isCursorCancellationError(cause: unknown): boolean { | |
| 213 | let current = cause; | |
| 214 | const seen = new Set<object>(); | |
| 215 | ||
| 216 | while (typeof current === "object" && current !== null && !seen.has(current)) { | |
| 217 | if (Reflect.get(current, "name") === "AbortError") { | |
| 218 | return true; | |
| 219 | } | |
| 220 | seen.add(current); | |
| 221 | current = Reflect.get(current, "cause"); | |
| 222 | } | |
| 223 | ||
| 224 | return false; | |
| 225 | } | |
| 226 | ||
| 227 | export function loggedCursorAgentOptions(options: AgentOptions): CursorAgentSdkLoggedAgentOptions { | |
| 228 | return { | |
| 229 | ...(options.model === undefined ? {} : { model: options.model }), | |
| 230 | ...(options.name === undefined ? {} : { hasName: true }), | |
| 231 | ...(options.mode === undefined ? {} : { mode: options.mode }), | |
| 232 | ...(options.local === undefined | |
| 233 | ? {} | |
| 234 | : { | |
| 235 | local: { | |
| 236 | ...(options.local.cwd === undefined ? {} : { hasCwd: true }), | |
| 237 | ...(options.local.autoReview === undefined | |
| 238 | ? {} | |
| 239 | : { autoReview: options.local.autoReview }), | |
| 240 | ...(options.local.settingSources === undefined | |
| 241 | ? {} | |
| 242 | : { settingSources: options.local.settingSources }), | |
| 243 | ...(options.local.sandboxOptions === undefined | |
| 244 | ? {} | |
| 245 | : { sandboxEnabled: options.local.sandboxOptions.enabled }), | |
| 246 | ...(options.local.enableAgentRetries === undefined | |
| 247 | ? {} | |
| 248 | : { enableAgentRetries: options.local.enableAgentRetries }), | |
| 249 | ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }), | |
| 250 | }, | |
| 251 | }), | |
| 252 | ...(options.agents === undefined ? {} : { agents: Object.keys(options.agents).toSorted() }), | |
| 253 | }; | |
| 254 | } | |
| 255 | ||
| 256 | export function loggedCursorSendOptions( | |
| 257 | options: Omit<SendOptions, "onDelta"> | undefined, | |
| 258 | ): CursorAgentSdkLoggedSendOptions { | |
| 259 | if (options === undefined) { | |
| 260 | return {}; | |
| 261 | } | |
| 262 | return { | |
| 263 | ...(options.model === undefined ? {} : { model: options.model }), | |
| 264 | ...(options.mode === undefined ? {} : { mode: options.mode }), | |
| 265 | ...(options.local === undefined | |
| 266 | ? {} | |
| 267 | : { | |
| 268 | local: { | |
| 269 | ...(options.local.force === undefined ? {} : { force: options.local.force }), | |
| 270 | ...(options.local.customTools === undefined ? {} : { hasCustomTools: true }), | |
| 271 | }, | |
| 272 | }), | |
| 273 | ...(options.idempotencyKey === undefined ? {} : { idempotencyKey: options.idempotencyKey }), | |
| 274 | }; | |
| 275 | } | |
| 276 | ||
| 277 | function makeCursorAgentSdkProtocolLogger(input: { | |
| 278 | readonly nativeEventLogger: ProviderEventLoggers.EventNdjsonLogger | undefined; | |
| 279 | readonly threadId: ThreadId; | |
| 280 | readonly providerSessionId: OrchestrationV2ProviderSession["id"]; | |
| 281 | }): CursorAgentSdkProtocolLogger | undefined { | |
| 282 | if (input.nativeEventLogger === undefined) { | |
| 283 | return undefined; | |
| 284 | } | |
| 285 | const nativeEventLogger = input.nativeEventLogger; | |
| 286 | return (event) => | |
| 287 | nativeEventLogger | |
| 288 | .write( | |
| 289 | { | |
| 290 | provider: CURSOR_PROVIDER, | |
| 291 | protocol: CURSOR_AGENT_SDK_PROTOCOL, | |
| 292 | kind: "protocol", | |
| 293 | providerSessionId: input.providerSessionId, | |
| 294 | event, | |
| 295 | }, | |
| 296 | input.threadId, | |
| 297 | ) | |
| 298 | .pipe(Effect.ignore); | |
| 299 | } | |
| 300 | ||
| 301 | /** | |
| 302 | * The Cursor SDK decides once per process whether local sandboxing works, and | |
| 303 | * caches the answer the first time any run starts. Only sandboxed runs point | |
| 304 | * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access) | |
| 305 | * run it caches "unsupported" and rejects every later sandboxed run until the | |
| 306 | * server restarts. Warming a bare sandboxed executor before the first | |
| 307 | * unsandboxed agent opens lets the SDK find the helper and cache the real | |
| 308 | * answer. Warming is best effort: on a machine without sandbox support it | |
| 309 | * fails, the SDK caches "unsupported", and sandboxed runs report that as | |
| 310 | * before. | |
| 311 | */ | |
| 312 | let cursorSandboxSupportPrime: Promise<void> | undefined; | |
| 313 | ||
| 314 | function primeCursorSandboxSupport(options: AgentOptions): Promise<void> { | |
| 315 | cursorSandboxSupportPrime ??= (async () => { | |
| 316 | const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined; | |
| 317 | const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd }); | |
| 318 | const release = await platform.prewarmLocalWorkspace({ | |
| 319 | ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), | |
| 320 | local: { | |
| 321 | ...(cwd === undefined ? {} : { cwd }), | |
| 322 | settingSources: [], | |
| 323 | sandboxOptions: { enabled: true }, | |
| 324 | }, | |
| 325 | }); | |
| 326 | await release(); | |
| 327 | })().catch(() => undefined); | |
| 328 | return cursorSandboxSupportPrime; | |
| 329 | } | |
| 330 | ||
| 331 | /** | |
| 332 | * Runs agents through the Cursor SDK, logging every frame to the protocol | |
| 333 | * logger chosen for each opened agent. The live layer writes the native | |
| 334 | * provider event log; the replay recorder turns the same frames into a | |
| 335 | * transcript. | |
| 336 | */ | |
| 337 | export function makeCursorAgentSdkRunner( | |
| 338 | protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined, | |
| 339 | ): CursorAgentSdkRunnerShape { | |
| 340 | return CursorAgentSdkRunner.of({ | |
| 341 | open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) { | |
| 342 | if (input.options.local?.sandboxOptions?.enabled === false) { | |
| 343 | yield* Effect.promise(() => primeCursorSandboxSupport(input.options)); | |
| 344 | } | |
| 345 | const protocolLogger = protocolLoggerFor(input); | |
| 346 | const log = (event: CursorAgentSdkProtocolLogEvent) => | |
| 347 | protocolLogger === undefined ? Effect.void : protocolLogger(event); | |
| 348 | ||
| 349 | yield* log({ | |
| 350 | direction: "outgoing", | |
| 351 | stage: "decoded", | |
| 352 | payload: { | |
| 353 | type: "agent.open", | |
| 354 | operation: input.operation, | |
| 355 | ...(input.agentId === undefined ? {} : { agentId: input.agentId }), | |
| 356 | options: loggedCursorAgentOptions(input.options), | |
| 357 | }, | |
| 358 | }); | |
| 359 | ||
| 360 | const agent = yield* Effect.tryPromise({ | |
| 361 | try: () => | |
| 362 | input.operation === "create" | |
| 363 | ? Agent.create(input.options) | |
| 364 | : Agent.resume(input.agentId!, input.options), | |
| 365 | catch: (cause) => runnerError(cause, `agent.${input.operation}`), | |
| 366 | }); | |
| 367 | ||
| 368 | yield* log({ | |
| 369 | direction: "incoming", | |
| 370 | stage: "decoded", | |
| 371 | payload: { | |
| 372 | type: "agent.opened", | |
| 373 | agentId: agent.agentId, | |
| 374 | }, | |
| 375 | }); | |
| 376 | ||
| 377 | const cwd = | |
| 378 | typeof input.options.local?.cwd === "string" | |
| 379 | ? input.options.local.cwd | |
| 380 | : input.options.local?.cwd?.[0]; | |
| 381 | const runOptions = { | |
| 382 | runtime: "local" as const, | |
| 383 | ...(cwd === undefined ? {} : { cwd }), | |
| 384 | ...(input.options.local?.store === undefined ? {} : { store: input.options.local.store }), | |
| 385 | }; | |
| 386 | let abandonedRunRecoveryAvailable = input.operation === "resume"; | |
| 387 | ||
| 388 | return { | |
| 389 | agentId: agent.agentId, | |
| 390 | send: Effect.fn("CursorAgentSdkSession.send")(function* (sendInput) { | |
| 391 | const context = yield* Effect.context(); | |
| 392 | yield* log({ | |
| 393 | direction: "outgoing", | |
| 394 | stage: "decoded", | |
| 395 | payload: { | |
| 396 | type: "run.start", | |
| 397 | message: sendInput.message, | |
| 398 | options: loggedCursorSendOptions(sendInput.options), | |
| 399 | }, | |
| 400 | }); | |
| 401 | ||
| 402 | let callbacksReady = false; | |
| 403 | const pendingUpdates: Array<InteractionUpdate> = []; | |
| 404 | let callbackFailure: { readonly cause: unknown } | undefined; | |
| 405 | let callbackChain = Promise.resolve(); | |
| 406 | let runId = ""; | |
| 407 | const dispatchUpdate = (update: InteractionUpdate): Promise<void> => { | |
| 408 | callbackChain = callbackChain | |
| 409 | .then(() => { | |
| 410 | if (callbackFailure !== undefined) { | |
| 411 | return; | |
| 412 | } | |
| 413 | return Effect.runPromiseWith(context)( | |
| 414 | log({ | |
| 415 | direction: "incoming", | |
| 416 | stage: "decoded", | |
| 417 | payload: { | |
| 418 | type: "interaction.update", | |
| 419 | runId, | |
| 420 | update, | |
| 421 | }, | |
| 422 | }).pipe(Effect.andThen(sendInput.onDelta?.(update) ?? Effect.void)), | |
| 423 | ); | |
| 424 | }) | |
| 425 | .catch((cause) => { | |
| 426 | callbackFailure ??= { cause }; | |
| 427 | }); | |
| 428 | return callbackChain; | |
| 429 | }; | |
| 430 | ||
| 431 | const startRun = () => | |
| 432 | Effect.tryPromise({ | |
| 433 | try: () => | |
| 434 | agent.send(sendInput.message, { | |
| 435 | ...sendInput.options, | |
| 436 | onDelta: async ({ update }) => { | |
| 437 | if (!callbacksReady) { | |
| 438 | pendingUpdates.push(update); | |
| 439 | return; | |
| 440 | } | |
| 441 | await dispatchUpdate(update); | |
| 442 | }, | |
| 443 | }), | |
| 444 | catch: (cause) => runnerError(cause, "run.start"), | |
| 445 | }); | |
| 446 | const run = yield* startRun().pipe( | |
| 447 | Effect.catchIf( | |
| 448 | (error) => abandonedRunRecoveryAvailable && isActiveRunConflict(error.cause), | |
| 449 | (error) => | |
| 450 | Effect.gen(function* () { | |
| 451 | abandonedRunRecoveryAvailable = false; | |
| 452 | const latestRun = yield* Effect.tryPromise({ | |
| 453 | try: () => Agent.listRuns(agent.agentId, { ...runOptions, limit: 1 }), | |
| 454 | catch: (cause) => runnerError(cause, "agent.listRuns"), | |
| 455 | }); | |
| 456 | const activeRun = latestRun.items.find( | |
| 457 | (candidate) => candidate.status === "running", | |
| 458 | ); | |
| 459 | if (activeRun === undefined) { | |
| 460 | return yield* error; | |
| 461 | } | |
| 462 | yield* log({ | |
| 463 | direction: "outgoing", | |
| 464 | stage: "decoded", | |
| 465 | payload: { type: "run.cancel", runId: activeRun.id }, | |
| 466 | }); | |
| 467 | yield* Effect.tryPromise({ | |
| 468 | try: () => Agent.cancelRun(activeRun.id, runOptions), | |
| 469 | catch: (cause) => runnerError(cause, "agent.cancelRun"), | |
| 470 | }); | |
| 471 | return yield* startRun(); | |
| 472 | }), | |
| 473 | ), | |
| 474 | ); | |
| 475 | abandonedRunRecoveryAvailable = false; | |
| 476 | runId = run.id; | |
| 477 | yield* log({ | |
| 478 | direction: "incoming", | |
| 479 | stage: "decoded", | |
| 480 | payload: { | |
| 481 | type: "run.started", | |
| 482 | runId: run.id, | |
| 483 | agentId: run.agentId, | |
| 484 | }, | |
| 485 | }); | |
| 486 | callbacksReady = true; | |
| 487 | for (const update of pendingUpdates) { | |
| 488 | yield* Effect.tryPromise({ | |
| 489 | try: () => dispatchUpdate(update), | |
| 490 | catch: (cause) => runnerError(cause, "run.onDelta"), | |
| 491 | }); | |
| 492 | } | |
| 493 | ||
| 494 | return { | |
| 495 | runId: run.id, | |
| 496 | agentId: run.agentId, | |
| 497 | wait: Effect.tryPromise({ | |
| 498 | try: async () => { | |
| 499 | const result = await run.wait(); | |
| 500 | await callbackChain; | |
| 501 | if (callbackFailure !== undefined) { | |
| 502 | throw callbackFailure.cause; | |
| 503 | } | |
| 504 | return result; | |
| 505 | }, | |
| 506 | catch: (cause) => runnerError(cause, "run.wait"), | |
| 507 | }).pipe( | |
| 508 | Effect.tap((result) => | |
| 509 | log({ | |
| 510 | direction: "incoming", | |
| 511 | stage: "decoded", | |
| 512 | payload: { | |
| 513 | type: "run.completed", | |
| 514 | result, | |
| 515 | }, | |
| 516 | }), | |
| 517 | ), | |
| 518 | ), | |
| 519 | cancel: log({ | |
| 520 | direction: "outgoing", | |
| 521 | stage: "decoded", | |
| 522 | payload: { | |
| 523 | type: "run.cancel", | |
| 524 | runId: run.id, | |
| 525 | }, | |
| 526 | }).pipe( | |
| 527 | Effect.andThen( | |
| 528 | Effect.tryPromise({ | |
| 529 | try: async () => { | |
| 530 | try { | |
| 531 | await run.cancel(); | |
| 532 | } catch (cause) { | |
| 533 | if (!isCursorCancellationError(cause)) { | |
| 534 | throw cause; | |
| 535 | } | |
| 536 | } | |
| 537 | }, | |
| 538 | catch: (cause) => runnerError(cause, "run.cancel"), | |
| 539 | }), | |
| 540 | ), | |
| 541 | ), | |
| 542 | } satisfies CursorAgentSdkRun; | |
| 543 | }), | |
| 544 | listMessages: log({ | |
| 545 | direction: "outgoing", | |
| 546 | stage: "decoded", | |
| 547 | payload: { | |
| 548 | type: "agent.messages.list", | |
| 549 | agentId: agent.agentId, | |
| 550 | }, | |
| 551 | }).pipe( | |
| 552 | Effect.andThen( | |
| 553 | Effect.tryPromise({ | |
| 554 | try: () => | |
| 555 | Agent.messages.list(agent.agentId, { | |
| 556 | runtime: "local", | |
| 557 | ...(cwd === undefined ? {} : { cwd }), | |
| 558 | }), | |
| 559 | catch: (cause) => runnerError(cause, "agent.messages.list"), | |
| 560 | }), | |
| 561 | ), | |
| 562 | Effect.tap((messages) => | |
| 563 | log({ | |
| 564 | direction: "incoming", | |
| 565 | stage: "decoded", | |
| 566 | payload: { | |
| 567 | type: "agent.messages", | |
| 568 | agentId: agent.agentId, | |
| 569 | messages, | |
| 570 | }, | |
| 571 | }), | |
| 572 | ), | |
| 573 | ), | |
| 574 | close: log({ | |
| 575 | direction: "outgoing", | |
| 576 | stage: "decoded", | |
| 577 | payload: { | |
| 578 | type: "agent.close", | |
| 579 | agentId: agent.agentId, | |
| 580 | }, | |
| 581 | }).pipe( | |
| 582 | Effect.andThen( | |
| 583 | Effect.try({ | |
| 584 | try: () => agent.close(), | |
| 585 | catch: (cause) => runnerError(cause, "agent.close"), | |
| 586 | }), | |
| 587 | ), | |
| 588 | ), | |
| 589 | } satisfies CursorAgentSdkSession; | |
| 590 | }), | |
| 591 | assertComplete: Effect.void, | |
| 592 | }); | |
| 593 | } | |
| 594 | ||
| 595 | export const layer: Layer.Layer< | |
| 596 | CursorAgentSdkRunner, | |
| 597 | never, | |
| 598 | ProviderEventLoggers.ProviderEventLoggers | |
| 599 | > = Layer.effect( | |
| 600 | CursorAgentSdkRunner, | |
| 601 | Effect.gen(function* () { | |
| 602 | const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; | |
| 603 | return makeCursorAgentSdkRunner((input) => | |
| 604 | makeCursorAgentSdkProtocolLogger({ | |
| 605 | nativeEventLogger, | |
| 606 | threadId: input.threadId, | |
| 607 | providerSessionId: input.providerSessionId, | |
| 608 | }), | |
| 609 | ); | |
| 610 | }), | |
| 611 | ); |
packages/provider-cursor/src/server/CursorSdkCatalog.test.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Exit from "effect/Exit"; | |
| 5 | ||
| 6 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | |
| 7 | ||
| 8 | const user = { | |
| 9 | apiKeyName: "test-key", | |
| 10 | userEmail: "cursor@example.com", | |
| 11 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 12 | } satisfies SDKUser; | |
| 13 | ||
| 14 | const model = { | |
| 15 | id: "claude-opus-4-8", | |
| 16 | displayName: "Opus 4.8", | |
| 17 | parameters: [], | |
| 18 | } satisfies SDKModel; | |
| 19 | ||
| 20 | const catalogError = () => | |
| 21 | new CursorSdkCatalog.CursorSdkCatalogError({ | |
| 22 | authenticationFailure: false, | |
| 23 | cause: new Error("catalog unavailable"), | |
| 24 | }); | |
| 25 | ||
| 26 | describe("CursorSdkCatalog", () => { | |
| 27 | it.effect("keeps the user probe fresh while caching models by API key", () => | |
| 28 | Effect.gen(function* () { | |
| 29 | let userCalls = 0; | |
| 30 | let modelCalls = 0; | |
| 31 | const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ | |
| 32 | readUser: () => Effect.sync(() => ((userCalls += 1), user)), | |
| 33 | readModels: () => Effect.sync(() => ((modelCalls += 1), [model])), | |
| 34 | }); | |
| 35 | ||
| 36 | yield* catalog.read("first-key"); | |
| 37 | yield* catalog.read("first-key"); | |
| 38 | yield* catalog.read("second-key"); | |
| 39 | ||
| 40 | expect(userCalls).toBe(3); | |
| 41 | expect(modelCalls).toBe(2); | |
| 42 | }), | |
| 43 | ); | |
| 44 | ||
| 45 | it.effect("does not cache failed or empty model discovery", () => | |
| 46 | Effect.gen(function* () { | |
| 47 | let modelCalls = 0; | |
| 48 | const catalog = yield* CursorSdkCatalog.makeCursorSdkCatalog({ | |
| 49 | readUser: () => Effect.succeed(user), | |
| 50 | readModels: () => | |
| 51 | Effect.suspend(() => { | |
| 52 | modelCalls += 1; | |
| 53 | if (modelCalls === 1) return Effect.fail(catalogError()); | |
| 54 | if (modelCalls === 2) return Effect.succeed([]); | |
| 55 | return Effect.succeed([model]); | |
| 56 | }), | |
| 57 | }); | |
| 58 | ||
| 59 | expect(Exit.isFailure(yield* Effect.exit(catalog.read("test-key")))).toBe(true); | |
| 60 | expect((yield* catalog.read("test-key")).models).toEqual([]); | |
| 61 | expect((yield* catalog.read("test-key")).models).toEqual([model]); | |
| 62 | expect((yield* catalog.read("test-key")).models).toEqual([model]); | |
| 63 | expect(modelCalls).toBe(3); | |
| 64 | }), | |
| 65 | ); | |
| 66 | }); |
packages/provider-cursor/src/server/CursorSdkCatalog.ts created+92| ... | ... | @@ -0,0 +1,92 @@ |
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | |
| 2 | import * as Cache from "effect/Cache"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Duration from "effect/Duration"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Exit from "effect/Exit"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | ||
| 10 | import { AuthenticationError, Cursor, CursorSdkError } from "./sdk.ts"; | |
| 11 | ||
| 12 | export interface CursorSdkCatalogSnapshot { | |
| 13 | readonly user: SDKUser; | |
| 14 | readonly models: ReadonlyArray<SDKModel>; | |
| 15 | } | |
| 16 | ||
| 17 | export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogError>()( | |
| 18 | "CursorSdkCatalogError", | |
| 19 | { | |
| 20 | authenticationFailure: Schema.Boolean, | |
| 21 | cause: Schema.Defect(), | |
| 22 | }, | |
| 23 | ) { | |
| 24 | override get message(): string { | |
| 25 | return this.authenticationFailure | |
| 26 | ? "Cursor SDK authentication failed." | |
| 27 | : "Cursor SDK catalog request failed."; | |
| 28 | } | |
| 29 | } | |
| 30 | ||
| 31 | export interface CursorSdkCatalogShape { | |
| 32 | readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>; | |
| 33 | } | |
| 34 | ||
| 35 | export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()( | |
| 36 | "@t3tools/provider-cursor/server/CursorSdkCatalog", | |
| 37 | ) {} | |
| 38 | ||
| 39 | function isAuthenticationFailure(cause: unknown): boolean { | |
| 40 | return ( | |
| 41 | cause instanceof AuthenticationError || | |
| 42 | (cause instanceof CursorSdkError && cause.status === 401) | |
| 43 | ); | |
| 44 | } | |
| 45 | ||
| 46 | export interface CursorSdkCatalogProbes { | |
| 47 | readonly readUser: (apiKey: string) => Effect.Effect<SDKUser, CursorSdkCatalogError>; | |
| 48 | readonly readModels: ( | |
| 49 | apiKey: string, | |
| 50 | ) => Effect.Effect<ReadonlyArray<SDKModel>, CursorSdkCatalogError>; | |
| 51 | } | |
| 52 | ||
| 53 | const sdkRequest = <A>(tryRequest: () => Promise<A>) => | |
| 54 | Effect.tryPromise({ | |
| 55 | try: tryRequest, | |
| 56 | catch: (cause) => | |
| 57 | new CursorSdkCatalogError({ | |
| 58 | authenticationFailure: isAuthenticationFailure(cause), | |
| 59 | cause, | |
| 60 | }), | |
| 61 | }); | |
| 62 | ||
| 63 | const liveProbes: CursorSdkCatalogProbes = { | |
| 64 | readUser: (apiKey) => sdkRequest(() => Cursor.me({ apiKey })), | |
| 65 | readModels: (apiKey) => sdkRequest(() => Cursor.models.list({ apiKey })), | |
| 66 | }; | |
| 67 | ||
| 68 | export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* ( | |
| 69 | probes: CursorSdkCatalogProbes = liveProbes, | |
| 70 | ) { | |
| 71 | const modelCache = yield* Cache.makeWith(probes.readModels, { | |
| 72 | capacity: 32, | |
| 73 | timeToLive: (exit) => | |
| 74 | Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, | |
| 75 | }); | |
| 76 | return CursorSdkCatalog.of({ | |
| 77 | read: (apiKey) => | |
| 78 | Effect.all( | |
| 79 | { | |
| 80 | user: probes.readUser(apiKey), | |
| 81 | models: Cache.get(modelCache, apiKey), | |
| 82 | }, | |
| 83 | { concurrency: "unbounded" }, | |
| 84 | ), | |
| 85 | }); | |
| 86 | }); | |
| 87 | ||
| 88 | export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); | |
| 89 | ||
| 90 | export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> { | |
| 91 | return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read })); | |
| 92 | } |
packages/provider-cursor/src/server/adapter.test.ts created+955| ... | ... | @@ -0,0 +1,955 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, describe, it } from "@effect/vitest"; | |
| 3 | import type { InteractionUpdate } from "@cursor/sdk"; | |
| 4 | import { | |
| 5 | EnvironmentId, | |
| 6 | MessageId, | |
| 7 | NodeId, | |
| 8 | ProjectId, | |
| 9 | ProviderInstanceId, | |
| 10 | ProviderSessionId, | |
| 11 | RunAttemptId, | |
| 12 | RunId, | |
| 13 | ThreadId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { CursorSettings } from "../settings.ts"; | |
| 16 | import * as DateTime from "effect/DateTime"; | |
| 17 | import * as Effect from "effect/Effect"; | |
| 18 | import * as FileSystem from "effect/FileSystem"; | |
| 19 | import * as Layer from "effect/Layer"; | |
| 20 | import * as Option from "effect/Option"; | |
| 21 | import * as Path from "effect/Path"; | |
| 22 | import * as Schema from "effect/Schema"; | |
| 23 | import * as Stream from "effect/Stream"; | |
| 24 | ||
| 25 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 26 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 27 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 28 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 29 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 30 | import { | |
| 31 | cursorMcpServers, | |
| 32 | cursorRuntimeAgentPolicy, | |
| 33 | cursorSdkModelSelection, | |
| 34 | makeCursorAgentOptions, | |
| 35 | makeCursorAdapterV2, | |
| 36 | nestedToolCallFromEnvelope, | |
| 37 | } from "./adapter.ts"; | |
| 38 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | |
| 39 | ||
| 40 | const decodeCursorSettings = Schema.decodeEffect(CursorSettings); | |
| 41 | ||
| 42 | describe("CursorAdapterV2", () => { | |
| 43 | it.effect.each([ | |
| 44 | { status: "finished", model: undefined, lateModel: undefined }, | |
| 45 | { status: "cancelled", model: "claude-opus-4-6", lateModel: undefined }, | |
| 46 | { status: "error", model: "custom-fable", lateModel: undefined }, | |
| 47 | { status: "finished", model: undefined, lateModel: "gpt-6-sol" }, | |
| 48 | { status: "finished", model: "gpt-6-sol", lateModel: null }, | |
| 49 | ] as const)( | |
| 50 | "projects Cursor tasks: $status, late model $lateModel", | |
| 51 | ({ status, model, lateModel }) => | |
| 52 | Effect.gen(function* () { | |
| 53 | const fileSystem = yield* FileSystem.FileSystem; | |
| 54 | const path = yield* Path.Path; | |
| 55 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ | |
| 56 | prefix: "cursor-v2-lifecycle-", | |
| 57 | }); | |
| 58 | const instanceId = ProviderInstanceId.make("cursor"); | |
| 59 | const threadId = ThreadId.make("cursor-lifecycle-thread"); | |
| 60 | const modelSelection = { instanceId, model: "composer-2.5" }; | |
| 61 | const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ | |
| 62 | runtimeMode: "full-access", | |
| 63 | interactionMode: "default", | |
| 64 | cwd: workspace, | |
| 65 | }); | |
| 66 | const adapter = makeCursorAdapterV2({ | |
| 67 | instanceId, | |
| 68 | settings: yield* decodeCursorSettings({}), | |
| 69 | environment: { HOME: workspace }, | |
| 70 | fileSystem, | |
| 71 | path, | |
| 72 | idAllocator: yield* IdAllocator.IdAllocatorV2, | |
| 73 | host: yield* ProviderHost.ProviderHost.pipe( | |
| 74 | Effect.provide(layerTestProviderHost({ cwd: workspace })), | |
| 75 | ), | |
| 76 | runner: { | |
| 77 | assertComplete: Effect.void, | |
| 78 | open: () => | |
| 79 | Effect.succeed({ | |
| 80 | agentId: "native-cursor-lifecycle", | |
| 81 | listMessages: Effect.succeed([]), | |
| 82 | close: Effect.void, | |
| 83 | send: (input) => | |
| 84 | Effect.gen(function* () { | |
| 85 | // Recorded Cursor task calls (see fixtures/subagent) stream a | |
| 86 | // partial-tool-call before tool-call-started with the same args. | |
| 87 | // The run then ends without tool-call-completed, which a live | |
| 88 | // run cannot produce on demand. | |
| 89 | const taskToolCall = { | |
| 90 | type: "task" as const, | |
| 91 | args: { | |
| 92 | description: "Review", | |
| 93 | prompt: "Review the code.", | |
| 94 | subagentType: { kind: "unspecified" }, | |
| 95 | ...(model === undefined ? {} : { model }), | |
| 96 | agentId: "cursor-task-agent", | |
| 97 | mode: "unspecified" as const, | |
| 98 | }, | |
| 99 | }; | |
| 100 | const updates = | |
| 101 | lateModel !== undefined | |
| 102 | ? (["tool-call-started", "tool-call-completed"] as const) | |
| 103 | : (["partial-tool-call", "tool-call-started"] as const); | |
| 104 | for (const type of updates) { | |
| 105 | yield* input.onDelta!({ | |
| 106 | type, | |
| 107 | modelCallId: "model-call", | |
| 108 | callId: "task-call", | |
| 109 | toolCall: { | |
| 110 | ...taskToolCall, | |
| 111 | args: { | |
| 112 | ...taskToolCall.args, | |
| 113 | ...(type === "tool-call-completed" | |
| 114 | ? { model: lateModel ?? undefined } | |
| 115 | : {}), | |
| 116 | }, | |
| 117 | }, | |
| 118 | }).pipe(Effect.orDie); | |
| 119 | } | |
| 120 | return { | |
| 121 | agentId: "native-cursor-lifecycle", | |
| 122 | runId: "native-cursor-run", | |
| 123 | wait: Effect.succeed({ | |
| 124 | id: "native-cursor-run", | |
| 125 | requestId: "native-request", | |
| 126 | status, | |
| 127 | model: { id: "composer-2.5" }, | |
| 128 | durationMs: 1, | |
| 129 | }), | |
| 130 | cancel: Effect.void, | |
| 131 | }; | |
| 132 | }), | |
| 133 | }), | |
| 134 | }, | |
| 135 | }); | |
| 136 | const runtime = yield* adapter.openSession({ | |
| 137 | threadId, | |
| 138 | providerSessionId: ProviderSessionId.make("cursor-lifecycle-session"), | |
| 139 | modelSelection, | |
| 140 | runtimePolicy, | |
| 141 | }); | |
| 142 | const providerThread = yield* runtime.ensureThread({ | |
| 143 | threadId, | |
| 144 | modelSelection, | |
| 145 | runtimePolicy, | |
| 146 | }); | |
| 147 | const now = yield* DateTime.now; | |
| 148 | yield* runtime.startTurn({ | |
| 149 | threadId, | |
| 150 | providerThread, | |
| 151 | modelSelection, | |
| 152 | runtimePolicy, | |
| 153 | runId: RunId.make("cursor-lifecycle-run"), | |
| 154 | runOrdinal: 1, | |
| 155 | providerTurnOrdinal: 1, | |
| 156 | attemptId: RunAttemptId.make("cursor-lifecycle-attempt"), | |
| 157 | rootNodeId: NodeId.make("cursor-lifecycle-root"), | |
| 158 | appThread: { | |
| 159 | id: threadId, | |
| 160 | projectId: ProjectId.make("cursor-lifecycle-project"), | |
| 161 | createdBy: "user", | |
| 162 | creationSource: "web", | |
| 163 | title: "Cursor lifecycle", | |
| 164 | providerInstanceId: instanceId, | |
| 165 | modelSelection, | |
| 166 | runtimeMode: "full-access", | |
| 167 | interactionMode: "default", | |
| 168 | branch: null, | |
| 169 | worktreePath: null, | |
| 170 | activeProviderThreadId: providerThread.id, | |
| 171 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 172 | forkedFrom: null, | |
| 173 | createdAt: now, | |
| 174 | updatedAt: now, | |
| 175 | archivedAt: null, | |
| 176 | settledOverride: null, | |
| 177 | settledAt: null, | |
| 178 | lastVisitedAt: null, | |
| 179 | deletedAt: null, | |
| 180 | }, | |
| 181 | message: { | |
| 182 | messageId: MessageId.make("cursor-lifecycle-message"), | |
| 183 | createdBy: "user", | |
| 184 | creationSource: "web", | |
| 185 | text: "Review the code.", | |
| 186 | attachments: [], | |
| 187 | }, | |
| 188 | }); | |
| 189 | const events = yield* runtime.events.pipe( | |
| 190 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 191 | Stream.runCollect, | |
| 192 | ); | |
| 193 | const rows = events.filter((event) => event.type === "subagent.updated"); | |
| 194 | assert.equal(rows[0]?.subagent.status, "running"); | |
| 195 | assert.equal(rows[0]?.subagent.model, model ?? null); | |
| 196 | assert.equal(rows.at(-1)?.subagent.model, lateModel ?? model ?? null); | |
| 197 | assert.equal( | |
| 198 | rows.at(-1)?.subagent.status, | |
| 199 | lateModel !== undefined | |
| 200 | ? "completed" | |
| 201 | : status === "finished" | |
| 202 | ? "idle" | |
| 203 | : status === "cancelled" | |
| 204 | ? "cancelled" | |
| 205 | : "failed", | |
| 206 | ); | |
| 207 | assert.isNotNull(rows.at(-1)?.subagent.completedAt); | |
| 208 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), | |
| 209 | ); | |
| 210 | ||
| 211 | it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => | |
| 212 | Effect.gen(function* () { | |
| 213 | const fileSystem = yield* FileSystem.FileSystem; | |
| 214 | const path = yield* Path.Path; | |
| 215 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-errors-" }); | |
| 216 | const sentMessages: Array<string> = []; | |
| 217 | let sendCalls = 0; | |
| 218 | const instanceId = ProviderInstanceId.make("cursor"); | |
| 219 | const threadId = ThreadId.make("cursor-transport-error-thread"); | |
| 220 | const modelSelection = { instanceId, model: "composer-2.5" }; | |
| 221 | const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ | |
| 222 | runtimeMode: "full-access", | |
| 223 | interactionMode: "default", | |
| 224 | cwd: workspace, | |
| 225 | }); | |
| 226 | const adapter = makeCursorAdapterV2({ | |
| 227 | instanceId, | |
| 228 | settings: yield* decodeCursorSettings({}), | |
| 229 | environment: { HOME: workspace }, | |
| 230 | fileSystem, | |
| 231 | path, | |
| 232 | idAllocator: yield* IdAllocator.IdAllocatorV2, | |
| 233 | host: yield* ProviderHost.ProviderHost.pipe( | |
| 234 | Effect.provide(layerTestProviderHost({ cwd: workspace })), | |
| 235 | ), | |
| 236 | runner: { | |
| 237 | assertComplete: Effect.void, | |
| 238 | open: () => | |
| 239 | Effect.succeed({ | |
| 240 | agentId: "native-cursor-error", | |
| 241 | listMessages: Effect.succeed([]), | |
| 242 | close: Effect.void, | |
| 243 | send: (input) => | |
| 244 | Effect.sync(() => { | |
| 245 | sendCalls += 1; | |
| 246 | sentMessages.push( | |
| 247 | typeof input.message === "string" ? input.message : input.message.text, | |
| 248 | ); | |
| 249 | const runId = `native-cursor-run-${sendCalls}`; | |
| 250 | return { | |
| 251 | agentId: "native-cursor-error", | |
| 252 | runId, | |
| 253 | wait: Effect.succeed({ | |
| 254 | id: runId, | |
| 255 | requestId: `native-request-${sendCalls}`, | |
| 256 | status: "finished" as const, | |
| 257 | model: { id: "composer-2.5" }, | |
| 258 | durationMs: 1, | |
| 259 | ...(sendCalls === 1 | |
| 260 | ? { result: "Error: RetriableError: WritableIterable is closed" } | |
| 261 | : {}), | |
| 262 | }), | |
| 263 | cancel: Effect.void, | |
| 264 | }; | |
| 265 | }), | |
| 266 | }), | |
| 267 | }, | |
| 268 | }); | |
| 269 | const runtime = yield* adapter.openSession({ | |
| 270 | threadId, | |
| 271 | providerSessionId: ProviderSessionId.make("cursor-error-session"), | |
| 272 | modelSelection, | |
| 273 | runtimePolicy, | |
| 274 | }); | |
| 275 | const providerThread = yield* runtime.ensureThread({ | |
| 276 | threadId, | |
| 277 | modelSelection, | |
| 278 | runtimePolicy, | |
| 279 | }); | |
| 280 | const now = yield* DateTime.now; | |
| 281 | const appThread = { | |
| 282 | id: threadId, | |
| 283 | projectId: ProjectId.make("cursor-error-project"), | |
| 284 | createdBy: "user" as const, | |
| 285 | creationSource: "web" as const, | |
| 286 | title: "Cursor transport failure", | |
| 287 | providerInstanceId: instanceId, | |
| 288 | modelSelection, | |
| 289 | runtimeMode: "full-access" as const, | |
| 290 | interactionMode: "default" as const, | |
| 291 | branch: null, | |
| 292 | worktreePath: null, | |
| 293 | activeProviderThreadId: providerThread.id, | |
| 294 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 295 | forkedFrom: null, | |
| 296 | createdAt: now, | |
| 297 | updatedAt: now, | |
| 298 | archivedAt: null, | |
| 299 | settledOverride: null, | |
| 300 | settledAt: null, | |
| 301 | lastVisitedAt: null, | |
| 302 | deletedAt: null, | |
| 303 | }; | |
| 304 | yield* runtime.startTurn({ | |
| 305 | threadId, | |
| 306 | providerThread, | |
| 307 | modelSelection, | |
| 308 | runtimePolicy, | |
| 309 | runId: RunId.make("cursor-error-run"), | |
| 310 | runOrdinal: 1, | |
| 311 | providerTurnOrdinal: 1, | |
| 312 | attemptId: RunAttemptId.make("cursor-error-attempt"), | |
| 313 | rootNodeId: NodeId.make("cursor-error-root"), | |
| 314 | appThread, | |
| 315 | message: { | |
| 316 | messageId: MessageId.make("cursor-error-message"), | |
| 317 | createdBy: "user", | |
| 318 | creationSource: "web", | |
| 319 | text: "continue", | |
| 320 | attachments: [], | |
| 321 | }, | |
| 322 | }); | |
| 323 | const first = yield* runtime.events.pipe( | |
| 324 | Stream.filter((event) => event.type === "turn.terminal"), | |
| 325 | Stream.runHead, | |
| 326 | ); | |
| 327 | assert.isTrue(Option.isSome(first)); | |
| 328 | if (Option.isSome(first)) { | |
| 329 | assert.equal(first.value.status, "failed"); | |
| 330 | assert.equal(first.value.failure?.class, "transport_error"); | |
| 331 | } | |
| 332 | ||
| 333 | assert.isDefined(runtime.compactThread); | |
| 334 | yield* runtime.compactThread!({ | |
| 335 | threadId, | |
| 336 | providerThread, | |
| 337 | modelSelection, | |
| 338 | runtimePolicy, | |
| 339 | runId: RunId.make("cursor-compact-run"), | |
| 340 | runOrdinal: 2, | |
| 341 | providerTurnOrdinal: 2, | |
| 342 | attemptId: RunAttemptId.make("cursor-compact-attempt"), | |
| 343 | rootNodeId: NodeId.make("cursor-compact-root"), | |
| 344 | appThread, | |
| 345 | message: { | |
| 346 | messageId: MessageId.make("cursor-compact-message"), | |
| 347 | createdBy: "user", | |
| 348 | creationSource: "web", | |
| 349 | text: "ignored by compactThread", | |
| 350 | attachments: [], | |
| 351 | }, | |
| 352 | }); | |
| 353 | yield* runtime.events.pipe( | |
| 354 | Stream.filter((event) => event.type === "turn.terminal"), | |
| 355 | Stream.runHead, | |
| 356 | ); | |
| 357 | assert.equal(sentMessages[1], "/compress"); | |
| 358 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), | |
| 359 | ); | |
| 360 | ||
| 361 | it.effect("projects Cursor directory trees and lint diagnostics as file search results", () => | |
| 362 | Effect.gen(function* () { | |
| 363 | const fileSystem = yield* FileSystem.FileSystem; | |
| 364 | const path = yield* Path.Path; | |
| 365 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-search-" }); | |
| 366 | const instanceId = ProviderInstanceId.make("cursor"); | |
| 367 | const threadId = ThreadId.make("cursor-search-thread"); | |
| 368 | const modelSelection = { instanceId, model: "composer-2.5" }; | |
| 369 | const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ | |
| 370 | runtimeMode: "full-access", | |
| 371 | interactionMode: "default", | |
| 372 | cwd: workspace, | |
| 373 | }); | |
| 374 | const emptyLsNode = { | |
| 375 | childrenDirs: [], | |
| 376 | childrenFiles: [], | |
| 377 | childrenWereProcessed: true, | |
| 378 | fullSubtreeExtensionCounts: {}, | |
| 379 | numFiles: 0, | |
| 380 | }; | |
| 381 | const updates: ReadonlyArray<InteractionUpdate> = [ | |
| 382 | ...(["tool-call-started", "tool-call-completed"] as const).map((type) => ({ | |
| 383 | type, | |
| 384 | modelCallId: "native-model-call", | |
| 385 | callId: "mcp-weather", | |
| 386 | toolCall: { | |
| 387 | type: "mcp" as const, | |
| 388 | args: { | |
| 389 | providerIdentifier: "weather", | |
| 390 | toolName: "get_weather", | |
| 391 | args: { city: "Berlin" }, | |
| 392 | }, | |
| 393 | ...(type === "tool-call-completed" | |
| 394 | ? { result: { status: "success" as const, value: { content: [], isError: false } } } | |
| 395 | : {}), | |
| 396 | }, | |
| 397 | })), | |
| 398 | { | |
| 399 | type: "tool-call-completed", | |
| 400 | modelCallId: "native-model-call", | |
| 401 | callId: "read-file", | |
| 402 | toolCall: { | |
| 403 | type: "read", | |
| 404 | args: { path: "src/env.ts" }, | |
| 405 | result: { | |
| 406 | status: "success", | |
| 407 | value: { fileSize: 12, content: "---\nfile body", totalLines: 2 }, | |
| 408 | }, | |
| 409 | }, | |
| 410 | }, | |
| 411 | { | |
| 412 | type: "tool-call-completed", | |
| 413 | modelCallId: "native-model-call", | |
| 414 | callId: "ls-nested", | |
| 415 | toolCall: { | |
| 416 | type: "ls", | |
| 417 | args: { path: workspace }, | |
| 418 | result: { | |
| 419 | status: "success", | |
| 420 | value: { | |
| 421 | directoryTreeRoot: { | |
| 422 | ...emptyLsNode, | |
| 423 | absPath: workspace, | |
| 424 | childrenFiles: [{ name: "README.md" }], | |
| 425 | childrenDirs: [ | |
| 426 | { | |
| 427 | ...emptyLsNode, | |
| 428 | absPath: path.join(workspace, "src"), | |
| 429 | childrenFiles: [{ name: "index.ts" }], | |
| 430 | childrenDirs: [ | |
| 431 | { | |
| 432 | ...emptyLsNode, | |
| 433 | absPath: path.join(workspace, "src", "nested"), | |
| 434 | childrenFiles: [{ name: "util.ts" }], | |
| 435 | }, | |
| 436 | ], | |
| 437 | }, | |
| 438 | ], | |
| 439 | }, | |
| 440 | }, | |
| 441 | }, | |
| 442 | }, | |
| 443 | }, | |
| 444 | { | |
| 445 | type: "tool-call-completed", | |
| 446 | modelCallId: "native-model-call", | |
| 447 | callId: "ls-empty", | |
| 448 | toolCall: { | |
| 449 | type: "ls", | |
| 450 | args: { path: path.join(workspace, "empty") }, | |
| 451 | result: { | |
| 452 | status: "success", | |
| 453 | value: { | |
| 454 | directoryTreeRoot: { ...emptyLsNode, absPath: path.join(workspace, "empty") }, | |
| 455 | }, | |
| 456 | }, | |
| 457 | }, | |
| 458 | }, | |
| 459 | { | |
| 460 | type: "tool-call-completed", | |
| 461 | modelCallId: "native-model-call", | |
| 462 | callId: "ls-failed", | |
| 463 | toolCall: { | |
| 464 | type: "ls", | |
| 465 | args: { path: path.join(workspace, "missing") }, | |
| 466 | result: { status: "error", error: "ENOENT" }, | |
| 467 | }, | |
| 468 | }, | |
| 469 | { | |
| 470 | type: "tool-call-completed", | |
| 471 | modelCallId: "native-model-call", | |
| 472 | callId: "grep-failed", | |
| 473 | toolCall: { | |
| 474 | type: "grep", | |
| 475 | args: { pattern: "TODO", path: "src" }, | |
| 476 | result: { status: "error", error: "search failed" }, | |
| 477 | }, | |
| 478 | }, | |
| 479 | { | |
| 480 | type: "tool-call-completed", | |
| 481 | modelCallId: "native-model-call", | |
| 482 | callId: "glob-failed", | |
| 483 | toolCall: { | |
| 484 | type: "glob", | |
| 485 | args: { globPattern: "*.ts" }, | |
| 486 | result: { status: "error", error: "search failed" }, | |
| 487 | }, | |
| 488 | }, | |
| 489 | { | |
| 490 | type: "tool-call-completed", | |
| 491 | modelCallId: "native-model-call", | |
| 492 | callId: "lints", | |
| 493 | toolCall: { | |
| 494 | type: "readLints", | |
| 495 | args: { paths: ["src/a.ts", "src/b.ts"] }, | |
| 496 | result: { | |
| 497 | status: "success", | |
| 498 | value: { | |
| 499 | totalFiles: 2, | |
| 500 | totalDiagnostics: 3, | |
| 501 | fileDiagnostics: [ | |
| 502 | { | |
| 503 | path: "src/a.ts", | |
| 504 | diagnosticsCount: 2, | |
| 505 | diagnostics: [ | |
| 506 | { | |
| 507 | message: "Unused variable", | |
| 508 | code: "TS6133", | |
| 509 | source: "ts", | |
| 510 | severity: "warning", | |
| 511 | range: { start: { line: 0, character: 4 } }, | |
| 512 | }, | |
| 513 | { | |
| 514 | message: "Cannot find name", | |
| 515 | code: "TS2304", | |
| 516 | source: "ts", | |
| 517 | severity: "error", | |
| 518 | range: { start: { line: 11 } }, | |
| 519 | }, | |
| 520 | ], | |
| 521 | }, | |
| 522 | { | |
| 523 | path: "src/b.ts", | |
| 524 | diagnosticsCount: 1, | |
| 525 | diagnostics: [ | |
| 526 | { | |
| 527 | message: "File-level diagnostic", | |
| 528 | code: "TS0", | |
| 529 | source: "ts", | |
| 530 | severity: "information", | |
| 531 | }, | |
| 532 | ], | |
| 533 | }, | |
| 534 | ], | |
| 535 | }, | |
| 536 | }, | |
| 537 | }, | |
| 538 | }, | |
| 539 | { | |
| 540 | type: "tool-call-completed", | |
| 541 | modelCallId: "native-model-call", | |
| 542 | callId: "lints-empty", | |
| 543 | toolCall: { | |
| 544 | type: "readLints", | |
| 545 | args: { paths: ["src/clean.ts"] }, | |
| 546 | result: { | |
| 547 | status: "success", | |
| 548 | value: { | |
| 549 | totalFiles: 1, | |
| 550 | totalDiagnostics: 0, | |
| 551 | fileDiagnostics: [{ path: "src/clean.ts", diagnosticsCount: 0, diagnostics: [] }], | |
| 552 | }, | |
| 553 | }, | |
| 554 | }, | |
| 555 | }, | |
| 556 | { | |
| 557 | type: "tool-call-completed", | |
| 558 | modelCallId: "native-model-call", | |
| 559 | callId: "lints-failed", | |
| 560 | toolCall: { | |
| 561 | type: "readLints", | |
| 562 | args: { paths: ["src/a.ts"] }, | |
| 563 | result: { status: "error", error: "lint failed" }, | |
| 564 | }, | |
| 565 | }, | |
| 566 | ]; | |
| 567 | const adapter = makeCursorAdapterV2({ | |
| 568 | instanceId, | |
| 569 | settings: yield* decodeCursorSettings({}), | |
| 570 | environment: { HOME: workspace }, | |
| 571 | fileSystem, | |
| 572 | path, | |
| 573 | idAllocator: yield* IdAllocator.IdAllocatorV2, | |
| 574 | host: yield* ProviderHost.ProviderHost.pipe( | |
| 575 | Effect.provide(layerTestProviderHost({ cwd: workspace })), | |
| 576 | ), | |
| 577 | runner: { | |
| 578 | assertComplete: Effect.void, | |
| 579 | open: () => | |
| 580 | Effect.succeed({ | |
| 581 | agentId: "native-cursor-search", | |
| 582 | listMessages: Effect.succeed([]), | |
| 583 | close: Effect.void, | |
| 584 | send: (input) => | |
| 585 | Effect.gen(function* () { | |
| 586 | for (const update of updates) { | |
| 587 | if (input.onDelta !== undefined) { | |
| 588 | yield* input.onDelta(update).pipe(Effect.orDie); | |
| 589 | } | |
| 590 | } | |
| 591 | return { | |
| 592 | agentId: "native-cursor-search", | |
| 593 | runId: "native-cursor-run", | |
| 594 | wait: Effect.succeed({ | |
| 595 | id: "native-cursor-run", | |
| 596 | requestId: "native-request", | |
| 597 | status: "finished" as const, | |
| 598 | model: { id: "composer-2.5" }, | |
| 599 | durationMs: 1, | |
| 600 | }), | |
| 601 | cancel: Effect.void, | |
| 602 | }; | |
| 603 | }), | |
| 604 | }), | |
| 605 | }, | |
| 606 | }); | |
| 607 | const runtime = yield* adapter.openSession({ | |
| 608 | threadId, | |
| 609 | providerSessionId: ProviderSessionId.make("cursor-search-session"), | |
| 610 | modelSelection, | |
| 611 | runtimePolicy, | |
| 612 | }); | |
| 613 | const providerThread = yield* runtime.ensureThread({ | |
| 614 | threadId, | |
| 615 | modelSelection, | |
| 616 | runtimePolicy, | |
| 617 | }); | |
| 618 | const now = yield* DateTime.now; | |
| 619 | yield* runtime.startTurn({ | |
| 620 | threadId, | |
| 621 | providerThread, | |
| 622 | modelSelection, | |
| 623 | runtimePolicy, | |
| 624 | runId: RunId.make("cursor-search-run"), | |
| 625 | runOrdinal: 1, | |
| 626 | providerTurnOrdinal: 1, | |
| 627 | attemptId: RunAttemptId.make("cursor-search-attempt"), | |
| 628 | rootNodeId: NodeId.make("cursor-search-root"), | |
| 629 | appThread: { | |
| 630 | id: threadId, | |
| 631 | projectId: ProjectId.make("cursor-search-project"), | |
| 632 | createdBy: "user", | |
| 633 | creationSource: "web", | |
| 634 | title: "Cursor search results", | |
| 635 | providerInstanceId: instanceId, | |
| 636 | modelSelection, | |
| 637 | runtimeMode: "full-access", | |
| 638 | interactionMode: "default", | |
| 639 | branch: null, | |
| 640 | worktreePath: null, | |
| 641 | activeProviderThreadId: providerThread.id, | |
| 642 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 643 | forkedFrom: null, | |
| 644 | createdAt: now, | |
| 645 | updatedAt: now, | |
| 646 | archivedAt: null, | |
| 647 | settledOverride: null, | |
| 648 | settledAt: null, | |
| 649 | lastVisitedAt: null, | |
| 650 | deletedAt: null, | |
| 651 | }, | |
| 652 | message: { | |
| 653 | messageId: MessageId.make("cursor-search-message"), | |
| 654 | createdBy: "user", | |
| 655 | creationSource: "web", | |
| 656 | text: "inspect the workspace", | |
| 657 | attachments: [], | |
| 658 | }, | |
| 659 | }); | |
| 660 | const events = yield* runtime.events.pipe( | |
| 661 | Stream.takeUntil((event) => event.type === "turn.terminal"), | |
| 662 | Stream.runCollect, | |
| 663 | ); | |
| 664 | const mcpItems = events.flatMap((event) => | |
| 665 | event.type === "turn_item.updated" && | |
| 666 | event.turnItem.type === "dynamic_tool" && | |
| 667 | event.turnItem.toolName === "mcp__weather__get_weather" | |
| 668 | ? [event.turnItem] | |
| 669 | : [], | |
| 670 | ); | |
| 671 | assert.deepEqual( | |
| 672 | mcpItems.map((item) => item.status), | |
| 673 | ["running", "completed"], | |
| 674 | ); | |
| 675 | for (const item of mcpItems) { | |
| 676 | assert.equal(item.title, "get weather"); | |
| 677 | assert.deepEqual(item.toolSource, { | |
| 678 | key: "mcp:weather", | |
| 679 | name: "weather", | |
| 680 | kind: "integration", | |
| 681 | }); | |
| 682 | assert.deepEqual(item.input, { | |
| 683 | providerIdentifier: "weather", | |
| 684 | toolName: "get_weather", | |
| 685 | args: { city: "Berlin" }, | |
| 686 | }); | |
| 687 | } | |
| 688 | const fileSearchItems = events.flatMap((event) => | |
| 689 | event.type === "turn_item.updated" && | |
| 690 | event.turnItem.type === "file_search" && | |
| 691 | event.turnItem.status !== "running" | |
| 692 | ? [event.turnItem] | |
| 693 | : [], | |
| 694 | ); | |
| 695 | const readItems = events.flatMap((event) => | |
| 696 | event.type === "turn_item.updated" && | |
| 697 | event.turnItem.type === "dynamic_tool" && | |
| 698 | event.turnItem.toolName === "Read" && | |
| 699 | event.turnItem.status === "completed" | |
| 700 | ? [event.turnItem] | |
| 701 | : [], | |
| 702 | ); | |
| 703 | assert.deepEqual( | |
| 704 | readItems.map((item) => ({ title: item.title, input: item.input })), | |
| 705 | [{ title: "Read src/env.ts", input: { path: "src/env.ts" } }], | |
| 706 | ); | |
| 707 | assert.deepEqual( | |
| 708 | fileSearchItems.map((item) => ({ | |
| 709 | pattern: item.pattern, | |
| 710 | status: item.status, | |
| 711 | results: item.results, | |
| 712 | })), | |
| 713 | [ | |
| 714 | { | |
| 715 | pattern: workspace, | |
| 716 | status: "completed", | |
| 717 | results: [ | |
| 718 | { fileName: path.join(workspace, "README.md") }, | |
| 719 | { fileName: path.join(workspace, "src", "index.ts") }, | |
| 720 | { fileName: path.join(workspace, "src", "nested", "util.ts") }, | |
| 721 | ], | |
| 722 | }, | |
| 723 | { | |
| 724 | pattern: path.join(workspace, "empty"), | |
| 725 | status: "completed", | |
| 726 | results: undefined, | |
| 727 | }, | |
| 728 | { | |
| 729 | pattern: path.join(workspace, "missing"), | |
| 730 | status: "failed", | |
| 731 | results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }], | |
| 732 | }, | |
| 733 | { | |
| 734 | pattern: "TODO", | |
| 735 | status: "failed", | |
| 736 | results: [{ fileName: "src", preview: "search failed" }], | |
| 737 | }, | |
| 738 | { | |
| 739 | pattern: "*.ts", | |
| 740 | status: "failed", | |
| 741 | results: [{ fileName: ".", preview: "search failed" }], | |
| 742 | }, | |
| 743 | { | |
| 744 | pattern: "src/a.ts, src/b.ts", | |
| 745 | status: "completed", | |
| 746 | results: [ | |
| 747 | { fileName: "src/a.ts", line: 1, column: 5, preview: "Unused variable" }, | |
| 748 | { fileName: "src/a.ts", line: 12, preview: "Cannot find name" }, | |
| 749 | { fileName: "src/b.ts", preview: "File-level diagnostic" }, | |
| 750 | ], | |
| 751 | }, | |
| 752 | { | |
| 753 | pattern: "src/clean.ts", | |
| 754 | status: "completed", | |
| 755 | results: undefined, | |
| 756 | }, | |
| 757 | { | |
| 758 | pattern: "src/a.ts", | |
| 759 | status: "failed", | |
| 760 | results: [{ fileName: "src/a.ts", preview: "lint failed" }], | |
| 761 | }, | |
| 762 | ], | |
| 763 | ); | |
| 764 | }).pipe(Effect.scoped, Effect.provide(Layer.merge(NodeServices.layer, IdAllocator.layer))), | |
| 765 | ); | |
| 766 | ||
| 767 | it("maps Cursor auto and model parameters to SDK selections", () => { | |
| 768 | assert.deepEqual( | |
| 769 | cursorSdkModelSelection({ | |
| 770 | instanceId: ProviderInstanceId.make("cursor"), | |
| 771 | model: "auto", | |
| 772 | options: [ | |
| 773 | { id: "thinking", value: "high" }, | |
| 774 | { id: "contextWindow", value: "1m" }, | |
| 775 | { id: "fastMode", value: true }, | |
| 776 | ], | |
| 777 | }), | |
| 778 | { | |
| 779 | id: "default", | |
| 780 | params: [ | |
| 781 | { id: "thinking", value: "high" }, | |
| 782 | { id: "context", value: "1m" }, | |
| 783 | { id: "fast", value: "true" }, | |
| 784 | ], | |
| 785 | }, | |
| 786 | ); | |
| 787 | }); | |
| 788 | ||
| 789 | it("maps runtime modes to the SDK sandbox and auto-review controls", () => { | |
| 790 | const base = { | |
| 791 | interactionMode: "default" as const, | |
| 792 | cwd: "/tmp/cursor-adapter", | |
| 793 | }; | |
| 794 | assert.deepEqual( | |
| 795 | cursorRuntimeAgentPolicy({ | |
| 796 | ...base, | |
| 797 | runtimeMode: "full-access", | |
| 798 | }), | |
| 799 | { | |
| 800 | autoReview: false, | |
| 801 | sandboxEnabled: false, | |
| 802 | }, | |
| 803 | ); | |
| 804 | assert.deepEqual( | |
| 805 | cursorRuntimeAgentPolicy({ | |
| 806 | ...base, | |
| 807 | runtimeMode: "auto-accept-edits", | |
| 808 | }), | |
| 809 | { | |
| 810 | autoReview: false, | |
| 811 | sandboxEnabled: true, | |
| 812 | }, | |
| 813 | ); | |
| 814 | assert.deepEqual( | |
| 815 | cursorRuntimeAgentPolicy({ | |
| 816 | ...base, | |
| 817 | runtimeMode: "approval-required", | |
| 818 | }), | |
| 819 | { | |
| 820 | autoReview: true, | |
| 821 | sandboxEnabled: true, | |
| 822 | }, | |
| 823 | ); | |
| 824 | assert.deepEqual( | |
| 825 | cursorRuntimeAgentPolicy({ | |
| 826 | ...base, | |
| 827 | runtimeMode: "full-access", | |
| 828 | approvalPolicy: "never", | |
| 829 | sandboxPolicy: { type: "readOnly" }, | |
| 830 | }), | |
| 831 | { | |
| 832 | autoReview: false, | |
| 833 | sandboxEnabled: true, | |
| 834 | }, | |
| 835 | ); | |
| 836 | assert.deepEqual( | |
| 837 | cursorRuntimeAgentPolicy({ | |
| 838 | ...base, | |
| 839 | runtimeMode: "approval-required", | |
| 840 | approvalPolicy: "never", | |
| 841 | sandboxPolicy: { type: "dangerFullAccess" }, | |
| 842 | }), | |
| 843 | { | |
| 844 | autoReview: false, | |
| 845 | sandboxEnabled: false, | |
| 846 | }, | |
| 847 | ); | |
| 848 | }); | |
| 849 | ||
| 850 | it("loads the user's Cursor settings layers in every runtime mode", () => { | |
| 851 | // The SDK loads no rules, skills, hooks, or MCP config from disk unless | |
| 852 | // settingSources names them, so an omitted list silently drops AGENTS.md. | |
| 853 | for (const runtimeMode of ["full-access", "auto-accept-edits", "approval-required"] as const) { | |
| 854 | const options = makeCursorAgentOptions({ | |
| 855 | modelSelection: { | |
| 856 | instanceId: ProviderInstanceId.make("cursor"), | |
| 857 | model: "composer-2.5", | |
| 858 | }, | |
| 859 | runtimePolicy: { runtimeMode, interactionMode: "default", cwd: "/workspace" }, | |
| 860 | threadId: ThreadId.make("thread-cursor-setting-sources"), | |
| 861 | }); | |
| 862 | assert.deepEqual(options.local?.settingSources, [ | |
| 863 | "project", | |
| 864 | "user", | |
| 865 | "team", | |
| 866 | "mdm", | |
| 867 | "plugins", | |
| 868 | ]); | |
| 869 | } | |
| 870 | }); | |
| 871 | ||
| 872 | it("injects thread-scoped MCP credentials without logging them", () => { | |
| 873 | const threadId = ThreadId.make("thread-cursor-mcp"); | |
| 874 | McpProviderSession.setMcpProviderSession({ | |
| 875 | environmentId: EnvironmentId.make("environment-cursor-mcp"), | |
| 876 | threadId, | |
| 877 | providerSessionId: "mcp-session-cursor", | |
| 878 | providerInstanceId: ProviderInstanceId.make("cursor"), | |
| 879 | endpoint: "http://127.0.0.1:43123/mcp", | |
| 880 | authorizationHeader: "Bearer secret-cursor-mcp-token", | |
| 881 | browserToolsAvailable: true, | |
| 882 | }); | |
| 883 | ||
| 884 | try { | |
| 885 | assert.deepEqual(cursorMcpServers(threadId), { | |
| 886 | "t3-code": { | |
| 887 | type: "http", | |
| 888 | url: "http://127.0.0.1:43123/mcp", | |
| 889 | headers: { | |
| 890 | Authorization: "Bearer secret-cursor-mcp-token", | |
| 891 | }, | |
| 892 | }, | |
| 893 | }); | |
| 894 | ||
| 895 | const options = makeCursorAgentOptions({ | |
| 896 | apiKey: "secret-cursor-api-key", | |
| 897 | modelSelection: { | |
| 898 | instanceId: ProviderInstanceId.make("cursor"), | |
| 899 | model: "composer-2.5", | |
| 900 | }, | |
| 901 | runtimePolicy: { | |
| 902 | runtimeMode: "full-access", | |
| 903 | interactionMode: "default", | |
| 904 | cwd: "/workspace", | |
| 905 | }, | |
| 906 | threadId, | |
| 907 | }); | |
| 908 | assert.deepEqual(options.mcpServers, cursorMcpServers(threadId)); | |
| 909 | ||
| 910 | const logged = JSON.stringify(CursorAgentSdk.loggedCursorAgentOptions(options)); | |
| 911 | assert.notInclude(logged, "secret-cursor-api-key"); | |
| 912 | assert.notInclude(logged, "secret-cursor-mcp-token"); | |
| 913 | } finally { | |
| 914 | McpProviderSession.clearMcpProviderSession(threadId); | |
| 915 | } | |
| 916 | }); | |
| 917 | ||
| 918 | it("recognizes direct and SDK-wrapped abort failures as cancellation", () => { | |
| 919 | assert.isTrue(CursorAgentSdk.isCursorCancellationError({ name: "AbortError" })); | |
| 920 | assert.isTrue( | |
| 921 | CursorAgentSdk.isCursorCancellationError({ | |
| 922 | name: "ConnectError", | |
| 923 | cause: { | |
| 924 | name: "ConnectError", | |
| 925 | cause: { name: "AbortError" }, | |
| 926 | }, | |
| 927 | }), | |
| 928 | ); | |
| 929 | assert.isFalse(CursorAgentSdk.isCursorCancellationError(new Error("request failed"))); | |
| 930 | assert.isFalse(CursorAgentSdk.isCursorCancellationError(null)); | |
| 931 | }); | |
| 932 | ||
| 933 | it("preserves failed nested read calls when Cursor omits their path", () => { | |
| 934 | assert.deepEqual( | |
| 935 | nestedToolCallFromEnvelope({ | |
| 936 | toolCallId: "tool:failed-read", | |
| 937 | readToolCall: { | |
| 938 | args: {}, | |
| 939 | result: { error: "File path was not provided." }, | |
| 940 | }, | |
| 941 | }), | |
| 942 | { | |
| 943 | callId: "tool:failed-read", | |
| 944 | toolCall: { | |
| 945 | type: "read", | |
| 946 | args: { path: "<unknown path>" }, | |
| 947 | result: { | |
| 948 | status: "error", | |
| 949 | error: "File path was not provided.", | |
| 950 | }, | |
| 951 | }, | |
| 952 | }, | |
| 953 | ); | |
| 954 | }); | |
| 955 | }); |
packages/provider-cursor/src/server/adapter.ts created+2680| ... | ... | @@ -0,0 +1,2680 @@ |
| 1 | import type { | |
| 2 | AgentMessage, | |
| 3 | AgentOptions, | |
| 4 | InteractionUpdate, | |
| 5 | McpServerConfig, | |
| 6 | RunResult, | |
| 7 | SDKUserMessage, | |
| 8 | SettingSource, | |
| 9 | ToolCall, | |
| 10 | } from "@cursor/sdk"; | |
| 11 | import { formatReadToolLabel, formatSearchToolLabel } from "@t3tools/shared/toolActivity"; | |
| 12 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | |
| 13 | import { | |
| 14 | isOrchestrationV2WorkActive, | |
| 15 | defaultInstanceIdForDriver, | |
| 16 | type ChatAttachment, | |
| 17 | type ModelSelection, | |
| 18 | type OrchestrationV2ConversationMessage, | |
| 19 | type OrchestrationV2ExecutionNode, | |
| 20 | type OrchestrationV2PlanArtifact, | |
| 21 | type OrchestrationV2PlanStep, | |
| 22 | type OrchestrationV2ProviderCapabilities, | |
| 23 | type OrchestrationV2ProviderFailure, | |
| 24 | type OrchestrationV2ProviderSession, | |
| 25 | type OrchestrationV2ProviderThread, | |
| 26 | type OrchestrationV2ProviderTurn, | |
| 27 | type OrchestrationV2Subagent, | |
| 28 | type OrchestrationV2TurnItem, | |
| 29 | type ProviderInstanceId, | |
| 30 | type ThreadId, | |
| 31 | } from "@t3tools/contracts"; | |
| 32 | import { CursorSettings } from "../settings.ts"; | |
| 33 | import * as DateTime from "effect/DateTime"; | |
| 34 | import * as Deferred from "effect/Deferred"; | |
| 35 | import * as Effect from "effect/Effect"; | |
| 36 | import * as FileSystem from "effect/FileSystem"; | |
| 37 | import * as Layer from "effect/Layer"; | |
| 38 | import * as Option from "effect/Option"; | |
| 39 | import * as Path from "effect/Path"; | |
| 40 | import * as Queue from "effect/Queue"; | |
| 41 | import * as Ref from "effect/Ref"; | |
| 42 | import * as Schema from "effect/Schema"; | |
| 43 | import * as Stream from "effect/Stream"; | |
| 44 | ||
| 45 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 46 | import { mcpToolPresentation } from "@t3tools/provider-core/server/mcpToolPresentation"; | |
| 47 | import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; | |
| 48 | import { CursorTransportFailure } from "./transportFailure.ts"; | |
| 49 | import { cursorSdkModelSelection } from "./sdkModel.ts"; | |
| 50 | import { | |
| 51 | discoverCursorSkills, | |
| 52 | hasCursorSkillMention, | |
| 53 | rewriteCursorSkillMentions, | |
| 54 | } from "./skills.ts"; | |
| 55 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 56 | import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions"; | |
| 57 | import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; | |
| 58 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 59 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; | |
| 60 | import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; | |
| 61 | import { | |
| 62 | isProviderNativeImageAttachment, | |
| 63 | providerMessageTextWithAttachmentPaths, | |
| 64 | } from "@t3tools/provider-core/server/attachmentPrompt"; | |
| 65 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 66 | import { | |
| 67 | ProviderAdapterDriverCreateError, | |
| 68 | type ProviderAdapterDriver, | |
| 69 | type ProviderAdapterDriverCreateInput, | |
| 70 | } from "@t3tools/provider-core/server/adapterDriver"; | |
| 71 | import { | |
| 72 | makeSubagentChildThread, | |
| 73 | makeSubagentConversationArtifacts, | |
| 74 | subagentThreadTitle, | |
| 75 | } from "@t3tools/provider-core/server/subagentProjection"; | |
| 76 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | |
| 77 | export { cursorSdkModelSelection } from "./sdkModel.ts"; | |
| 78 | ||
| 79 | export const CURSOR_DRIVER_KIND = CursorAgentSdk.CURSOR_PROVIDER; | |
| 80 | export const CURSOR_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CURSOR_DRIVER_KIND); | |
| 81 | const DEFAULT_CURSOR_SETTINGS = Schema.decodeSync(CursorSettings)({}); | |
| 82 | ||
| 83 | export const CursorProviderCapabilitiesV2 = { | |
| 84 | sessions: { | |
| 85 | supportsMultipleProviderThreadsPerSession: false, | |
| 86 | supportsModelSwitchInSession: true, | |
| 87 | supportsProviderSwitchingViaHandoff: true, | |
| 88 | supportsRuntimeModeSwitchInSession: false, | |
| 89 | pendingRequestsSurviveRestart: false, | |
| 90 | }, | |
| 91 | threads: { | |
| 92 | canCreateEmptyThread: true, | |
| 93 | canReadThreadSnapshot: true, | |
| 94 | canRollbackThread: false, | |
| 95 | canForkThread: false, | |
| 96 | canForkFromTurn: false, | |
| 97 | canForkFromSubagentThread: false, | |
| 98 | exposesNativeThreadId: true, | |
| 99 | }, | |
| 100 | turns: { | |
| 101 | exposesNativeTurnId: true, | |
| 102 | emitsTurnStarted: true, | |
| 103 | emitsTurnCompleted: true, | |
| 104 | supportsInterrupt: true, | |
| 105 | supportsActiveSteering: false, | |
| 106 | supportsSteeringByInterruptRestart: true, | |
| 107 | supportsQueuedMessages: true, | |
| 108 | terminalStatusQuality: "strong", | |
| 109 | }, | |
| 110 | streaming: { | |
| 111 | streamsAssistantText: true, | |
| 112 | streamsReasoning: true, | |
| 113 | streamsToolOutput: true, | |
| 114 | streamsPlanText: true, | |
| 115 | emitsMessageCompleted: true, | |
| 116 | }, | |
| 117 | tools: { | |
| 118 | exposesToolItemIds: true, | |
| 119 | emitsToolStarted: true, | |
| 120 | emitsToolCompleted: true, | |
| 121 | emitsToolOutput: true, | |
| 122 | supportsMcpTools: true, | |
| 123 | supportsDynamicToolCallbacks: false, | |
| 124 | }, | |
| 125 | approvals: { | |
| 126 | supportsCommandApproval: false, | |
| 127 | supportsFileReadApproval: false, | |
| 128 | supportsFileChangeApproval: false, | |
| 129 | supportsApplyPatchApproval: false, | |
| 130 | approvalsHaveNativeRequestIds: false, | |
| 131 | approvalCallbacksAreLiveOnly: false, | |
| 132 | approvalsCanOriginateFromSubagents: false, | |
| 133 | }, | |
| 134 | planning: { | |
| 135 | emitsPlanUpdated: true, | |
| 136 | emitsTodoList: true, | |
| 137 | emitsProposedPlan: true, | |
| 138 | supportsStructuredQuestions: false, | |
| 139 | planDeltasHaveItemIds: true, | |
| 140 | }, | |
| 141 | subagents: { | |
| 142 | supportsSubagents: true, | |
| 143 | exposesSubagentThreadIds: false, | |
| 144 | emitsSubagentLifecycle: true, | |
| 145 | canWaitForSubagents: true, | |
| 146 | canCloseSubagents: false, | |
| 147 | canForkSubagentThread: false, | |
| 148 | }, | |
| 149 | context: { | |
| 150 | acceptsSystemContext: false, | |
| 151 | acceptsDeveloperContext: false, | |
| 152 | acceptsSyntheticUserContext: true, | |
| 153 | canGenerateSummaries: true, | |
| 154 | canConsumeHandoffSummaries: true, | |
| 155 | supportsDeltaHandoff: true, | |
| 156 | supportsFullThreadHandoff: true, | |
| 157 | maxRecommendedHandoffChars: null, | |
| 158 | }, | |
| 159 | checkpointing: { | |
| 160 | appCanCheckpointFilesystem: true, | |
| 161 | supportsNestedCheckpointScopes: true, | |
| 162 | providerCanRollbackConversation: false, | |
| 163 | providerRollbackReturnsSnapshot: false, | |
| 164 | providerCanReadConversationSnapshot: true, | |
| 165 | }, | |
| 166 | identity: { | |
| 167 | nativeThreadIds: "strong", | |
| 168 | nativeTurnIds: "strong", | |
| 169 | nativeItemIds: "weak", | |
| 170 | nativeRequestIds: "none", | |
| 171 | }, | |
| 172 | runtimePolicy: { | |
| 173 | enforcement: "native", | |
| 174 | }, | |
| 175 | } satisfies OrchestrationV2ProviderCapabilities; | |
| 176 | ||
| 177 | export interface CursorRuntimeAgentPolicy { | |
| 178 | readonly autoReview: boolean; | |
| 179 | readonly sandboxEnabled: boolean; | |
| 180 | } | |
| 181 | ||
| 182 | export function cursorRuntimeAgentPolicy( | |
| 183 | runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, | |
| 184 | ): CursorRuntimeAgentPolicy { | |
| 185 | const sandboxPolicyType = | |
| 186 | typeof runtimePolicy.sandboxPolicy === "object" && | |
| 187 | runtimePolicy.sandboxPolicy !== null && | |
| 188 | "type" in runtimePolicy.sandboxPolicy && | |
| 189 | typeof runtimePolicy.sandboxPolicy.type === "string" | |
| 190 | ? runtimePolicy.sandboxPolicy.type | |
| 191 | : undefined; | |
| 192 | return { | |
| 193 | autoReview: | |
| 194 | runtimePolicy.approvalPolicy === undefined | |
| 195 | ? runtimePolicy.runtimeMode === "approval-required" | |
| 196 | : runtimePolicy.approvalPolicy !== "never", | |
| 197 | sandboxEnabled: | |
| 198 | runtimePolicy.sandboxPolicy === undefined | |
| 199 | ? runtimePolicy.runtimeMode !== "full-access" | |
| 200 | : sandboxPolicyType !== "dangerFullAccess", | |
| 201 | }; | |
| 202 | } | |
| 203 | ||
| 204 | export function cursorMcpServers(threadId: ThreadId): Record<string, McpServerConfig> | undefined { | |
| 205 | const session = McpProviderSession.readMcpProviderSession(threadId); | |
| 206 | if (session === undefined) { | |
| 207 | return undefined; | |
| 208 | } | |
| 209 | return { | |
| 210 | "t3-code": { | |
| 211 | type: "http", | |
| 212 | url: session.endpoint, | |
| 213 | headers: { | |
| 214 | Authorization: session.authorizationHeader, | |
| 215 | }, | |
| 216 | }, | |
| 217 | }; | |
| 218 | } | |
| 219 | ||
| 220 | function providerSession(input: { | |
| 221 | readonly providerSessionId: OrchestrationV2ProviderSession["id"]; | |
| 222 | readonly providerInstanceId: ProviderInstanceId; | |
| 223 | readonly cwd: string | null; | |
| 224 | readonly model: string; | |
| 225 | readonly now: DateTime.Utc; | |
| 226 | }): OrchestrationV2ProviderSession { | |
| 227 | return { | |
| 228 | id: input.providerSessionId, | |
| 229 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 230 | providerInstanceId: input.providerInstanceId, | |
| 231 | status: "ready", | |
| 232 | cwd: input.cwd ?? process.cwd(), | |
| 233 | model: input.model, | |
| 234 | capabilities: CursorProviderCapabilitiesV2, | |
| 235 | createdAt: input.now, | |
| 236 | updatedAt: input.now, | |
| 237 | lastError: null, | |
| 238 | }; | |
| 239 | } | |
| 240 | ||
| 241 | function makeProviderThread(input: { | |
| 242 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | |
| 243 | readonly providerInstanceId: ProviderInstanceId; | |
| 244 | readonly appThreadId: OrchestrationV2ProviderThread["appThreadId"]; | |
| 245 | readonly providerSessionId: OrchestrationV2ProviderThread["providerSessionId"]; | |
| 246 | readonly nativeThreadId: string; | |
| 247 | readonly ownerNodeId?: OrchestrationV2ProviderThread["ownerNodeId"]; | |
| 248 | readonly forkedFrom?: OrchestrationV2ProviderThread["forkedFrom"]; | |
| 249 | readonly now: DateTime.Utc; | |
| 250 | }): OrchestrationV2ProviderThread { | |
| 251 | return { | |
| 252 | id: input.idAllocator.derive.providerThread({ | |
| 253 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 254 | nativeThreadId: input.nativeThreadId, | |
| 255 | }), | |
| 256 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 257 | providerInstanceId: input.providerInstanceId, | |
| 258 | providerSessionId: input.providerSessionId, | |
| 259 | appThreadId: input.appThreadId, | |
| 260 | ownerNodeId: input.ownerNodeId ?? null, | |
| 261 | nativeThreadRef: { | |
| 262 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 263 | nativeId: input.nativeThreadId, | |
| 264 | strength: "strong", | |
| 265 | }, | |
| 266 | nativeConversationHeadRef: null, | |
| 267 | status: "idle", | |
| 268 | firstRunOrdinal: null, | |
| 269 | lastRunOrdinal: null, | |
| 270 | handoffIds: [], | |
| 271 | forkedFrom: input.forkedFrom ?? null, | |
| 272 | createdAt: input.now, | |
| 273 | updatedAt: input.now, | |
| 274 | }; | |
| 275 | } | |
| 276 | ||
| 277 | function nativeThreadId(providerThread: OrchestrationV2ProviderThread): string { | |
| 278 | const id = providerThread.nativeThreadRef?.nativeId; | |
| 279 | if (id === null || id === undefined) { | |
| 280 | throw new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 281 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 282 | detail: `Provider thread ${providerThread.id} is missing its Cursor agent id.`, | |
| 283 | }); | |
| 284 | } | |
| 285 | return id; | |
| 286 | } | |
| 287 | ||
| 288 | /** | |
| 289 | * Every Cursor settings layer the Cursor CLI loads: project and user rules, | |
| 290 | * skills, hooks, and MCP servers, team and MDM admin policy, and account | |
| 291 | * plugins. The SDK loads none of them when `settingSources` is omitted. | |
| 292 | * Sandbox policy files are read either way, and hooks can only deny or ask | |
| 293 | * (which local SDK runs reject), so these layers do not loosen the sandbox or | |
| 294 | * approval mode T3 sets. | |
| 295 | */ | |
| 296 | const CURSOR_AGENT_SETTING_SOURCES = [ | |
| 297 | "project", | |
| 298 | "user", | |
| 299 | "team", | |
| 300 | "mdm", | |
| 301 | "plugins", | |
| 302 | ] as const satisfies ReadonlyArray<SettingSource>; | |
| 303 | ||
| 304 | export function makeCursorAgentOptions(input: { | |
| 305 | readonly apiKey?: string; | |
| 306 | readonly modelSelection: ModelSelection; | |
| 307 | readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; | |
| 308 | readonly threadId: ThreadId; | |
| 309 | }): AgentOptions { | |
| 310 | const policy = cursorRuntimeAgentPolicy(input.runtimePolicy); | |
| 311 | const mcpServers = cursorMcpServers(input.threadId); | |
| 312 | return { | |
| 313 | model: cursorSdkModelSelection(input.modelSelection), | |
| 314 | name: `T3 Code ${input.threadId}`, | |
| 315 | mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", | |
| 316 | ...(input.apiKey === undefined ? {} : { apiKey: input.apiKey }), | |
| 317 | local: { | |
| 318 | ...(input.runtimePolicy.cwd === null ? {} : { cwd: input.runtimePolicy.cwd }), | |
| 319 | autoReview: policy.autoReview, | |
| 320 | settingSources: [...CURSOR_AGENT_SETTING_SOURCES], | |
| 321 | sandboxOptions: { | |
| 322 | enabled: policy.sandboxEnabled, | |
| 323 | }, | |
| 324 | enableAgentRetries: true, | |
| 325 | }, | |
| 326 | ...(mcpServers === undefined ? {} : { mcpServers }), | |
| 327 | }; | |
| 328 | } | |
| 329 | ||
| 330 | function stableJson(value: unknown): string { | |
| 331 | try { | |
| 332 | return JSON.stringify(value, null, 2); | |
| 333 | } catch { | |
| 334 | return String(value); | |
| 335 | } | |
| 336 | } | |
| 337 | ||
| 338 | function unknownRecord(value: unknown): Record<string, unknown> | undefined { | |
| 339 | return typeof value === "object" && value !== null && !Array.isArray(value) | |
| 340 | ? (value as Record<string, unknown>) | |
| 341 | : undefined; | |
| 342 | } | |
| 343 | ||
| 344 | function nestedString(value: unknown, keys: ReadonlyArray<string>): string | undefined { | |
| 345 | let current: unknown = value; | |
| 346 | for (const key of keys) { | |
| 347 | current = unknownRecord(current)?.[key]; | |
| 348 | } | |
| 349 | return typeof current === "string" ? current : undefined; | |
| 350 | } | |
| 351 | ||
| 352 | function cursorToolFailed(toolCall: ToolCall): boolean { | |
| 353 | if (toolCall.result?.status === "error") { | |
| 354 | return true; | |
| 355 | } | |
| 356 | return toolCall.type === "mcp" && toolCall.result?.status === "success" | |
| 357 | ? toolCall.result.value.isError | |
| 358 | : false; | |
| 359 | } | |
| 360 | ||
| 361 | function cursorToolOutput(toolCall: ToolCall): unknown { | |
| 362 | const result = toolCall.result; | |
| 363 | if (result === undefined) { | |
| 364 | return undefined; | |
| 365 | } | |
| 366 | return result.status === "success" ? result.value : result.error; | |
| 367 | } | |
| 368 | ||
| 369 | function cursorToolOutputText(toolCall: ToolCall): string { | |
| 370 | if (toolCall.type === "shell" && toolCall.result?.status === "success") { | |
| 371 | return [toolCall.result.value.stdout, toolCall.result.value.stderr] | |
| 372 | .filter((part) => part.length > 0) | |
| 373 | .join("\n"); | |
| 374 | } | |
| 375 | if (toolCall.type === "mcp" && toolCall.result?.status === "success") { | |
| 376 | return toolCall.result.value.content | |
| 377 | .flatMap((part) => (part.text === undefined ? [] : [part.text.text])) | |
| 378 | .join("\n"); | |
| 379 | } | |
| 380 | const output = cursorToolOutput(toolCall); | |
| 381 | if (output === undefined) { | |
| 382 | return ""; | |
| 383 | } | |
| 384 | return typeof output === "string" ? output : stableJson(output); | |
| 385 | } | |
| 386 | ||
| 387 | function cursorToolName(toolCall: ToolCall): string { | |
| 388 | if (toolCall.type !== "mcp") { | |
| 389 | return toolCall.type; | |
| 390 | } | |
| 391 | const provider = toolCall.args.providerIdentifier ?? "mcp"; | |
| 392 | const tool = toolCall.args.toolName ?? "unknown"; | |
| 393 | return `mcp__${provider}__${tool}`; | |
| 394 | } | |
| 395 | ||
| 396 | function cursorToolFileName(toolCall: ToolCall): string { | |
| 397 | switch (toolCall.type) { | |
| 398 | case "write": | |
| 399 | case "delete": | |
| 400 | case "read": | |
| 401 | case "edit": | |
| 402 | case "ls": | |
| 403 | return toolCall.args.path; | |
| 404 | case "generateImage": | |
| 405 | return toolCall.args.filePath ?? "generated-image"; | |
| 406 | default: | |
| 407 | return cursorToolName(toolCall); | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | function cursorToolSearchPattern(toolCall: ToolCall): string | undefined { | |
| 412 | switch (toolCall.type) { | |
| 413 | case "glob": | |
| 414 | return toolCall.args.globPattern; | |
| 415 | case "grep": | |
| 416 | return toolCall.args.pattern; | |
| 417 | case "semSearch": | |
| 418 | return toolCall.args.query; | |
| 419 | case "ls": | |
| 420 | return toolCall.args.path; | |
| 421 | case "readLints": | |
| 422 | return toolCall.args.paths.join(", "); | |
| 423 | default: | |
| 424 | return undefined; | |
| 425 | } | |
| 426 | } | |
| 427 | ||
| 428 | type CursorLsDirectoryNode = Extract< | |
| 429 | Extract<ToolCall, { readonly type: "ls" }>["result"], | |
| 430 | { readonly status: "success" } | |
| 431 | >["value"]["directoryTreeRoot"]; | |
| 432 | ||
| 433 | function cursorLsSearchResults( | |
| 434 | node: CursorLsDirectoryNode, | |
| 435 | path: Path.Path, | |
| 436 | ): ReadonlyArray<{ readonly fileName: string }> { | |
| 437 | return [ | |
| 438 | ...node.childrenFiles.map((file) => ({ fileName: path.join(node.absPath, file.name) })), | |
| 439 | ...node.childrenDirs.flatMap((child) => cursorLsSearchResults(child, path)), | |
| 440 | ]; | |
| 441 | } | |
| 442 | ||
| 443 | function cursorToolSearchResults( | |
| 444 | toolCall: ToolCall, | |
| 445 | path: Path.Path, | |
| 446 | ): ReadonlyArray<{ | |
| 447 | readonly fileName: string; | |
| 448 | readonly line?: number; | |
| 449 | readonly column?: number; | |
| 450 | readonly preview?: string; | |
| 451 | }> { | |
| 452 | if (toolCall.result?.status !== "success") { | |
| 453 | return []; | |
| 454 | } | |
| 455 | switch (toolCall.type) { | |
| 456 | case "glob": | |
| 457 | return toolCall.result.value.files.map((fileName) => ({ fileName })); | |
| 458 | case "grep": | |
| 459 | return Object.values(toolCall.result.value.workspaceResults ?? {}).flatMap((result) => { | |
| 460 | if (result.type === "files") { | |
| 461 | return result.output.files.map((fileName) => ({ fileName })); | |
| 462 | } | |
| 463 | if (result.type === "count") { | |
| 464 | return result.output.counts.map((entry) => ({ | |
| 465 | fileName: entry.file, | |
| 466 | preview: `${entry.count} matches`, | |
| 467 | })); | |
| 468 | } | |
| 469 | return result.output.matches.map((entry) => ({ | |
| 470 | fileName: entry.file, | |
| 471 | ...(entry.lineNumber === undefined ? {} : { line: entry.lineNumber }), | |
| 472 | preview: entry.line, | |
| 473 | })); | |
| 474 | }); | |
| 475 | case "ls": | |
| 476 | return cursorLsSearchResults(toolCall.result.value.directoryTreeRoot, path); | |
| 477 | case "readLints": | |
| 478 | return toolCall.result.value.fileDiagnostics.flatMap((file) => | |
| 479 | file.diagnostics.map((diagnostic) => { | |
| 480 | const line = diagnostic.range?.start?.line; | |
| 481 | const character = diagnostic.range?.start?.character; | |
| 482 | const hasLine = typeof line === "number" && Number.isInteger(line) && line >= 0; | |
| 483 | return { | |
| 484 | fileName: file.path, | |
| 485 | ...(hasLine ? { line: line + 1 } : {}), | |
| 486 | ...(hasLine && | |
| 487 | typeof character === "number" && | |
| 488 | Number.isInteger(character) && | |
| 489 | character >= 0 | |
| 490 | ? { column: character + 1 } | |
| 491 | : {}), | |
| 492 | preview: diagnostic.message, | |
| 493 | }; | |
| 494 | }), | |
| 495 | ); | |
| 496 | case "semSearch": | |
| 497 | return [ | |
| 498 | { | |
| 499 | fileName: "semantic-search", | |
| 500 | preview: toolCall.result.value.results, | |
| 501 | }, | |
| 502 | ]; | |
| 503 | default: | |
| 504 | return []; | |
| 505 | } | |
| 506 | } | |
| 507 | ||
| 508 | function cursorTodoSteps( | |
| 509 | toolCall: Extract<ToolCall, { readonly type: "updateTodos" }>, | |
| 510 | ): ReadonlyArray<OrchestrationV2PlanStep> { | |
| 511 | const todos = | |
| 512 | toolCall.result?.status === "success" ? toolCall.result.value.todos : toolCall.args.todos; | |
| 513 | return todos | |
| 514 | .filter((todo) => todo.status !== "cancelled" && todo.content.trim().length > 0) | |
| 515 | .map((todo, index) => ({ | |
| 516 | id: `todo-${index + 1}`, | |
| 517 | text: todo.content, | |
| 518 | status: | |
| 519 | todo.status === "inProgress" | |
| 520 | ? "running" | |
| 521 | : todo.status === "completed" | |
| 522 | ? "completed" | |
| 523 | : "pending", | |
| 524 | })); | |
| 525 | } | |
| 526 | ||
| 527 | function assistantTextsFromConversationSteps(steps: ReadonlyArray<unknown>): ReadonlyArray<string> { | |
| 528 | return steps.flatMap((step) => { | |
| 529 | const record = unknownRecord(step); | |
| 530 | if (record?.type === "assistantMessage") { | |
| 531 | const text = nestedString(record, ["message", "text"]); | |
| 532 | return text === undefined || text.length === 0 ? [] : [text]; | |
| 533 | } | |
| 534 | const text = nestedString(record, ["assistantMessage", "text"]); | |
| 535 | return text === undefined || text.length === 0 ? [] : [text]; | |
| 536 | }); | |
| 537 | } | |
| 538 | ||
| 539 | function nestedToolResult<Value = Record<string, unknown>>( | |
| 540 | rawResult: unknown, | |
| 541 | mapSuccess: (success: Record<string, unknown>) => Value = (success) => success as Value, | |
| 542 | ): | |
| 543 | | { readonly status: "success"; readonly value: Value } | |
| 544 | | { readonly status: "error"; readonly error: unknown } | |
| 545 | | undefined { | |
| 546 | const result = unknownRecord(rawResult); | |
| 547 | if (result === undefined) { | |
| 548 | return undefined; | |
| 549 | } | |
| 550 | const success = unknownRecord(result.success); | |
| 551 | if (success !== undefined) { | |
| 552 | return { | |
| 553 | status: "success", | |
| 554 | value: mapSuccess(success), | |
| 555 | }; | |
| 556 | } | |
| 557 | return { | |
| 558 | status: "error", | |
| 559 | error: result.error ?? result.permissionDenied ?? result, | |
| 560 | }; | |
| 561 | } | |
| 562 | ||
| 563 | function nestedGrepWorkspaceResults(success: Record<string, unknown>): Record<string, unknown> { | |
| 564 | const workspaces = unknownRecord(success.workspaceResults); | |
| 565 | if (workspaces === undefined) { | |
| 566 | return {}; | |
| 567 | } | |
| 568 | return Object.fromEntries( | |
| 569 | Object.entries(workspaces).map(([workspace, rawWorkspaceResult]) => { | |
| 570 | const workspaceResult = unknownRecord(rawWorkspaceResult); | |
| 571 | const content = unknownRecord(workspaceResult?.content); | |
| 572 | const rawMatches = Array.isArray(content?.matches) ? content.matches : []; | |
| 573 | const matches = rawMatches.flatMap((rawFileMatch) => { | |
| 574 | const fileMatch = unknownRecord(rawFileMatch); | |
| 575 | const file = typeof fileMatch?.file === "string" ? fileMatch.file : ""; | |
| 576 | const lineMatches = Array.isArray(fileMatch?.matches) ? fileMatch.matches : []; | |
| 577 | return lineMatches.flatMap((rawLineMatch) => { | |
| 578 | const lineMatch = unknownRecord(rawLineMatch); | |
| 579 | if (lineMatch === undefined) { | |
| 580 | return []; | |
| 581 | } | |
| 582 | const line = typeof lineMatch.content === "string" ? lineMatch.content : undefined; | |
| 583 | if (file.length === 0 || line === undefined) { | |
| 584 | return []; | |
| 585 | } | |
| 586 | return [ | |
| 587 | { | |
| 588 | file, | |
| 589 | line, | |
| 590 | ...(typeof lineMatch.lineNumber === "number" | |
| 591 | ? { lineNumber: lineMatch.lineNumber } | |
| 592 | : {}), | |
| 593 | }, | |
| 594 | ]; | |
| 595 | }); | |
| 596 | }); | |
| 597 | return [ | |
| 598 | workspace, | |
| 599 | { | |
| 600 | type: "content", | |
| 601 | output: { | |
| 602 | matches, | |
| 603 | totalMatches: | |
| 604 | typeof content?.totalMatchedLines === "number" | |
| 605 | ? content.totalMatchedLines | |
| 606 | : matches.length, | |
| 607 | }, | |
| 608 | }, | |
| 609 | ]; | |
| 610 | }), | |
| 611 | ); | |
| 612 | } | |
| 613 | ||
| 614 | export function nestedToolCallFromEnvelope( | |
| 615 | envelope: Record<string, unknown>, | |
| 616 | ): { readonly callId: string; readonly toolCall: ToolCall } | undefined { | |
| 617 | const callId = typeof envelope.toolCallId === "string" ? envelope.toolCallId : undefined; | |
| 618 | const wrapperEntry = Object.entries(envelope).find(([key]) => key.endsWith("ToolCall")); | |
| 619 | if (wrapperEntry === undefined) { | |
| 620 | return undefined; | |
| 621 | } | |
| 622 | const [wrapperName, rawCall] = wrapperEntry; | |
| 623 | const call = unknownRecord(rawCall); | |
| 624 | if (call === undefined) { | |
| 625 | return undefined; | |
| 626 | } | |
| 627 | const args = unknownRecord(call.args) ?? {}; | |
| 628 | const fallbackCallId = `nested-${wrapperName}`; | |
| 629 | const nestedCallId = callId ?? fallbackCallId; | |
| 630 | ||
| 631 | switch (wrapperName) { | |
| 632 | case "readToolCall": { | |
| 633 | const success = unknownRecord(unknownRecord(call.result)?.success); | |
| 634 | const path = | |
| 635 | typeof args.path === "string" | |
| 636 | ? args.path | |
| 637 | : typeof success?.path === "string" | |
| 638 | ? success.path | |
| 639 | : "<unknown path>"; | |
| 640 | return { | |
| 641 | callId: nestedCallId, | |
| 642 | toolCall: { | |
| 643 | type: "read", | |
| 644 | args: { path }, | |
| 645 | result: nestedToolResult(call.result, (value) => ({ | |
| 646 | content: typeof value.content === "string" ? value.content : "", | |
| 647 | totalLines: typeof value.totalLines === "number" ? value.totalLines : 0, | |
| 648 | fileSize: typeof value.fileSize === "number" ? value.fileSize : 0, | |
| 649 | })), | |
| 650 | } as unknown as ToolCall, | |
| 651 | }; | |
| 652 | } | |
| 653 | case "globToolCall": | |
| 654 | return { | |
| 655 | callId: nestedCallId, | |
| 656 | toolCall: { | |
| 657 | type: "glob", | |
| 658 | args: { | |
| 659 | globPattern: typeof args.globPattern === "string" ? args.globPattern : "**/*", | |
| 660 | ...(typeof args.targetDirectory === "string" | |
| 661 | ? { targetDirectory: args.targetDirectory } | |
| 662 | : {}), | |
| 663 | }, | |
| 664 | result: nestedToolResult(call.result, (value) => ({ | |
| 665 | files: Array.isArray(value.files) | |
| 666 | ? value.files.filter((file): file is string => typeof file === "string") | |
| 667 | : [], | |
| 668 | totalFiles: typeof value.totalFiles === "number" ? value.totalFiles : 0, | |
| 669 | clientTruncated: false, | |
| 670 | ripgrepTruncated: false, | |
| 671 | })), | |
| 672 | } as unknown as ToolCall, | |
| 673 | }; | |
| 674 | case "grepToolCall": | |
| 675 | return { | |
| 676 | callId: nestedCallId, | |
| 677 | toolCall: { | |
| 678 | type: "grep", | |
| 679 | args: { | |
| 680 | pattern: typeof args.pattern === "string" ? args.pattern : "", | |
| 681 | ...(typeof args.path === "string" ? { path: args.path } : {}), | |
| 682 | ...(typeof args.glob === "string" ? { glob: args.glob } : {}), | |
| 683 | ...(typeof args.outputMode === "string" ? { outputMode: args.outputMode } : {}), | |
| 684 | ...(typeof args.caseInsensitive === "boolean" | |
| 685 | ? { caseInsensitive: args.caseInsensitive } | |
| 686 | : {}), | |
| 687 | ...(typeof args.offset === "number" ? { offset: args.offset } : {}), | |
| 688 | ...(typeof args.multiline === "boolean" ? { multiline: args.multiline } : {}), | |
| 689 | }, | |
| 690 | result: nestedToolResult(call.result, (value) => ({ | |
| 691 | workspaceResults: nestedGrepWorkspaceResults(value), | |
| 692 | })), | |
| 693 | } as unknown as ToolCall, | |
| 694 | }; | |
| 695 | case "shellToolCall": { | |
| 696 | const rawResult = unknownRecord(call.result); | |
| 697 | const permissionDenied = unknownRecord(rawResult?.permissionDenied); | |
| 698 | const command = | |
| 699 | typeof args.command === "string" | |
| 700 | ? args.command | |
| 701 | : typeof permissionDenied?.command === "string" | |
| 702 | ? permissionDenied.command | |
| 703 | : "<unknown command>"; | |
| 704 | return { | |
| 705 | callId: nestedCallId, | |
| 706 | toolCall: { | |
| 707 | type: "shell", | |
| 708 | args: { command }, | |
| 709 | result: nestedToolResult(call.result, (value) => ({ | |
| 710 | stdout: typeof value.stdout === "string" ? value.stdout : "", | |
| 711 | stderr: typeof value.stderr === "string" ? value.stderr : "", | |
| 712 | exitCode: typeof value.exitCode === "number" ? value.exitCode : 0, | |
| 713 | signal: typeof value.signal === "string" ? value.signal : "", | |
| 714 | executionTime: typeof value.executionTime === "number" ? value.executionTime : 0, | |
| 715 | })), | |
| 716 | } as unknown as ToolCall, | |
| 717 | }; | |
| 718 | } | |
| 719 | default: { | |
| 720 | const type = wrapperName.slice(0, -"ToolCall".length); | |
| 721 | return { | |
| 722 | callId: nestedCallId, | |
| 723 | toolCall: { | |
| 724 | type, | |
| 725 | args, | |
| 726 | result: nestedToolResult(call.result), | |
| 727 | } as unknown as ToolCall, | |
| 728 | }; | |
| 729 | } | |
| 730 | } | |
| 731 | } | |
| 732 | ||
| 733 | function toolCallsFromConversationSteps( | |
| 734 | steps: ReadonlyArray<unknown>, | |
| 735 | ): ReadonlyArray<{ readonly callId: string; readonly toolCall: ToolCall }> { | |
| 736 | return steps.flatMap((step) => { | |
| 737 | const record = unknownRecord(step); | |
| 738 | if (record?.type === "toolCall") { | |
| 739 | const message = unknownRecord(record.message); | |
| 740 | return typeof message?.type === "string" | |
| 741 | ? [ | |
| 742 | { | |
| 743 | callId: typeof record.callId === "string" ? record.callId : `nested-${message.type}`, | |
| 744 | toolCall: message as ToolCall, | |
| 745 | }, | |
| 746 | ] | |
| 747 | : []; | |
| 748 | } | |
| 749 | const envelope = unknownRecord(record?.toolCall); | |
| 750 | const nested = envelope === undefined ? undefined : nestedToolCallFromEnvelope(envelope); | |
| 751 | return nested === undefined ? [] : [nested]; | |
| 752 | }); | |
| 753 | } | |
| 754 | ||
| 755 | function textFromAgentMessage(message: AgentMessage): string { | |
| 756 | const value = message.message; | |
| 757 | if (typeof value === "string") { | |
| 758 | return value; | |
| 759 | } | |
| 760 | const direct = | |
| 761 | nestedString(value, ["text"]) ?? | |
| 762 | nestedString(value, ["message", "text"]) ?? | |
| 763 | nestedString(value, ["userMessage", "text"]) ?? | |
| 764 | nestedString(value, ["assistantMessage", "text"]); | |
| 765 | if (direct !== undefined) { | |
| 766 | return direct; | |
| 767 | } | |
| 768 | const content = unknownRecord(value)?.content; | |
| 769 | if (!Array.isArray(content)) { | |
| 770 | return ""; | |
| 771 | } | |
| 772 | return content | |
| 773 | .flatMap((part) => { | |
| 774 | const text = unknownRecord(part)?.text; | |
| 775 | return typeof text === "string" ? [text] : []; | |
| 776 | }) | |
| 777 | .join(""); | |
| 778 | } | |
| 779 | ||
| 780 | interface CursorProjectionTarget { | |
| 781 | readonly threadId: ThreadId; | |
| 782 | readonly runId: ProviderAdapter.ProviderAdapterV2TurnInput["runId"] | null; | |
| 783 | readonly rootNodeId: OrchestrationV2ExecutionNode["rootNodeId"]; | |
| 784 | readonly parentNodeId: OrchestrationV2ExecutionNode["id"]; | |
| 785 | readonly providerThreadId: OrchestrationV2ProviderThread["id"] | null; | |
| 786 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"] | null; | |
| 787 | } | |
| 788 | ||
| 789 | interface ActiveCursorToolCall { | |
| 790 | readonly callId: string; | |
| 791 | toolCall: ToolCall; | |
| 792 | readonly target: CursorProjectionTarget; | |
| 793 | readonly ordinal: number; | |
| 794 | readonly startedAt: DateTime.Utc; | |
| 795 | streamedOutput: string; | |
| 796 | } | |
| 797 | ||
| 798 | interface ActiveCursorSubagent { | |
| 799 | task: OrchestrationV2Subagent; | |
| 800 | toolCall: Extract<ToolCall, { readonly type: "task" }>; | |
| 801 | readonly callId: string; | |
| 802 | readonly childThreadId: ThreadId; | |
| 803 | readonly childRootNodeId: OrchestrationV2ExecutionNode["id"]; | |
| 804 | readonly turnItemId: OrchestrationV2TurnItem["id"]; | |
| 805 | readonly turnItemOrdinal: number; | |
| 806 | nextChildOrdinal: number; | |
| 807 | resultProjected: boolean; | |
| 808 | } | |
| 809 | ||
| 810 | interface ActiveCursorTextSegment { | |
| 811 | readonly nativeItemId: string; | |
| 812 | readonly startedAt: DateTime.Utc; | |
| 813 | text: string; | |
| 814 | } | |
| 815 | ||
| 816 | interface ActiveCursorTextStream { | |
| 817 | current: ActiveCursorTextSegment | null; | |
| 818 | nextSegment: number; | |
| 819 | } | |
| 820 | ||
| 821 | interface ActiveCursorTurn { | |
| 822 | readonly input: ProviderAdapter.ProviderAdapterV2TurnInput; | |
| 823 | readonly run: CursorAgentSdk.CursorAgentSdkRun; | |
| 824 | readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; | |
| 825 | readonly startedAt: DateTime.Utc; | |
| 826 | readonly completed: Deferred.Deferred<void, never>; | |
| 827 | // Item ordinals allocated in this turn. No later turn looks items up here. | |
| 828 | readonly itemOrdinals: Map<string, number>; | |
| 829 | readonly tools: Map<string, ActiveCursorToolCall>; | |
| 830 | readonly subagents: Map<string, ActiveCursorSubagent>; | |
| 831 | readonly assistant: ActiveCursorTextStream; | |
| 832 | readonly assistantReply: CursorTransportFailure; | |
| 833 | readonly reasoning: ActiveCursorTextStream; | |
| 834 | interrupted: boolean; | |
| 835 | finalized: boolean; | |
| 836 | } | |
| 837 | ||
| 838 | interface CursorLiveAgent { | |
| 839 | readonly nativeThreadId: string; | |
| 840 | readonly session: CursorAgentSdk.CursorAgentSdkSession; | |
| 841 | } | |
| 842 | ||
| 843 | export interface CursorAdapterV2Options { | |
| 844 | readonly instanceId: ProviderInstanceId; | |
| 845 | readonly settings: CursorSettings; | |
| 846 | readonly environment: NodeJS.ProcessEnv; | |
| 847 | readonly fileSystem: FileSystem.FileSystem; | |
| 848 | readonly path: Path.Path; | |
| 849 | readonly idAllocator: IdAllocator.IdAllocatorV2Shape; | |
| 850 | readonly runner: CursorAgentSdk.CursorAgentSdkRunnerShape; | |
| 851 | readonly host: ProviderHost.ProviderHostShape; | |
| 852 | } | |
| 853 | ||
| 854 | export function makeCursorAdapterV2( | |
| 855 | adapterOptions: CursorAdapterV2Options, | |
| 856 | ): ProviderAdapter.ProviderAdapterV2Shape { | |
| 857 | const { fileSystem, path, idAllocator, runner, host } = adapterOptions; | |
| 858 | const apiKey = adapterOptions.environment.CURSOR_API_KEY?.trim() || undefined; | |
| 859 | ||
| 860 | return ProviderAdapter.ProviderAdapterV2.of({ | |
| 861 | instanceId: adapterOptions.instanceId, | |
| 862 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 863 | getCapabilities: () => Effect.succeed(CursorProviderCapabilitiesV2), | |
| 864 | planSelectionTransition: () => Effect.succeed(turnScopedSelectionTransition()), | |
| 865 | openSession: Effect.fn("CursorAdapterV2.openSession")( | |
| 866 | function* (input: ProviderAdapter.ProviderAdapterV2OpenSessionInput) { | |
| 867 | const sessionScope = yield* Effect.scope; | |
| 868 | const createdAt = yield* DateTime.now; | |
| 869 | const session = providerSession({ | |
| 870 | providerSessionId: input.providerSessionId, | |
| 871 | providerInstanceId: adapterOptions.instanceId, | |
| 872 | cwd: input.runtimePolicy.cwd, | |
| 873 | model: input.modelSelection.model, | |
| 874 | now: createdAt, | |
| 875 | }); | |
| 876 | const events = yield* Queue.unbounded<ProviderAdapter.ProviderAdapterV2Event>(); | |
| 877 | const liveAgent = yield* Ref.make<CursorLiveAgent | null>(null); | |
| 878 | const activeTurn = yield* Ref.make<ActiveCursorTurn | null>(null); | |
| 879 | const planIds = yield* Ref.make(new Map<string, OrchestrationV2PlanArtifact["id"]>()); | |
| 880 | ||
| 881 | const emitProviderEvent = (event: ProviderAdapter.ProviderAdapterV2Event) => | |
| 882 | Queue.offer(events, event).pipe(Effect.asVoid); | |
| 883 | ||
| 884 | const resolveItemOrdinal = (context: ActiveCursorTurn, nativeItemId: string) => | |
| 885 | Effect.sync(() => { | |
| 886 | const existing = context.itemOrdinals.get(nativeItemId); | |
| 887 | if (existing !== undefined) { | |
| 888 | return existing; | |
| 889 | } | |
| 890 | const ordinal = context.input.providerTurnOrdinal * 100 + context.itemOrdinals.size + 1; | |
| 891 | context.itemOrdinals.set(nativeItemId, ordinal); | |
| 892 | return ordinal; | |
| 893 | }); | |
| 894 | ||
| 895 | const resolvePlanId = Effect.fnUntraced(function* ( | |
| 896 | context: ActiveCursorTurn, | |
| 897 | nativeItemId: string, | |
| 898 | ) { | |
| 899 | const existing = (yield* Ref.get(planIds)).get(nativeItemId); | |
| 900 | if (existing !== undefined) { | |
| 901 | return existing; | |
| 902 | } | |
| 903 | const planId = yield* idAllocator.allocate.plan({ | |
| 904 | threadId: context.input.threadId, | |
| 905 | runId: context.input.runId, | |
| 906 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 907 | }); | |
| 908 | yield* Ref.update(planIds, (current) => { | |
| 909 | const updated = new Map(current); | |
| 910 | updated.set(nativeItemId, planId); | |
| 911 | return updated; | |
| 912 | }); | |
| 913 | return planId; | |
| 914 | }); | |
| 915 | ||
| 916 | const parentTarget = (context: ActiveCursorTurn): CursorProjectionTarget => ({ | |
| 917 | threadId: context.input.threadId, | |
| 918 | runId: context.input.runId, | |
| 919 | rootNodeId: context.input.rootNodeId, | |
| 920 | parentNodeId: context.input.rootNodeId, | |
| 921 | providerThreadId: context.input.providerThread.id, | |
| 922 | providerTurnId: context.providerTurnId, | |
| 923 | }); | |
| 924 | ||
| 925 | const emitAssistant = Effect.fnUntraced(function* ( | |
| 926 | context: ActiveCursorTurn, | |
| 927 | completed: boolean, | |
| 928 | ) { | |
| 929 | const segment = context.assistant.current; | |
| 930 | if (segment === null || segment.text.length === 0) { | |
| 931 | return; | |
| 932 | } | |
| 933 | const now = yield* DateTime.now; | |
| 934 | const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); | |
| 935 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 936 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 937 | nativeItemId: segment.nativeItemId, | |
| 938 | }); | |
| 939 | const messageId = idAllocator.derive.messageFromProviderItem({ | |
| 940 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 941 | nativeItemId: segment.nativeItemId, | |
| 942 | }); | |
| 943 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 944 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 945 | nativeItemId: segment.nativeItemId, | |
| 946 | }); | |
| 947 | const nativeItemRef = { | |
| 948 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 949 | nativeId: segment.nativeItemId, | |
| 950 | strength: "weak" as const, | |
| 951 | }; | |
| 952 | yield* emitProviderEvent({ | |
| 953 | type: "node.updated", | |
| 954 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 955 | node: { | |
| 956 | id: nodeId, | |
| 957 | threadId: context.input.threadId, | |
| 958 | runId: context.input.runId, | |
| 959 | parentNodeId: context.input.rootNodeId, | |
| 960 | rootNodeId: context.input.rootNodeId, | |
| 961 | kind: "assistant_message", | |
| 962 | status: completed ? "completed" : "running", | |
| 963 | countsForRun: false, | |
| 964 | providerThreadId: context.input.providerThread.id, | |
| 965 | providerTurnId: context.providerTurnId, | |
| 966 | nativeItemRef, | |
| 967 | runtimeRequestId: null, | |
| 968 | checkpointScopeId: null, | |
| 969 | startedAt: segment.startedAt, | |
| 970 | completedAt: completed ? now : null, | |
| 971 | }, | |
| 972 | }); | |
| 973 | yield* emitProviderEvent({ | |
| 974 | type: "message.updated", | |
| 975 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 976 | message: { | |
| 977 | createdBy: "agent", | |
| 978 | creationSource: "provider", | |
| 979 | id: messageId, | |
| 980 | threadId: context.input.threadId, | |
| 981 | runId: context.input.runId, | |
| 982 | nodeId, | |
| 983 | role: "assistant", | |
| 984 | text: segment.text, | |
| 985 | attachments: [], | |
| 986 | streaming: !completed, | |
| 987 | createdAt: segment.startedAt, | |
| 988 | updatedAt: now, | |
| 989 | }, | |
| 990 | }); | |
| 991 | yield* emitProviderEvent({ | |
| 992 | type: "turn_item.updated", | |
| 993 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 994 | turnItem: { | |
| 995 | id: turnItemId, | |
| 996 | threadId: context.input.threadId, | |
| 997 | runId: context.input.runId, | |
| 998 | nodeId, | |
| 999 | providerThreadId: context.input.providerThread.id, | |
| 1000 | providerTurnId: context.providerTurnId, | |
| 1001 | nativeItemRef, | |
| 1002 | parentItemId: null, | |
| 1003 | ordinal, | |
| 1004 | status: completed ? "completed" : "running", | |
| 1005 | title: null, | |
| 1006 | startedAt: segment.startedAt, | |
| 1007 | completedAt: completed ? now : null, | |
| 1008 | updatedAt: now, | |
| 1009 | type: "assistant_message", | |
| 1010 | messageId, | |
| 1011 | text: segment.text, | |
| 1012 | streaming: !completed, | |
| 1013 | }, | |
| 1014 | }); | |
| 1015 | }); | |
| 1016 | ||
| 1017 | const emitReasoning = Effect.fnUntraced(function* ( | |
| 1018 | context: ActiveCursorTurn, | |
| 1019 | completed: boolean, | |
| 1020 | ) { | |
| 1021 | const segment = context.reasoning.current; | |
| 1022 | if (segment === null || segment.text.length === 0) { | |
| 1023 | return; | |
| 1024 | } | |
| 1025 | const now = yield* DateTime.now; | |
| 1026 | const ordinal = yield* resolveItemOrdinal(context, segment.nativeItemId); | |
| 1027 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1028 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1029 | nativeItemId: segment.nativeItemId, | |
| 1030 | }); | |
| 1031 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1032 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1033 | nativeItemId: segment.nativeItemId, | |
| 1034 | }); | |
| 1035 | const nativeItemRef = { | |
| 1036 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1037 | nativeId: segment.nativeItemId, | |
| 1038 | strength: "weak" as const, | |
| 1039 | }; | |
| 1040 | yield* emitProviderEvent({ | |
| 1041 | type: "node.updated", | |
| 1042 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1043 | node: { | |
| 1044 | id: nodeId, | |
| 1045 | threadId: context.input.threadId, | |
| 1046 | runId: context.input.runId, | |
| 1047 | parentNodeId: context.input.rootNodeId, | |
| 1048 | rootNodeId: context.input.rootNodeId, | |
| 1049 | kind: "reasoning", | |
| 1050 | status: completed ? "completed" : "running", | |
| 1051 | countsForRun: false, | |
| 1052 | providerThreadId: context.input.providerThread.id, | |
| 1053 | providerTurnId: context.providerTurnId, | |
| 1054 | nativeItemRef, | |
| 1055 | runtimeRequestId: null, | |
| 1056 | checkpointScopeId: null, | |
| 1057 | startedAt: segment.startedAt, | |
| 1058 | completedAt: completed ? now : null, | |
| 1059 | }, | |
| 1060 | }); | |
| 1061 | yield* emitProviderEvent({ | |
| 1062 | type: "turn_item.updated", | |
| 1063 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1064 | turnItem: { | |
| 1065 | id: turnItemId, | |
| 1066 | threadId: context.input.threadId, | |
| 1067 | runId: context.input.runId, | |
| 1068 | nodeId, | |
| 1069 | providerThreadId: context.input.providerThread.id, | |
| 1070 | providerTurnId: context.providerTurnId, | |
| 1071 | nativeItemRef, | |
| 1072 | parentItemId: null, | |
| 1073 | ordinal, | |
| 1074 | status: completed ? "completed" : "running", | |
| 1075 | title: null, | |
| 1076 | startedAt: segment.startedAt, | |
| 1077 | completedAt: completed ? now : null, | |
| 1078 | updatedAt: now, | |
| 1079 | type: "reasoning", | |
| 1080 | text: segment.text, | |
| 1081 | streaming: !completed, | |
| 1082 | }, | |
| 1083 | }); | |
| 1084 | }); | |
| 1085 | ||
| 1086 | const appendTextSegment = Effect.fnUntraced(function* (input: { | |
| 1087 | readonly context: ActiveCursorTurn; | |
| 1088 | readonly stream: ActiveCursorTextStream; | |
| 1089 | readonly kind: "assistant" | "reasoning"; | |
| 1090 | readonly text: string; | |
| 1091 | }) { | |
| 1092 | if (input.stream.current === null) { | |
| 1093 | input.stream.nextSegment += 1; | |
| 1094 | input.stream.current = { | |
| 1095 | nativeItemId: `${input.kind}:${input.context.run.runId}:${input.stream.nextSegment}`, | |
| 1096 | startedAt: yield* DateTime.now, | |
| 1097 | text: "", | |
| 1098 | }; | |
| 1099 | } | |
| 1100 | input.stream.current.text += input.text; | |
| 1101 | }); | |
| 1102 | ||
| 1103 | const completeAssistant = Effect.fnUntraced(function* (context: ActiveCursorTurn) { | |
| 1104 | if (context.assistant.current === null) { | |
| 1105 | return; | |
| 1106 | } | |
| 1107 | yield* emitAssistant(context, true); | |
| 1108 | context.assistant.current = null; | |
| 1109 | }); | |
| 1110 | ||
| 1111 | const completeReasoning = Effect.fnUntraced(function* (context: ActiveCursorTurn) { | |
| 1112 | if (context.reasoning.current === null) { | |
| 1113 | return; | |
| 1114 | } | |
| 1115 | yield* emitReasoning(context, true); | |
| 1116 | context.reasoning.current = null; | |
| 1117 | }); | |
| 1118 | ||
| 1119 | const emitToolArtifacts = Effect.fnUntraced(function* (input: { | |
| 1120 | readonly active: ActiveCursorToolCall; | |
| 1121 | readonly completed: boolean; | |
| 1122 | /** Terminal status for a tool the turn ended before Cursor completed it. */ | |
| 1123 | readonly unfinishedStatus?: "interrupted" | "failed" | "cancelled"; | |
| 1124 | }) { | |
| 1125 | const { active } = input; | |
| 1126 | const toolCall = active.toolCall; | |
| 1127 | const now = yield* DateTime.now; | |
| 1128 | const failed = input.completed && cursorToolFailed(toolCall); | |
| 1129 | const status = !input.completed | |
| 1130 | ? "running" | |
| 1131 | : (input.unfinishedStatus ?? (failed ? "failed" : "completed")); | |
| 1132 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1133 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1134 | nativeItemId: active.callId, | |
| 1135 | }); | |
| 1136 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1137 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1138 | nativeItemId: active.callId, | |
| 1139 | }); | |
| 1140 | const nativeItemRef = { | |
| 1141 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1142 | nativeId: active.callId, | |
| 1143 | strength: "strong" as const, | |
| 1144 | }; | |
| 1145 | const node: OrchestrationV2ExecutionNode = { | |
| 1146 | id: nodeId, | |
| 1147 | threadId: active.target.threadId, | |
| 1148 | runId: active.target.runId, | |
| 1149 | parentNodeId: active.target.parentNodeId, | |
| 1150 | rootNodeId: active.target.rootNodeId, | |
| 1151 | kind: "tool_call", | |
| 1152 | status, | |
| 1153 | countsForRun: false, | |
| 1154 | providerThreadId: active.target.providerThreadId, | |
| 1155 | providerTurnId: active.target.providerTurnId, | |
| 1156 | nativeItemRef, | |
| 1157 | runtimeRequestId: null, | |
| 1158 | checkpointScopeId: null, | |
| 1159 | startedAt: active.startedAt, | |
| 1160 | completedAt: input.completed ? now : null, | |
| 1161 | }; | |
| 1162 | const base = { | |
| 1163 | id: turnItemId, | |
| 1164 | threadId: active.target.threadId, | |
| 1165 | runId: active.target.runId, | |
| 1166 | nodeId, | |
| 1167 | providerThreadId: active.target.providerThreadId, | |
| 1168 | providerTurnId: active.target.providerTurnId, | |
| 1169 | nativeItemRef, | |
| 1170 | parentItemId: null, | |
| 1171 | ordinal: active.ordinal, | |
| 1172 | status, | |
| 1173 | title: null, | |
| 1174 | startedAt: active.startedAt, | |
| 1175 | completedAt: input.completed ? now : null, | |
| 1176 | updatedAt: now, | |
| 1177 | } satisfies Pick< | |
| 1178 | OrchestrationV2TurnItem, | |
| 1179 | | "id" | |
| 1180 | | "threadId" | |
| 1181 | | "runId" | |
| 1182 | | "nodeId" | |
| 1183 | | "providerThreadId" | |
| 1184 | | "providerTurnId" | |
| 1185 | | "nativeItemRef" | |
| 1186 | | "parentItemId" | |
| 1187 | | "ordinal" | |
| 1188 | | "status" | |
| 1189 | | "title" | |
| 1190 | | "startedAt" | |
| 1191 | | "completedAt" | |
| 1192 | | "updatedAt" | |
| 1193 | >; | |
| 1194 | const outputText = cursorToolOutputText(toolCall) || active.streamedOutput; | |
| 1195 | let turnItem: OrchestrationV2TurnItem; | |
| 1196 | switch (toolCall.type) { | |
| 1197 | case "shell": | |
| 1198 | turnItem = { | |
| 1199 | ...base, | |
| 1200 | type: "command_execution", | |
| 1201 | input: toolCall.args.command, | |
| 1202 | ...(outputText.length === 0 ? {} : { output: outputText }), | |
| 1203 | ...(toolCall.result?.status === "success" | |
| 1204 | ? { exitCode: toolCall.result.value.exitCode } | |
| 1205 | : {}), | |
| 1206 | }; | |
| 1207 | break; | |
| 1208 | case "write": | |
| 1209 | case "delete": | |
| 1210 | case "edit": | |
| 1211 | case "generateImage": | |
| 1212 | turnItem = { | |
| 1213 | ...base, | |
| 1214 | type: "file_change", | |
| 1215 | fileName: cursorToolFileName(toolCall), | |
| 1216 | ...(toolCall.type === "edit" && | |
| 1217 | toolCall.result?.status === "success" && | |
| 1218 | toolCall.result.value.linesAdded !== undefined | |
| 1219 | ? { additions: toolCall.result.value.linesAdded } | |
| 1220 | : {}), | |
| 1221 | ...(toolCall.type === "edit" && | |
| 1222 | toolCall.result?.status === "success" && | |
| 1223 | toolCall.result.value.linesRemoved !== undefined | |
| 1224 | ? { deletions: toolCall.result.value.linesRemoved } | |
| 1225 | : {}), | |
| 1226 | ...(toolCall.type === "edit" && | |
| 1227 | toolCall.result?.status === "success" && | |
| 1228 | toolCall.result.value.diffString !== undefined | |
| 1229 | ? { diffStr: toolCall.result.value.diffString } | |
| 1230 | : // A failed change keeps its error where the diff would be. | |
| 1231 | toolCall.result?.status === "error" && outputText.trim().length > 0 | |
| 1232 | ? { diffStr: outputText } | |
| 1233 | : {}), | |
| 1234 | ...(toolCall.type === "write" ? { newStr: toolCall.args.fileText } : {}), | |
| 1235 | }; | |
| 1236 | break; | |
| 1237 | case "read": | |
| 1238 | turnItem = { | |
| 1239 | ...base, | |
| 1240 | title: formatReadToolLabel(toolCall.args.path), | |
| 1241 | type: "dynamic_tool", | |
| 1242 | toolName: "Read", | |
| 1243 | input: toolCall.args, | |
| 1244 | ...(cursorToolOutput(toolCall) === undefined | |
| 1245 | ? {} | |
| 1246 | : { output: cursorToolOutput(toolCall) }), | |
| 1247 | }; | |
| 1248 | break; | |
| 1249 | case "glob": | |
| 1250 | case "grep": | |
| 1251 | case "ls": | |
| 1252 | case "readLints": | |
| 1253 | case "semSearch": { | |
| 1254 | const pattern = cursorToolSearchPattern(toolCall); | |
| 1255 | const searchPath = | |
| 1256 | toolCall.type === "grep" | |
| 1257 | ? toolCall.args.path | |
| 1258 | : toolCall.type === "glob" | |
| 1259 | ? toolCall.args.targetDirectory | |
| 1260 | : toolCall.type === "semSearch" | |
| 1261 | ? toolCall.args.targetDirectories?.join(", ") | |
| 1262 | : pattern; | |
| 1263 | // A failed search keeps its error as one row under the searched path. | |
| 1264 | const results = | |
| 1265 | toolCall.result?.status === "error" && outputText.trim().length > 0 | |
| 1266 | ? [{ fileName: searchPath?.trim() || ".", preview: outputText }] | |
| 1267 | : cursorToolSearchResults(toolCall, path); | |
| 1268 | turnItem = { | |
| 1269 | ...base, | |
| 1270 | title: | |
| 1271 | formatSearchToolLabel({ | |
| 1272 | input: toolCall.args, | |
| 1273 | ...(pattern === undefined ? {} : { pattern }), | |
| 1274 | }) ?? null, | |
| 1275 | type: "file_search", | |
| 1276 | ...(pattern === undefined ? {} : { pattern }), | |
| 1277 | ...(results.length === 0 ? {} : { results: [...results] }), | |
| 1278 | }; | |
| 1279 | break; | |
| 1280 | } | |
| 1281 | default: | |
| 1282 | turnItem = { | |
| 1283 | ...base, | |
| 1284 | type: "dynamic_tool", | |
| 1285 | ...(toolCall.type === "mcp" | |
| 1286 | ? mcpToolPresentation({ | |
| 1287 | serverName: toolCall.args.providerIdentifier, | |
| 1288 | toolName: toolCall.args.toolName, | |
| 1289 | }) | |
| 1290 | : {}), | |
| 1291 | toolName: cursorToolName(toolCall), | |
| 1292 | input: toolCall.args, | |
| 1293 | ...(cursorToolOutput(toolCall) === undefined | |
| 1294 | ? {} | |
| 1295 | : { output: cursorToolOutput(toolCall) }), | |
| 1296 | }; | |
| 1297 | } | |
| 1298 | yield* emitProviderEvent({ | |
| 1299 | type: "node.updated", | |
| 1300 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1301 | node, | |
| 1302 | }); | |
| 1303 | yield* emitProviderEvent({ | |
| 1304 | type: "turn_item.updated", | |
| 1305 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1306 | turnItem, | |
| 1307 | }); | |
| 1308 | }); | |
| 1309 | ||
| 1310 | const ensureToolStarted = Effect.fnUntraced(function* ( | |
| 1311 | context: ActiveCursorTurn, | |
| 1312 | callId: string, | |
| 1313 | toolCall: ToolCall, | |
| 1314 | ) { | |
| 1315 | const existing = context.tools.get(callId); | |
| 1316 | if (existing !== undefined) { | |
| 1317 | existing.toolCall = toolCall; | |
| 1318 | return existing; | |
| 1319 | } | |
| 1320 | const startedAt = yield* DateTime.now; | |
| 1321 | const active: ActiveCursorToolCall = { | |
| 1322 | callId, | |
| 1323 | toolCall, | |
| 1324 | target: parentTarget(context), | |
| 1325 | ordinal: yield* resolveItemOrdinal(context, callId), | |
| 1326 | startedAt, | |
| 1327 | streamedOutput: "", | |
| 1328 | }; | |
| 1329 | context.tools.set(callId, active); | |
| 1330 | yield* emitToolArtifacts({ active, completed: false }); | |
| 1331 | return active; | |
| 1332 | }); | |
| 1333 | ||
| 1334 | const emitPlanArtifacts = Effect.fnUntraced(function* (input: { | |
| 1335 | readonly context: ActiveCursorTurn; | |
| 1336 | readonly callId: string; | |
| 1337 | readonly markdown: string; | |
| 1338 | readonly completed: boolean; | |
| 1339 | readonly failed: boolean; | |
| 1340 | }) { | |
| 1341 | const nativeItemId = `plan:${input.callId}`; | |
| 1342 | const now = yield* DateTime.now; | |
| 1343 | const planId = yield* resolvePlanId(input.context, nativeItemId); | |
| 1344 | const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId); | |
| 1345 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1346 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1347 | nativeItemId, | |
| 1348 | }); | |
| 1349 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1350 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1351 | nativeItemId, | |
| 1352 | }); | |
| 1353 | const status = input.failed ? "failed" : input.completed ? "completed" : "running"; | |
| 1354 | const nativeItemRef = { | |
| 1355 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1356 | nativeId: input.callId, | |
| 1357 | strength: "strong" as const, | |
| 1358 | }; | |
| 1359 | const plan: OrchestrationV2PlanArtifact = { | |
| 1360 | id: planId, | |
| 1361 | threadId: input.context.input.threadId, | |
| 1362 | runId: input.context.input.runId, | |
| 1363 | nodeId, | |
| 1364 | kind: "proposed_plan", | |
| 1365 | status: input.completed ? "active" : "draft", | |
| 1366 | markdown: input.markdown, | |
| 1367 | }; | |
| 1368 | yield* emitProviderEvent({ | |
| 1369 | type: "node.updated", | |
| 1370 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1371 | node: { | |
| 1372 | id: nodeId, | |
| 1373 | threadId: input.context.input.threadId, | |
| 1374 | runId: input.context.input.runId, | |
| 1375 | parentNodeId: input.context.input.rootNodeId, | |
| 1376 | rootNodeId: input.context.input.rootNodeId, | |
| 1377 | kind: "plan", | |
| 1378 | status, | |
| 1379 | countsForRun: false, | |
| 1380 | providerThreadId: input.context.input.providerThread.id, | |
| 1381 | providerTurnId: input.context.providerTurnId, | |
| 1382 | nativeItemRef, | |
| 1383 | runtimeRequestId: null, | |
| 1384 | checkpointScopeId: null, | |
| 1385 | startedAt: input.context.startedAt, | |
| 1386 | completedAt: input.completed ? now : null, | |
| 1387 | }, | |
| 1388 | }); | |
| 1389 | yield* emitProviderEvent({ | |
| 1390 | type: "plan.updated", | |
| 1391 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1392 | plan, | |
| 1393 | }); | |
| 1394 | yield* emitProviderEvent({ | |
| 1395 | type: "turn_item.updated", | |
| 1396 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1397 | turnItem: { | |
| 1398 | id: turnItemId, | |
| 1399 | threadId: input.context.input.threadId, | |
| 1400 | runId: input.context.input.runId, | |
| 1401 | nodeId, | |
| 1402 | providerThreadId: input.context.input.providerThread.id, | |
| 1403 | providerTurnId: input.context.providerTurnId, | |
| 1404 | nativeItemRef, | |
| 1405 | parentItemId: null, | |
| 1406 | ordinal, | |
| 1407 | status, | |
| 1408 | title: null, | |
| 1409 | startedAt: input.context.startedAt, | |
| 1410 | completedAt: input.completed ? now : null, | |
| 1411 | updatedAt: now, | |
| 1412 | type: "proposed_plan", | |
| 1413 | planId, | |
| 1414 | markdown: input.markdown, | |
| 1415 | streaming: !input.completed, | |
| 1416 | }, | |
| 1417 | }); | |
| 1418 | }); | |
| 1419 | ||
| 1420 | const emitTodoArtifacts = Effect.fnUntraced(function* (input: { | |
| 1421 | readonly context: ActiveCursorTurn; | |
| 1422 | readonly callId: string; | |
| 1423 | readonly toolCall: Extract<ToolCall, { readonly type: "updateTodos" }>; | |
| 1424 | readonly completed: boolean; | |
| 1425 | readonly failed: boolean; | |
| 1426 | }) { | |
| 1427 | const nativeItemId = `todos:${input.callId}`; | |
| 1428 | const now = yield* DateTime.now; | |
| 1429 | const planId = yield* resolvePlanId(input.context, nativeItemId); | |
| 1430 | const ordinal = yield* resolveItemOrdinal(input.context, nativeItemId); | |
| 1431 | const nodeId = idAllocator.derive.nodeFromProviderItem({ | |
| 1432 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1433 | nativeItemId, | |
| 1434 | }); | |
| 1435 | const turnItemId = idAllocator.derive.turnItemFromProviderItem({ | |
| 1436 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1437 | nativeItemId, | |
| 1438 | }); | |
| 1439 | const status = input.failed ? "failed" : input.completed ? "completed" : "running"; | |
| 1440 | const steps = cursorTodoSteps(input.toolCall); | |
| 1441 | const nativeItemRef = { | |
| 1442 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1443 | nativeId: input.callId, | |
| 1444 | strength: "strong" as const, | |
| 1445 | }; | |
| 1446 | yield* emitProviderEvent({ | |
| 1447 | type: "node.updated", | |
| 1448 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1449 | node: { | |
| 1450 | id: nodeId, | |
| 1451 | threadId: input.context.input.threadId, | |
| 1452 | runId: input.context.input.runId, | |
| 1453 | parentNodeId: input.context.input.rootNodeId, | |
| 1454 | rootNodeId: input.context.input.rootNodeId, | |
| 1455 | kind: "todo_list", | |
| 1456 | status, | |
| 1457 | countsForRun: false, | |
| 1458 | providerThreadId: input.context.input.providerThread.id, | |
| 1459 | providerTurnId: input.context.providerTurnId, | |
| 1460 | nativeItemRef, | |
| 1461 | runtimeRequestId: null, | |
| 1462 | checkpointScopeId: null, | |
| 1463 | startedAt: input.context.startedAt, | |
| 1464 | completedAt: input.completed ? now : null, | |
| 1465 | }, | |
| 1466 | }); | |
| 1467 | yield* emitProviderEvent({ | |
| 1468 | type: "plan.updated", | |
| 1469 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1470 | plan: { | |
| 1471 | id: planId, | |
| 1472 | threadId: input.context.input.threadId, | |
| 1473 | runId: input.context.input.runId, | |
| 1474 | nodeId, | |
| 1475 | kind: "todo_list", | |
| 1476 | status: input.completed ? "active" : "draft", | |
| 1477 | steps: [...steps], | |
| 1478 | }, | |
| 1479 | }); | |
| 1480 | yield* emitProviderEvent({ | |
| 1481 | type: "turn_item.updated", | |
| 1482 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1483 | turnItem: { | |
| 1484 | id: turnItemId, | |
| 1485 | threadId: input.context.input.threadId, | |
| 1486 | runId: input.context.input.runId, | |
| 1487 | nodeId, | |
| 1488 | providerThreadId: input.context.input.providerThread.id, | |
| 1489 | providerTurnId: input.context.providerTurnId, | |
| 1490 | nativeItemRef, | |
| 1491 | parentItemId: null, | |
| 1492 | ordinal, | |
| 1493 | status, | |
| 1494 | title: null, | |
| 1495 | startedAt: input.context.startedAt, | |
| 1496 | completedAt: input.completed ? now : null, | |
| 1497 | updatedAt: now, | |
| 1498 | type: "todo_list", | |
| 1499 | planId, | |
| 1500 | steps: [...steps], | |
| 1501 | }, | |
| 1502 | }); | |
| 1503 | }); | |
| 1504 | ||
| 1505 | const emitSubagent = Effect.fnUntraced(function* (input: { | |
| 1506 | readonly context: ActiveCursorTurn; | |
| 1507 | readonly callId: string; | |
| 1508 | readonly toolCall: Extract<ToolCall, { readonly type: "task" }>; | |
| 1509 | readonly completed: boolean; | |
| 1510 | readonly status?: OrchestrationV2Subagent["status"]; | |
| 1511 | }) { | |
| 1512 | const args = input.toolCall.args; | |
| 1513 | const result = | |
| 1514 | input.toolCall.result?.status === "success" ? input.toolCall.result.value : undefined; | |
| 1515 | const existing = input.context.subagents.get(input.callId); | |
| 1516 | if ( | |
| 1517 | existing !== undefined && | |
| 1518 | !isOrchestrationV2WorkActive(existing.task.status) && | |
| 1519 | !input.completed | |
| 1520 | ) | |
| 1521 | return; | |
| 1522 | const now = yield* DateTime.now; | |
| 1523 | const status: OrchestrationV2Subagent["status"] = | |
| 1524 | input.status ?? | |
| 1525 | (input.completed | |
| 1526 | ? cursorToolFailed(input.toolCall) | |
| 1527 | ? "failed" | |
| 1528 | : "completed" | |
| 1529 | : "running"); | |
| 1530 | const resultText = [ | |
| 1531 | ...assistantTextsFromConversationSteps(result?.conversationSteps ?? []), | |
| 1532 | ...(result?.resultSuffix === undefined ? [] : [result.resultSuffix]), | |
| 1533 | ] | |
| 1534 | .filter((part) => part.trim().length > 0) | |
| 1535 | .join("\n"); | |
| 1536 | const nativeItemId = input.callId; | |
| 1537 | const nodeId = | |
| 1538 | existing?.task.id ?? | |
| 1539 | idAllocator.derive.nodeFromProviderItem({ | |
| 1540 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1541 | nativeItemId, | |
| 1542 | }); | |
| 1543 | const childRootNodeId = | |
| 1544 | existing?.childRootNodeId ?? | |
| 1545 | idAllocator.derive.nodeFromProviderItem({ | |
| 1546 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1547 | nativeItemId: `${nativeItemId}:child-root`, | |
| 1548 | }); | |
| 1549 | const childThreadId = | |
| 1550 | existing?.childThreadId ?? | |
| 1551 | idAllocator.derive.threadFromProviderThread({ | |
| 1552 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1553 | nativeThreadId: `${input.context.run.runId}:task:${input.callId}`, | |
| 1554 | }); | |
| 1555 | const task: OrchestrationV2Subagent = { | |
| 1556 | ...(existing?.task ?? { | |
| 1557 | id: nodeId, | |
| 1558 | threadId: input.context.input.threadId, | |
| 1559 | runId: input.context.input.runId, | |
| 1560 | parentNodeId: input.context.input.rootNodeId, | |
| 1561 | origin: "provider_native" as const, | |
| 1562 | createdBy: "agent" as const, | |
| 1563 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1564 | providerInstanceId: input.context.input.modelSelection.instanceId, | |
| 1565 | providerThreadId: null, | |
| 1566 | childThreadId, | |
| 1567 | nativeTaskRef: { | |
| 1568 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1569 | nativeId: input.callId, | |
| 1570 | strength: "strong" as const, | |
| 1571 | }, | |
| 1572 | prompt: args.prompt, | |
| 1573 | title: args.description, | |
| 1574 | result: null, | |
| 1575 | startedAt: now, | |
| 1576 | }), | |
| 1577 | model: args.model?.trim() || existing?.task.model || null, | |
| 1578 | nativeTaskRef: { | |
| 1579 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1580 | nativeId: input.callId, | |
| 1581 | strength: "strong" as const, | |
| 1582 | }, | |
| 1583 | status, | |
| 1584 | result: resultText.length === 0 ? (existing?.task.result ?? null) : resultText, | |
| 1585 | completedAt: input.completed ? (existing?.task.completedAt ?? now) : null, | |
| 1586 | updatedAt: now, | |
| 1587 | }; | |
| 1588 | const subagent: ActiveCursorSubagent = { | |
| 1589 | task, | |
| 1590 | toolCall: input.toolCall, | |
| 1591 | callId: input.callId, | |
| 1592 | childThreadId, | |
| 1593 | childRootNodeId, | |
| 1594 | turnItemId: | |
| 1595 | existing?.turnItemId ?? | |
| 1596 | idAllocator.derive.turnItemFromProviderItem({ | |
| 1597 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1598 | nativeItemId, | |
| 1599 | }), | |
| 1600 | turnItemOrdinal: | |
| 1601 | existing?.turnItemOrdinal ?? (yield* resolveItemOrdinal(input.context, nativeItemId)), | |
| 1602 | nextChildOrdinal: existing?.nextChildOrdinal ?? 100, | |
| 1603 | resultProjected: existing?.resultProjected ?? false, | |
| 1604 | }; | |
| 1605 | input.context.subagents.set(input.callId, subagent); | |
| 1606 | ||
| 1607 | if (existing === undefined) { | |
| 1608 | yield* emitProviderEvent({ | |
| 1609 | type: "app_thread.created", | |
| 1610 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1611 | appThread: makeSubagentChildThread({ | |
| 1612 | parentThread: input.context.input.appThread, | |
| 1613 | childThreadId, | |
| 1614 | parentNodeId: nodeId, | |
| 1615 | activeProviderThreadId: null, | |
| 1616 | providerInstanceId: input.context.input.modelSelection.instanceId, | |
| 1617 | modelSelection: input.context.input.modelSelection, | |
| 1618 | title: subagentThreadTitle({ | |
| 1619 | parentTitle: input.context.input.appThread.title, | |
| 1620 | title: args.description, | |
| 1621 | prompt: args.prompt, | |
| 1622 | ordinal: input.context.subagents.size, | |
| 1623 | }), | |
| 1624 | now, | |
| 1625 | createdBy: "agent", | |
| 1626 | creationSource: "provider", | |
| 1627 | }), | |
| 1628 | }); | |
| 1629 | const promptNativeId = `${nativeItemId}:prompt`; | |
| 1630 | const promptArtifacts = makeSubagentConversationArtifacts({ | |
| 1631 | senderThreadId: input.context.input.threadId, | |
| 1632 | messageId: idAllocator.derive.messageFromProviderItem({ | |
| 1633 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1634 | nativeItemId: promptNativeId, | |
| 1635 | }), | |
| 1636 | turnItemId: idAllocator.derive.turnItemFromProviderItem({ | |
| 1637 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1638 | nativeItemId: promptNativeId, | |
| 1639 | }), | |
| 1640 | threadId: childThreadId, | |
| 1641 | rootNodeId: childRootNodeId, | |
| 1642 | providerThreadId: null, | |
| 1643 | providerTurnId: null, | |
| 1644 | nativeItemRef: { | |
| 1645 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1646 | nativeId: promptNativeId, | |
| 1647 | strength: "weak", | |
| 1648 | }, | |
| 1649 | role: "user", | |
| 1650 | text: args.prompt, | |
| 1651 | ordinal: 100, | |
| 1652 | now, | |
| 1653 | }); | |
| 1654 | yield* emitProviderEvent({ | |
| 1655 | type: "message.updated", | |
| 1656 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1657 | message: promptArtifacts.message, | |
| 1658 | }); | |
| 1659 | yield* emitProviderEvent({ | |
| 1660 | type: "turn_item.updated", | |
| 1661 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1662 | turnItem: promptArtifacts.turnItem, | |
| 1663 | }); | |
| 1664 | } | |
| 1665 | ||
| 1666 | yield* emitProviderEvent({ | |
| 1667 | type: "node.updated", | |
| 1668 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1669 | node: { | |
| 1670 | id: nodeId, | |
| 1671 | threadId: input.context.input.threadId, | |
| 1672 | runId: input.context.input.runId, | |
| 1673 | parentNodeId: input.context.input.rootNodeId, | |
| 1674 | rootNodeId: input.context.input.rootNodeId, | |
| 1675 | kind: "subagent", | |
| 1676 | status, | |
| 1677 | countsForRun: false, | |
| 1678 | providerThreadId: input.context.input.providerThread.id, | |
| 1679 | providerTurnId: input.context.providerTurnId, | |
| 1680 | nativeItemRef: task.nativeTaskRef, | |
| 1681 | runtimeRequestId: null, | |
| 1682 | checkpointScopeId: null, | |
| 1683 | startedAt: task.startedAt, | |
| 1684 | completedAt: task.completedAt, | |
| 1685 | }, | |
| 1686 | }); | |
| 1687 | yield* emitProviderEvent({ | |
| 1688 | type: "node.updated", | |
| 1689 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1690 | node: { | |
| 1691 | id: childRootNodeId, | |
| 1692 | threadId: childThreadId, | |
| 1693 | runId: null, | |
| 1694 | parentNodeId: null, | |
| 1695 | rootNodeId: childRootNodeId, | |
| 1696 | kind: "root_turn", | |
| 1697 | status, | |
| 1698 | countsForRun: false, | |
| 1699 | providerThreadId: null, | |
| 1700 | providerTurnId: null, | |
| 1701 | nativeItemRef: task.nativeTaskRef, | |
| 1702 | runtimeRequestId: null, | |
| 1703 | checkpointScopeId: null, | |
| 1704 | startedAt: task.startedAt, | |
| 1705 | completedAt: task.completedAt, | |
| 1706 | }, | |
| 1707 | }); | |
| 1708 | yield* emitProviderEvent({ | |
| 1709 | type: "subagent.updated", | |
| 1710 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1711 | subagent: task, | |
| 1712 | }); | |
| 1713 | yield* emitProviderEvent({ | |
| 1714 | type: "turn_item.updated", | |
| 1715 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1716 | turnItem: { | |
| 1717 | id: subagent.turnItemId, | |
| 1718 | threadId: input.context.input.threadId, | |
| 1719 | runId: input.context.input.runId, | |
| 1720 | nodeId, | |
| 1721 | providerThreadId: input.context.input.providerThread.id, | |
| 1722 | providerTurnId: input.context.providerTurnId, | |
| 1723 | nativeItemRef: task.nativeTaskRef, | |
| 1724 | parentItemId: null, | |
| 1725 | ordinal: subagent.turnItemOrdinal, | |
| 1726 | status, | |
| 1727 | title: task.title, | |
| 1728 | startedAt: task.startedAt, | |
| 1729 | completedAt: task.completedAt, | |
| 1730 | updatedAt: now, | |
| 1731 | type: "subagent", | |
| 1732 | subagentId: task.id, | |
| 1733 | origin: task.origin, | |
| 1734 | driver: task.driver, | |
| 1735 | providerInstanceId: task.providerInstanceId, | |
| 1736 | childThreadId, | |
| 1737 | prompt: task.prompt, | |
| 1738 | result: task.result, | |
| 1739 | }, | |
| 1740 | }); | |
| 1741 | ||
| 1742 | if (input.completed && !subagent.resultProjected) { | |
| 1743 | for (const [index, nestedTool] of toolCallsFromConversationSteps( | |
| 1744 | result?.conversationSteps ?? [], | |
| 1745 | ).entries()) { | |
| 1746 | const callId = `${nativeItemId}:child-tool:${nestedTool.callId || index + 1}`; | |
| 1747 | const startedAt = task.startedAt ?? now; | |
| 1748 | const active: ActiveCursorToolCall = { | |
| 1749 | callId, | |
| 1750 | toolCall: nestedTool.toolCall, | |
| 1751 | target: { | |
| 1752 | threadId: childThreadId, | |
| 1753 | runId: null, | |
| 1754 | rootNodeId: childRootNodeId, | |
| 1755 | parentNodeId: childRootNodeId, | |
| 1756 | providerThreadId: null, | |
| 1757 | providerTurnId: null, | |
| 1758 | }, | |
| 1759 | ordinal: ++subagent.nextChildOrdinal, | |
| 1760 | startedAt, | |
| 1761 | streamedOutput: "", | |
| 1762 | }; | |
| 1763 | yield* emitToolArtifacts({ active, completed: true }); | |
| 1764 | } | |
| 1765 | if (task.result !== null && task.result.length > 0) { | |
| 1766 | const resultNativeId = `${nativeItemId}:result`; | |
| 1767 | const resultArtifacts = makeSubagentConversationArtifacts({ | |
| 1768 | messageId: idAllocator.derive.messageFromProviderItem({ | |
| 1769 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1770 | nativeItemId: resultNativeId, | |
| 1771 | }), | |
| 1772 | turnItemId: idAllocator.derive.turnItemFromProviderItem({ | |
| 1773 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1774 | nativeItemId: resultNativeId, | |
| 1775 | }), | |
| 1776 | threadId: childThreadId, | |
| 1777 | rootNodeId: childRootNodeId, | |
| 1778 | providerThreadId: null, | |
| 1779 | providerTurnId: null, | |
| 1780 | nativeItemRef: { | |
| 1781 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1782 | nativeId: resultNativeId, | |
| 1783 | strength: "weak", | |
| 1784 | }, | |
| 1785 | role: "assistant", | |
| 1786 | text: task.result, | |
| 1787 | ordinal: ++subagent.nextChildOrdinal, | |
| 1788 | now, | |
| 1789 | }); | |
| 1790 | yield* emitProviderEvent({ | |
| 1791 | type: "message.updated", | |
| 1792 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1793 | message: resultArtifacts.message, | |
| 1794 | }); | |
| 1795 | yield* emitProviderEvent({ | |
| 1796 | type: "turn_item.updated", | |
| 1797 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1798 | turnItem: resultArtifacts.turnItem, | |
| 1799 | }); | |
| 1800 | } | |
| 1801 | subagent.resultProjected = true; | |
| 1802 | } | |
| 1803 | }); | |
| 1804 | ||
| 1805 | const handleToolUpdate = Effect.fnUntraced(function* ( | |
| 1806 | context: ActiveCursorTurn, | |
| 1807 | update: Extract< | |
| 1808 | InteractionUpdate, | |
| 1809 | { | |
| 1810 | readonly type: "tool-call-started" | "partial-tool-call" | "tool-call-completed"; | |
| 1811 | } | |
| 1812 | >, | |
| 1813 | ) { | |
| 1814 | const completed = update.type === "tool-call-completed"; | |
| 1815 | const toolCall = update.toolCall; | |
| 1816 | switch (toolCall.type) { | |
| 1817 | case "createPlan": | |
| 1818 | yield* emitPlanArtifacts({ | |
| 1819 | context, | |
| 1820 | callId: update.callId, | |
| 1821 | markdown: toolCall.args.plan, | |
| 1822 | completed, | |
| 1823 | failed: completed && cursorToolFailed(toolCall), | |
| 1824 | }); | |
| 1825 | return; | |
| 1826 | case "updateTodos": | |
| 1827 | yield* emitTodoArtifacts({ | |
| 1828 | context, | |
| 1829 | callId: update.callId, | |
| 1830 | toolCall, | |
| 1831 | completed, | |
| 1832 | failed: completed && cursorToolFailed(toolCall), | |
| 1833 | }); | |
| 1834 | return; | |
| 1835 | case "task": | |
| 1836 | if (update.type === "partial-tool-call") { | |
| 1837 | return; | |
| 1838 | } | |
| 1839 | yield* emitSubagent({ | |
| 1840 | context, | |
| 1841 | callId: update.callId, | |
| 1842 | toolCall, | |
| 1843 | completed, | |
| 1844 | }); | |
| 1845 | return; | |
| 1846 | default: { | |
| 1847 | const active = yield* ensureToolStarted(context, update.callId, toolCall); | |
| 1848 | active.toolCall = toolCall; | |
| 1849 | if (update.type !== "tool-call-started") { | |
| 1850 | yield* emitToolArtifacts({ active, completed }); | |
| 1851 | } | |
| 1852 | if (completed) { | |
| 1853 | context.tools.delete(update.callId); | |
| 1854 | } | |
| 1855 | } | |
| 1856 | } | |
| 1857 | }); | |
| 1858 | ||
| 1859 | const shellOutputText = (event: Record<string, unknown>): string => { | |
| 1860 | const candidates = [ | |
| 1861 | event.text, | |
| 1862 | event.output, | |
| 1863 | event.data, | |
| 1864 | event.stdout, | |
| 1865 | event.stderr, | |
| 1866 | event.chunk, | |
| 1867 | ]; | |
| 1868 | return candidates.filter((value): value is string => typeof value === "string").join(""); | |
| 1869 | }; | |
| 1870 | ||
| 1871 | const handleInteractionUpdate = Effect.fnUntraced(function* ( | |
| 1872 | context: ActiveCursorTurn, | |
| 1873 | update: InteractionUpdate, | |
| 1874 | ) { | |
| 1875 | if (context.finalized) { | |
| 1876 | return; | |
| 1877 | } | |
| 1878 | switch (update.type) { | |
| 1879 | case "text-delta": | |
| 1880 | context.assistantReply.push(update.text); | |
| 1881 | yield* completeReasoning(context); | |
| 1882 | yield* appendTextSegment({ | |
| 1883 | context, | |
| 1884 | stream: context.assistant, | |
| 1885 | kind: "assistant", | |
| 1886 | text: update.text, | |
| 1887 | }); | |
| 1888 | yield* emitAssistant(context, false); | |
| 1889 | return; | |
| 1890 | case "thinking-delta": | |
| 1891 | yield* completeAssistant(context); | |
| 1892 | yield* appendTextSegment({ | |
| 1893 | context, | |
| 1894 | stream: context.reasoning, | |
| 1895 | kind: "reasoning", | |
| 1896 | text: update.text, | |
| 1897 | }); | |
| 1898 | yield* emitReasoning(context, false); | |
| 1899 | return; | |
| 1900 | case "thinking-completed": | |
| 1901 | yield* completeReasoning(context); | |
| 1902 | return; | |
| 1903 | case "tool-call-started": | |
| 1904 | case "partial-tool-call": | |
| 1905 | case "tool-call-completed": | |
| 1906 | yield* completeAssistant(context); | |
| 1907 | yield* completeReasoning(context); | |
| 1908 | yield* handleToolUpdate(context, update); | |
| 1909 | return; | |
| 1910 | case "step-completed": | |
| 1911 | case "turn-ended": | |
| 1912 | yield* completeAssistant(context); | |
| 1913 | yield* completeReasoning(context); | |
| 1914 | return; | |
| 1915 | case "shell-output-delta": { | |
| 1916 | const shell = Array.from(context.tools.values()) | |
| 1917 | .toReversed() | |
| 1918 | .find((candidate) => candidate.toolCall.type === "shell"); | |
| 1919 | if (shell === undefined) { | |
| 1920 | return; | |
| 1921 | } | |
| 1922 | shell.streamedOutput += shellOutputText(update.event); | |
| 1923 | yield* emitToolArtifacts({ active: shell, completed: false }); | |
| 1924 | return; | |
| 1925 | } | |
| 1926 | default: | |
| 1927 | return; | |
| 1928 | } | |
| 1929 | }); | |
| 1930 | ||
| 1931 | const providerTurnPayload = (input: { | |
| 1932 | readonly context: ActiveCursorTurn; | |
| 1933 | readonly status: OrchestrationV2ProviderTurn["status"]; | |
| 1934 | readonly completedAt: DateTime.Utc | null; | |
| 1935 | }): OrchestrationV2ProviderTurn => ({ | |
| 1936 | id: input.context.providerTurnId, | |
| 1937 | providerThreadId: input.context.input.providerThread.id, | |
| 1938 | nodeId: input.context.input.rootNodeId, | |
| 1939 | runAttemptId: input.context.input.attemptId, | |
| 1940 | nativeTurnRef: { | |
| 1941 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1942 | nativeId: input.context.run.runId, | |
| 1943 | strength: "strong", | |
| 1944 | }, | |
| 1945 | ordinal: input.context.input.providerTurnOrdinal, | |
| 1946 | status: input.status, | |
| 1947 | startedAt: input.context.startedAt, | |
| 1948 | completedAt: input.completedAt, | |
| 1949 | }); | |
| 1950 | ||
| 1951 | const finalizeTurn = Effect.fnUntraced(function* (input: { | |
| 1952 | readonly context: ActiveCursorTurn; | |
| 1953 | readonly status: Extract< | |
| 1954 | OrchestrationV2ProviderTurn["status"], | |
| 1955 | "completed" | "interrupted" | "failed" | "cancelled" | |
| 1956 | >; | |
| 1957 | readonly failure?: OrchestrationV2ProviderFailure; | |
| 1958 | readonly threadDisposition?: "reusable" | "broken"; | |
| 1959 | }) { | |
| 1960 | if (input.context.finalized) { | |
| 1961 | return; | |
| 1962 | } | |
| 1963 | input.context.finalized = true; | |
| 1964 | const completedAt = yield* DateTime.now; | |
| 1965 | // Tools still here never got a tool-call-completed. A stopped or | |
| 1966 | // failed turn cut them short, so they end with the turn's status. | |
| 1967 | for (const tool of input.context.tools.values()) { | |
| 1968 | yield* emitToolArtifacts({ | |
| 1969 | active: tool, | |
| 1970 | completed: true, | |
| 1971 | ...(input.status === "completed" ? {} : { unfinishedStatus: input.status }), | |
| 1972 | }); | |
| 1973 | } | |
| 1974 | input.context.tools.clear(); | |
| 1975 | // This interaction stops delivering updates at finalization. Tasks | |
| 1976 | // without a completion have an unknown outcome. Background launch | |
| 1977 | // acknowledgements have already settled their rows. | |
| 1978 | for (const subagent of input.context.subagents.values()) { | |
| 1979 | if (!isOrchestrationV2WorkActive(subagent.task.status)) continue; | |
| 1980 | yield* emitSubagent({ | |
| 1981 | context: input.context, | |
| 1982 | callId: subagent.callId, | |
| 1983 | toolCall: subagent.toolCall, | |
| 1984 | completed: true, | |
| 1985 | status: input.status === "completed" ? "idle" : input.status, | |
| 1986 | }); | |
| 1987 | } | |
| 1988 | yield* completeReasoning(input.context); | |
| 1989 | yield* completeAssistant(input.context); | |
| 1990 | yield* emitProviderEvent({ | |
| 1991 | type: "provider_turn.updated", | |
| 1992 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 1993 | providerTurn: providerTurnPayload({ | |
| 1994 | context: input.context, | |
| 1995 | status: input.status, | |
| 1996 | completedAt, | |
| 1997 | }), | |
| 1998 | }); | |
| 1999 | yield* emitProviderEvent({ | |
| 2000 | type: "provider_thread.updated", | |
| 2001 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2002 | providerThread: { | |
| 2003 | ...input.context.input.providerThread, | |
| 2004 | providerSessionId: session.id, | |
| 2005 | status: "active", | |
| 2006 | firstRunOrdinal: | |
| 2007 | input.context.input.providerThread.firstRunOrdinal ?? | |
| 2008 | input.context.input.runOrdinal, | |
| 2009 | lastRunOrdinal: input.context.input.runOrdinal, | |
| 2010 | updatedAt: completedAt, | |
| 2011 | }, | |
| 2012 | }); | |
| 2013 | const threadDisposition = input.threadDisposition ?? "reusable"; | |
| 2014 | yield* emitProviderEvent( | |
| 2015 | input.status === "failed" | |
| 2016 | ? { | |
| 2017 | type: "turn.terminal", | |
| 2018 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2019 | providerThreadId: input.context.input.providerThread.id, | |
| 2020 | providerTurnId: input.context.providerTurnId, | |
| 2021 | runOrdinal: input.context.input.runOrdinal, | |
| 2022 | failureItemOrdinal: yield* resolveItemOrdinal( | |
| 2023 | input.context, | |
| 2024 | `terminal-failure:${input.context.providerTurnId}`, | |
| 2025 | ), | |
| 2026 | status: input.status, | |
| 2027 | failure: input.failure ?? makeProviderFailure({ class: "provider_error" }), | |
| 2028 | threadDisposition, | |
| 2029 | } | |
| 2030 | : { | |
| 2031 | type: "turn.terminal", | |
| 2032 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2033 | providerThreadId: input.context.input.providerThread.id, | |
| 2034 | providerTurnId: input.context.providerTurnId, | |
| 2035 | runOrdinal: input.context.input.runOrdinal, | |
| 2036 | status: input.status, | |
| 2037 | failure: null, | |
| 2038 | threadDisposition, | |
| 2039 | }, | |
| 2040 | ); | |
| 2041 | yield* Ref.update(activeTurn, (current) => | |
| 2042 | current?.providerTurnId === input.context.providerTurnId ? null : current, | |
| 2043 | ); | |
| 2044 | yield* Deferred.succeed(input.context.completed, undefined); | |
| 2045 | }); | |
| 2046 | ||
| 2047 | const terminalStatus = ( | |
| 2048 | context: ActiveCursorTurn, | |
| 2049 | result: RunResult, | |
| 2050 | ): Extract< | |
| 2051 | OrchestrationV2ProviderTurn["status"], | |
| 2052 | "completed" | "interrupted" | "failed" | "cancelled" | |
| 2053 | > => { | |
| 2054 | if (context.interrupted) { | |
| 2055 | return "interrupted"; | |
| 2056 | } | |
| 2057 | switch (result.status) { | |
| 2058 | case "finished": | |
| 2059 | return "completed"; | |
| 2060 | case "cancelled": | |
| 2061 | return "cancelled"; | |
| 2062 | case "error": | |
| 2063 | return "failed"; | |
| 2064 | } | |
| 2065 | }; | |
| 2066 | ||
| 2067 | const openAgent = Effect.fnUntraced(function* (openInput: { | |
| 2068 | readonly operation: "create" | "resume"; | |
| 2069 | readonly threadId: ThreadId; | |
| 2070 | readonly modelSelection: ModelSelection; | |
| 2071 | readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; | |
| 2072 | readonly agentId?: string; | |
| 2073 | }) { | |
| 2074 | const existing = yield* Ref.get(liveAgent); | |
| 2075 | if ( | |
| 2076 | existing !== null && | |
| 2077 | openInput.operation === "resume" && | |
| 2078 | existing.nativeThreadId === openInput.agentId | |
| 2079 | ) { | |
| 2080 | return existing; | |
| 2081 | } | |
| 2082 | if (existing !== null) { | |
| 2083 | yield* existing.session.close.pipe(Effect.ignore); | |
| 2084 | yield* Ref.set(liveAgent, null); | |
| 2085 | } | |
| 2086 | const sdkSession = yield* runner.open({ | |
| 2087 | operation: openInput.operation, | |
| 2088 | ...(openInput.agentId === undefined ? {} : { agentId: openInput.agentId }), | |
| 2089 | options: makeCursorAgentOptions({ | |
| 2090 | ...(apiKey === undefined ? {} : { apiKey }), | |
| 2091 | modelSelection: openInput.modelSelection, | |
| 2092 | runtimePolicy: openInput.runtimePolicy, | |
| 2093 | threadId: openInput.threadId, | |
| 2094 | }), | |
| 2095 | threadId: openInput.threadId, | |
| 2096 | providerSessionId: input.providerSessionId, | |
| 2097 | }); | |
| 2098 | const next = { | |
| 2099 | nativeThreadId: sdkSession.agentId, | |
| 2100 | session: sdkSession, | |
| 2101 | } satisfies CursorLiveAgent; | |
| 2102 | yield* Ref.set(liveAgent, next); | |
| 2103 | return next; | |
| 2104 | }); | |
| 2105 | ||
| 2106 | let cursorSkillNames: ReadonlySet<string> | undefined; | |
| 2107 | const resolveUserMessage = Effect.fnUntraced(function* ( | |
| 2108 | turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, | |
| 2109 | ) { | |
| 2110 | const rawText = turnInput.message.text; | |
| 2111 | if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) { | |
| 2112 | return "/compress"; | |
| 2113 | } | |
| 2114 | if (hasCursorSkillMention(rawText) && cursorSkillNames === undefined) { | |
| 2115 | const skills = yield* discoverCursorSkills( | |
| 2116 | turnInput.runtimePolicy.cwd ?? undefined, | |
| 2117 | adapterOptions.environment, | |
| 2118 | ).pipe( | |
| 2119 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 2120 | Effect.provideService(Path.Path, path), | |
| 2121 | ); | |
| 2122 | cursorSkillNames = new Set( | |
| 2123 | skills | |
| 2124 | .filter((skill) => skill.enabled && skill.userInvocable !== false) | |
| 2125 | .map((skill) => skill.name), | |
| 2126 | ); | |
| 2127 | } | |
| 2128 | const userText = t3OrchestrationPromptForFirstRun({ | |
| 2129 | prompt: providerMessageTextWithAttachmentPaths({ | |
| 2130 | text: | |
| 2131 | cursorSkillNames === undefined | |
| 2132 | ? rawText | |
| 2133 | : rewriteCursorSkillMentions(rawText, cursorSkillNames), | |
| 2134 | attachments: turnInput.message.attachments, | |
| 2135 | resolveAttachmentPath: host.resolveAttachmentPath, | |
| 2136 | }), | |
| 2137 | runOrdinal: turnInput.runOrdinal, | |
| 2138 | hasT3Mcp: cursorMcpServers(turnInput.threadId) !== undefined, | |
| 2139 | }); | |
| 2140 | const images = yield* Effect.forEach( | |
| 2141 | turnInput.message.attachments.filter(isProviderNativeImageAttachment), | |
| 2142 | (attachment: ChatAttachment) => | |
| 2143 | Effect.gen(function* () { | |
| 2144 | const path = host.resolveAttachmentPath(attachment); | |
| 2145 | if (path === null) { | |
| 2146 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2147 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2148 | detail: `Invalid attachment id '${attachment.id}'.`, | |
| 2149 | }); | |
| 2150 | } | |
| 2151 | const bytes = yield* fileSystem.readFile(path).pipe( | |
| 2152 | Effect.mapError( | |
| 2153 | (cause) => | |
| 2154 | new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2155 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2156 | detail: `Failed to read attachment '${attachment.id}'.`, | |
| 2157 | payload: cause, | |
| 2158 | }), | |
| 2159 | ), | |
| 2160 | ); | |
| 2161 | return { | |
| 2162 | data: Buffer.from(bytes).toString("base64"), | |
| 2163 | mimeType: attachment.mimeType, | |
| 2164 | }; | |
| 2165 | }), | |
| 2166 | { concurrency: 1 }, | |
| 2167 | ); | |
| 2168 | if (userText.length === 0 && images.length === 0) { | |
| 2169 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2170 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2171 | detail: "Cursor turn requires non-empty text or attachments.", | |
| 2172 | }); | |
| 2173 | } | |
| 2174 | const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`; | |
| 2175 | return images.length === 0 | |
| 2176 | ? text | |
| 2177 | : ({ | |
| 2178 | text, | |
| 2179 | images, | |
| 2180 | } satisfies SDKUserMessage); | |
| 2181 | }); | |
| 2182 | ||
| 2183 | const startTurn = Effect.fn("CursorAdapterV2.startTurn")( | |
| 2184 | function* (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) { | |
| 2185 | const current = yield* Ref.get(activeTurn); | |
| 2186 | if (current !== null) { | |
| 2187 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2188 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2189 | detail: `Cursor provider turn ${current.providerTurnId} is still active.`, | |
| 2190 | }); | |
| 2191 | } | |
| 2192 | const agentId = nativeThreadId(turnInput.providerThread); | |
| 2193 | const agent = yield* openAgent({ | |
| 2194 | operation: "resume", | |
| 2195 | agentId, | |
| 2196 | threadId: turnInput.threadId, | |
| 2197 | modelSelection: turnInput.modelSelection, | |
| 2198 | runtimePolicy: turnInput.runtimePolicy, | |
| 2199 | }); | |
| 2200 | const message = yield* resolveUserMessage(turnInput); | |
| 2201 | const mcpServers = cursorMcpServers(turnInput.threadId); | |
| 2202 | const pendingUpdates: Array<InteractionUpdate> = []; | |
| 2203 | let context: ActiveCursorTurn | null = null; | |
| 2204 | const sdkRun = yield* agent.session.send({ | |
| 2205 | message, | |
| 2206 | options: { | |
| 2207 | model: cursorSdkModelSelection(turnInput.modelSelection), | |
| 2208 | mode: turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", | |
| 2209 | ...(mcpServers === undefined ? {} : { mcpServers }), | |
| 2210 | }, | |
| 2211 | onDelta: (update) => { | |
| 2212 | if (context === null) { | |
| 2213 | return Effect.sync(() => { | |
| 2214 | pendingUpdates.push(update); | |
| 2215 | }); | |
| 2216 | } | |
| 2217 | return handleInteractionUpdate(context, update); | |
| 2218 | }, | |
| 2219 | }); | |
| 2220 | const startedAt = yield* DateTime.now; | |
| 2221 | const completed = yield* Deferred.make<void, never>(); | |
| 2222 | const providerTurnId = idAllocator.derive.providerTurn({ | |
| 2223 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2224 | nativeTurnId: sdkRun.runId, | |
| 2225 | }); | |
| 2226 | context = { | |
| 2227 | input: turnInput, | |
| 2228 | run: sdkRun, | |
| 2229 | providerTurnId, | |
| 2230 | startedAt, | |
| 2231 | completed, | |
| 2232 | itemOrdinals: new Map(), | |
| 2233 | tools: new Map(), | |
| 2234 | subagents: new Map(), | |
| 2235 | assistant: { | |
| 2236 | current: null, | |
| 2237 | nextSegment: 0, | |
| 2238 | }, | |
| 2239 | assistantReply: new CursorTransportFailure(), | |
| 2240 | reasoning: { | |
| 2241 | current: null, | |
| 2242 | nextSegment: 0, | |
| 2243 | }, | |
| 2244 | interrupted: false, | |
| 2245 | finalized: false, | |
| 2246 | }; | |
| 2247 | yield* Ref.set(activeTurn, context); | |
| 2248 | yield* emitProviderEvent({ | |
| 2249 | type: "provider_turn.updated", | |
| 2250 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2251 | providerTurn: providerTurnPayload({ | |
| 2252 | context, | |
| 2253 | status: "running", | |
| 2254 | completedAt: null, | |
| 2255 | }), | |
| 2256 | }); | |
| 2257 | yield* emitProviderEvent({ | |
| 2258 | type: "provider_thread.updated", | |
| 2259 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2260 | providerThread: { | |
| 2261 | ...turnInput.providerThread, | |
| 2262 | providerSessionId: session.id, | |
| 2263 | status: "active", | |
| 2264 | updatedAt: startedAt, | |
| 2265 | }, | |
| 2266 | }); | |
| 2267 | for (const update of pendingUpdates) { | |
| 2268 | yield* handleInteractionUpdate(context, update); | |
| 2269 | } | |
| 2270 | ||
| 2271 | yield* sdkRun.wait.pipe( | |
| 2272 | Effect.flatMap((result) => | |
| 2273 | Effect.gen(function* () { | |
| 2274 | if ( | |
| 2275 | context !== null && | |
| 2276 | context.assistant.nextSegment === 0 && | |
| 2277 | result.result !== undefined && | |
| 2278 | result.result.length > 0 | |
| 2279 | ) { | |
| 2280 | context.assistantReply.push(result.result); | |
| 2281 | yield* appendTextSegment({ | |
| 2282 | context, | |
| 2283 | stream: context.assistant, | |
| 2284 | kind: "assistant", | |
| 2285 | text: result.result, | |
| 2286 | }); | |
| 2287 | } | |
| 2288 | if (context !== null) { | |
| 2289 | const transportFailure = context.assistantReply.failure; | |
| 2290 | const status = | |
| 2291 | transportFailure === undefined ? terminalStatus(context, result) : "failed"; | |
| 2292 | yield* finalizeTurn({ | |
| 2293 | context, | |
| 2294 | status, | |
| 2295 | ...(status === "failed" | |
| 2296 | ? { | |
| 2297 | failure: makeProviderFailure({ | |
| 2298 | cause: | |
| 2299 | transportFailure ?? (result as { readonly error?: unknown }).error, | |
| 2300 | class: | |
| 2301 | transportFailure === undefined | |
| 2302 | ? "provider_error" | |
| 2303 | : "transport_error", | |
| 2304 | }), | |
| 2305 | } | |
| 2306 | : {}), | |
| 2307 | }); | |
| 2308 | } | |
| 2309 | }), | |
| 2310 | ), | |
| 2311 | Effect.catch((cause) => | |
| 2312 | Effect.gen(function* () { | |
| 2313 | if (context !== null) { | |
| 2314 | yield* finalizeTurn({ | |
| 2315 | context, | |
| 2316 | status: context.interrupted ? "interrupted" : "failed", | |
| 2317 | ...(context.interrupted | |
| 2318 | ? {} | |
| 2319 | : { | |
| 2320 | failure: makeProviderFailure({ cause, class: "transport_error" }), | |
| 2321 | }), | |
| 2322 | }); | |
| 2323 | } | |
| 2324 | yield* Effect.logWarning("orchestration-v2.cursor-run-failed", { | |
| 2325 | providerSessionId: input.providerSessionId, | |
| 2326 | providerThreadId: turnInput.providerThread.id, | |
| 2327 | providerTurnId, | |
| 2328 | cause, | |
| 2329 | }); | |
| 2330 | }), | |
| 2331 | ), | |
| 2332 | Effect.forkIn(sessionScope), | |
| 2333 | ); | |
| 2334 | }, | |
| 2335 | (effect, turnInput) => | |
| 2336 | effect.pipe( | |
| 2337 | Effect.mapError( | |
| 2338 | (cause) => | |
| 2339 | new ProviderAdapter.ProviderAdapterTurnStartError({ | |
| 2340 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2341 | threadId: turnInput.threadId, | |
| 2342 | providerThreadId: turnInput.providerThread.id, | |
| 2343 | runId: turnInput.runId, | |
| 2344 | cause, | |
| 2345 | }), | |
| 2346 | ), | |
| 2347 | ), | |
| 2348 | ); | |
| 2349 | ||
| 2350 | const closeSession = Effect.fnUntraced(function* () { | |
| 2351 | const existing = yield* Ref.get(liveAgent); | |
| 2352 | if (existing !== null) { | |
| 2353 | yield* existing.session.close.pipe(Effect.ignore); | |
| 2354 | yield* Ref.set(liveAgent, null); | |
| 2355 | } | |
| 2356 | yield* runner.assertComplete.pipe( | |
| 2357 | Effect.catchCause((cause) => | |
| 2358 | Effect.logWarning("orchestration-v2.cursor-runner-incomplete", { | |
| 2359 | providerSessionId: input.providerSessionId, | |
| 2360 | cause, | |
| 2361 | }), | |
| 2362 | ), | |
| 2363 | ); | |
| 2364 | }); | |
| 2365 | yield* Effect.addFinalizer(() => closeSession()); | |
| 2366 | ||
| 2367 | const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { | |
| 2368 | instanceId: adapterOptions.instanceId, | |
| 2369 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2370 | providerSessionId: input.providerSessionId, | |
| 2371 | providerSession: session, | |
| 2372 | events: Stream.fromEffectRepeat(Queue.take(events)), | |
| 2373 | ensureThread: Effect.fn("CursorAdapterV2.ensureThread")( | |
| 2374 | function* (threadInput: ProviderAdapter.ProviderAdapterV2EnsureThreadInput) { | |
| 2375 | const opened = yield* openAgent({ | |
| 2376 | operation: "create", | |
| 2377 | threadId: threadInput.threadId, | |
| 2378 | modelSelection: threadInput.modelSelection, | |
| 2379 | runtimePolicy: threadInput.runtimePolicy, | |
| 2380 | }); | |
| 2381 | const now = yield* DateTime.now; | |
| 2382 | return makeProviderThread({ | |
| 2383 | idAllocator, | |
| 2384 | providerInstanceId: adapterOptions.instanceId, | |
| 2385 | appThreadId: threadInput.threadId, | |
| 2386 | providerSessionId: input.providerSessionId, | |
| 2387 | nativeThreadId: opened.nativeThreadId, | |
| 2388 | now, | |
| 2389 | }); | |
| 2390 | }, | |
| 2391 | (effect, threadInput) => | |
| 2392 | effect.pipe( | |
| 2393 | Effect.mapError( | |
| 2394 | (cause) => | |
| 2395 | new ProviderAdapter.ProviderAdapterEnsureThreadError({ | |
| 2396 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2397 | threadId: threadInput.threadId, | |
| 2398 | cause, | |
| 2399 | }), | |
| 2400 | ), | |
| 2401 | ), | |
| 2402 | ), | |
| 2403 | resumeThread: Effect.fn("CursorAdapterV2.resumeThread")( | |
| 2404 | function* (threadInput: { readonly providerThread: OrchestrationV2ProviderThread }) { | |
| 2405 | const agentId = nativeThreadId(threadInput.providerThread); | |
| 2406 | yield* openAgent({ | |
| 2407 | operation: "resume", | |
| 2408 | agentId, | |
| 2409 | threadId: threadInput.providerThread.appThreadId ?? input.threadId, | |
| 2410 | modelSelection: input.modelSelection, | |
| 2411 | runtimePolicy: input.runtimePolicy, | |
| 2412 | }); | |
| 2413 | const now = yield* DateTime.now; | |
| 2414 | return { | |
| 2415 | ...threadInput.providerThread, | |
| 2416 | providerSessionId: input.providerSessionId, | |
| 2417 | status: "idle" as const, | |
| 2418 | updatedAt: now, | |
| 2419 | }; | |
| 2420 | }, | |
| 2421 | (effect, threadInput) => | |
| 2422 | effect.pipe( | |
| 2423 | Effect.mapError( | |
| 2424 | (cause) => | |
| 2425 | new ProviderAdapter.ProviderAdapterResumeThreadError({ | |
| 2426 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2427 | providerSessionId: input.providerSessionId, | |
| 2428 | providerThreadId: threadInput.providerThread.id, | |
| 2429 | cause, | |
| 2430 | }), | |
| 2431 | ), | |
| 2432 | ), | |
| 2433 | ), | |
| 2434 | startTurn, | |
| 2435 | compactThread: (turnInput) => | |
| 2436 | startTurn({ | |
| 2437 | ...turnInput, | |
| 2438 | message: { ...turnInput.message, text: "/compress" }, | |
| 2439 | }), | |
| 2440 | steerTurn: (turnInput) => | |
| 2441 | Effect.fail( | |
| 2442 | new ProviderAdapter.ProviderAdapterSteerRunUnsupportedError({ | |
| 2443 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2444 | providerThreadId: turnInput.providerThread.id, | |
| 2445 | }), | |
| 2446 | ), | |
| 2447 | interruptTurn: Effect.fn("CursorAdapterV2.interruptTurn")( | |
| 2448 | function* (turnInput: ProviderAdapter.ProviderAdapterV2InterruptInput) { | |
| 2449 | const context = yield* Ref.get(activeTurn); | |
| 2450 | // Stop on a settled turn: finalization already ended its tools | |
| 2451 | // and subagents, so nothing of it is left running to stop. | |
| 2452 | if (context === null && turnInput.requestRuntimeRestart === true) return; | |
| 2453 | if (context?.providerTurnId !== turnInput.providerTurnId) { | |
| 2454 | return yield* new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2455 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2456 | detail: `Cursor provider turn ${turnInput.providerTurnId} is not active.`, | |
| 2457 | }); | |
| 2458 | } | |
| 2459 | context.interrupted = true; | |
| 2460 | yield* context.run.cancel; | |
| 2461 | const stopped = yield* Deferred.await(context.completed).pipe( | |
| 2462 | Effect.timeoutOption("10 seconds"), | |
| 2463 | ); | |
| 2464 | if (Option.isSome(stopped)) { | |
| 2465 | return; | |
| 2466 | } | |
| 2467 | yield* Effect.logWarning("orchestration-v2.cursor-interrupt-timeout", { | |
| 2468 | providerSessionId: input.providerSessionId, | |
| 2469 | providerThreadId: turnInput.providerThread.id, | |
| 2470 | providerTurnId: turnInput.providerTurnId, | |
| 2471 | }); | |
| 2472 | yield* finalizeTurn({ context, status: "interrupted" }); | |
| 2473 | }, | |
| 2474 | (effect, turnInput) => | |
| 2475 | effect.pipe( | |
| 2476 | Effect.mapError( | |
| 2477 | (cause) => | |
| 2478 | new ProviderAdapter.ProviderAdapterInterruptError({ | |
| 2479 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2480 | providerThreadId: turnInput.providerThread.id, | |
| 2481 | providerTurnId: turnInput.providerTurnId, | |
| 2482 | cause, | |
| 2483 | }), | |
| 2484 | ), | |
| 2485 | ), | |
| 2486 | ), | |
| 2487 | respondToRuntimeRequest: (requestInput) => | |
| 2488 | Effect.fail( | |
| 2489 | new ProviderAdapter.ProviderAdapterRuntimeRequestResponseError({ | |
| 2490 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2491 | requestId: requestInput.requestId, | |
| 2492 | cause: new ProviderAdapter.ProviderAdapterProtocolError({ | |
| 2493 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2494 | detail: "Cursor Agent SDK does not expose interactive approval requests.", | |
| 2495 | }), | |
| 2496 | }), | |
| 2497 | ), | |
| 2498 | readThreadSnapshot: Effect.fn("CursorAdapterV2.readThreadSnapshot")( | |
| 2499 | function* (snapshotInput) { | |
| 2500 | const requestedAgentId = nativeThreadId(snapshotInput.providerThread); | |
| 2501 | const agent = yield* openAgent({ | |
| 2502 | operation: "resume", | |
| 2503 | agentId: requestedAgentId, | |
| 2504 | threadId: snapshotInput.providerThread.appThreadId ?? input.threadId, | |
| 2505 | modelSelection: input.modelSelection, | |
| 2506 | runtimePolicy: input.runtimePolicy, | |
| 2507 | }); | |
| 2508 | const messages = yield* agent.session.listMessages; | |
| 2509 | const now = yield* DateTime.now; | |
| 2510 | const threadId = snapshotInput.providerThread.appThreadId ?? input.threadId; | |
| 2511 | const projectedMessages: Array<OrchestrationV2ConversationMessage> = messages.flatMap( | |
| 2512 | (message) => { | |
| 2513 | const text = textFromAgentMessage(message); | |
| 2514 | if (text.length === 0) { | |
| 2515 | return []; | |
| 2516 | } | |
| 2517 | return [ | |
| 2518 | { | |
| 2519 | createdBy: message.type === "user" ? "user" : "agent", | |
| 2520 | creationSource: "provider", | |
| 2521 | id: idAllocator.derive.messageFromProviderItem({ | |
| 2522 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2523 | nativeItemId: message.uuid, | |
| 2524 | }), | |
| 2525 | threadId, | |
| 2526 | runId: null, | |
| 2527 | nodeId: null, | |
| 2528 | role: message.type, | |
| 2529 | text, | |
| 2530 | attachments: [], | |
| 2531 | streaming: false, | |
| 2532 | createdAt: now, | |
| 2533 | updatedAt: now, | |
| 2534 | }, | |
| 2535 | ]; | |
| 2536 | }, | |
| 2537 | ); | |
| 2538 | return { | |
| 2539 | providerThread: { | |
| 2540 | ...snapshotInput.providerThread, | |
| 2541 | providerSessionId: input.providerSessionId, | |
| 2542 | status: "idle" as const, | |
| 2543 | updatedAt: now, | |
| 2544 | }, | |
| 2545 | providerTurns: [], | |
| 2546 | messages: projectedMessages, | |
| 2547 | runtimeRequests: [], | |
| 2548 | providerPayload: { | |
| 2549 | agentId: requestedAgentId, | |
| 2550 | messages, | |
| 2551 | }, | |
| 2552 | }; | |
| 2553 | }, | |
| 2554 | (effect, snapshotInput) => | |
| 2555 | effect.pipe( | |
| 2556 | Effect.mapError( | |
| 2557 | (cause) => | |
| 2558 | new ProviderAdapter.ProviderAdapterReadThreadSnapshotError({ | |
| 2559 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2560 | providerThreadId: snapshotInput.providerThread.id, | |
| 2561 | cause, | |
| 2562 | }), | |
| 2563 | ), | |
| 2564 | ), | |
| 2565 | ), | |
| 2566 | rollbackThread: (rollbackInput) => | |
| 2567 | Effect.fail( | |
| 2568 | new ProviderAdapter.ProviderAdapterRollbackThreadError({ | |
| 2569 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2570 | providerThreadId: rollbackInput.providerThread.id, | |
| 2571 | checkpointId: rollbackInput.target.checkpointId, | |
| 2572 | cause: "Cursor Agent SDK does not expose conversation rollback.", | |
| 2573 | }), | |
| 2574 | ), | |
| 2575 | forkThread: (forkInput) => | |
| 2576 | Effect.fail( | |
| 2577 | new ProviderAdapter.ProviderAdapterForkThreadError({ | |
| 2578 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2579 | providerThreadId: forkInput.sourceProviderThread.id, | |
| 2580 | cause: "Cursor Agent SDK does not expose native agent forks.", | |
| 2581 | }), | |
| 2582 | ), | |
| 2583 | }; | |
| 2584 | return runtime; | |
| 2585 | }, | |
| 2586 | (effect, input) => | |
| 2587 | effect.pipe( | |
| 2588 | Effect.mapError( | |
| 2589 | (cause) => | |
| 2590 | new ProviderAdapter.ProviderAdapterOpenSessionError({ | |
| 2591 | driver: CursorAgentSdk.CURSOR_PROVIDER, | |
| 2592 | providerSessionId: input.providerSessionId, | |
| 2593 | cause, | |
| 2594 | }), | |
| 2595 | ), | |
| 2596 | ), | |
| 2597 | ), | |
| 2598 | }); | |
| 2599 | } | |
| 2600 | ||
| 2601 | export type CursorAdapterV2DriverEnv = | |
| 2602 | | CursorAgentSdk.CursorAgentSdkRunner | |
| 2603 | | FileSystem.FileSystem | |
| 2604 | | Path.Path | |
| 2605 | | IdAllocator.IdAllocatorV2 | |
| 2606 | | ProviderHost.ProviderHost; | |
| 2607 | ||
| 2608 | export const CursorAdapterV2Driver: ProviderAdapterDriver< | |
| 2609 | CursorSettings, | |
| 2610 | CursorAdapterV2DriverEnv | |
| 2611 | > = { | |
| 2612 | driverKind: CURSOR_DRIVER_KIND, | |
| 2613 | configSchema: CursorSettings, | |
| 2614 | defaultConfig: (): CursorSettings => DEFAULT_CURSOR_SETTINGS, | |
| 2615 | create: Effect.fn("CursorAdapterV2Driver.create")( | |
| 2616 | function* (input: ProviderAdapterDriverCreateInput<CursorSettings>) { | |
| 2617 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 2618 | const fileSystem = yield* FileSystem.FileSystem; | |
| 2619 | const path = yield* Path.Path; | |
| 2620 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 2621 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 2622 | const host = yield* ProviderHost.ProviderHost; | |
| 2623 | return makeCursorAdapterV2({ | |
| 2624 | instanceId: input.instanceId, | |
| 2625 | settings: { | |
| 2626 | ...input.config, | |
| 2627 | enabled: input.enabled, | |
| 2628 | }, | |
| 2629 | environment: mergeProviderInstanceEnvironment(input.environment, hostEnvironment), | |
| 2630 | fileSystem, | |
| 2631 | path, | |
| 2632 | idAllocator, | |
| 2633 | runner, | |
| 2634 | host, | |
| 2635 | }); | |
| 2636 | }, | |
| 2637 | (effect, input) => | |
| 2638 | effect.pipe( | |
| 2639 | Effect.mapError( | |
| 2640 | (cause) => | |
| 2641 | new ProviderAdapterDriverCreateError({ | |
| 2642 | driver: CURSOR_DRIVER_KIND, | |
| 2643 | instanceId: input.instanceId, | |
| 2644 | detail: "Failed to create Cursor Agent SDK adapter.", | |
| 2645 | cause, | |
| 2646 | }), | |
| 2647 | ), | |
| 2648 | ), | |
| 2649 | ), | |
| 2650 | }; | |
| 2651 | ||
| 2652 | const layer: Layer.Layer< | |
| 2653 | ProviderAdapter.ProviderAdapterV2, | |
| 2654 | never, | |
| 2655 | | CursorAgentSdk.CursorAgentSdkRunner | |
| 2656 | | FileSystem.FileSystem | |
| 2657 | | Path.Path | |
| 2658 | | IdAllocator.IdAllocatorV2 | |
| 2659 | | ProviderHost.ProviderHost | |
| 2660 | > = Layer.effect( | |
| 2661 | ProviderAdapter.ProviderAdapterV2, | |
| 2662 | Effect.gen(function* () { | |
| 2663 | const hostEnvironment = yield* HostProcessEnvironment; | |
| 2664 | const fileSystem = yield* FileSystem.FileSystem; | |
| 2665 | const path = yield* Path.Path; | |
| 2666 | const idAllocator = yield* IdAllocator.IdAllocatorV2; | |
| 2667 | const runner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 2668 | const host = yield* ProviderHost.ProviderHost; | |
| 2669 | return makeCursorAdapterV2({ | |
| 2670 | instanceId: CURSOR_DEFAULT_INSTANCE_ID, | |
| 2671 | settings: DEFAULT_CURSOR_SETTINGS, | |
| 2672 | environment: hostEnvironment, | |
| 2673 | fileSystem, | |
| 2674 | path, | |
| 2675 | idAllocator, | |
| 2676 | runner, | |
| 2677 | host, | |
| 2678 | }); | |
| 2679 | }), | |
| 2680 | ); |
packages/provider-cursor/src/server/auth.test.ts created+356| ... | ... | @@ -0,0 +1,356 @@ |
| 1 | import type { SdkLoginOptions, StoredSdkCredentials } from "@cursor/sdk"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { expect, it } from "@effect/vitest"; | |
| 4 | import { ProviderInstanceId, ProviderSetupError, type ProviderAuthState } from "@t3tools/contracts"; | |
| 5 | import * as Clock from "effect/Clock"; | |
| 6 | import * as Deferred from "effect/Deferred"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as Exit from "effect/Exit"; | |
| 9 | import * as Fiber from "effect/Fiber"; | |
| 10 | import * as Option from "effect/Option"; | |
| 11 | import * as Scope from "effect/Scope"; | |
| 12 | import * as Stream from "effect/Stream"; | |
| 13 | import * as TestClock from "effect/testing/TestClock"; | |
| 14 | ||
| 15 | import { makeCursorAuth, type CursorAuthOptions } from "./auth.ts"; | |
| 16 | import { InMemoryCredentialStore } from "./sdk.ts"; | |
| 17 | ||
| 18 | const instanceId = ProviderInstanceId.make("cursor-personal"); | |
| 19 | const owner = "owner-client"; | |
| 20 | const otherOwner = "other-client"; | |
| 21 | const authorizationUrl = "https://cursor.com/loginDeepControl?challenge=test-challenge"; | |
| 22 | ||
| 23 | const makeHarness = Effect.fn("makeCursorAuthHarness")(function* ( | |
| 24 | overrides: Partial<CursorAuthOptions> = {}, | |
| 25 | ) { | |
| 26 | const store = new InMemoryCredentialStore(); | |
| 27 | const complete = Promise.withResolvers<void>(); | |
| 28 | const returned = Promise.withResolvers<void>(); | |
| 29 | const now = yield* Clock.currentTimeMillis; | |
| 30 | const credentials: StoredSdkCredentials = { | |
| 31 | version: 1, | |
| 32 | backendUrl: "https://api2.cursor.sh", | |
| 33 | apiKey: "browser-key", | |
| 34 | createdAtMs: now, | |
| 35 | apiKeyExpiresAtMs: now + 86_400_000, | |
| 36 | email: "cursor@example.com", | |
| 37 | }; | |
| 38 | let loginOptions: SdkLoginOptions | undefined; | |
| 39 | const changes: boolean[] = []; | |
| 40 | const auth = yield* makeCursorAuth({ | |
| 41 | instanceId, | |
| 42 | displayName: "Personal Cursor", | |
| 43 | enabled: true, | |
| 44 | store, | |
| 45 | onChanged: (signedIn) => | |
| 46 | Effect.sync(() => { | |
| 47 | changes.push(signedIn); | |
| 48 | }), | |
| 49 | login: async (options) => { | |
| 50 | loginOptions = options; | |
| 51 | options.onLoginUrl?.(authorizationUrl); | |
| 52 | await complete.promise; | |
| 53 | await options.store?.save(credentials); | |
| 54 | returned.resolve(); | |
| 55 | return { | |
| 56 | apiKey: credentials.apiKey, | |
| 57 | apiKeyExpiresAtMs: credentials.apiKeyExpiresAtMs!, | |
| 58 | email: credentials.email!, | |
| 59 | }; | |
| 60 | }, | |
| 61 | ...overrides, | |
| 62 | }); | |
| 63 | return { | |
| 64 | auth, | |
| 65 | store, | |
| 66 | complete, | |
| 67 | returned, | |
| 68 | credentials, | |
| 69 | changes, | |
| 70 | loginOptions: () => loginOptions, | |
| 71 | }; | |
| 72 | }); | |
| 73 | ||
| 74 | type Auth = Effect.Success<ReturnType<typeof makeCursorAuth>>; | |
| 75 | const phase = (auth: Auth, phase: ProviderAuthState["phase"], session = owner) => | |
| 76 | auth.controller.subscribe(session).pipe( | |
| 77 | Stream.filter((state) => state.phase === phase), | |
| 78 | Stream.runHead, | |
| 79 | Effect.map(Option.getOrThrow), | |
| 80 | ); | |
| 81 | ||
| 82 | it.layer(NodeServices.layer)("CursorAuth", (it) => { | |
| 83 | it.effect("closes SDK sessions and interrupts in-flight credential work before signing out", () => | |
| 84 | Effect.gen(function* () { | |
| 85 | const harness = yield* makeHarness(); | |
| 86 | yield* Effect.promise(() => harness.store.save(harness.credentials)); | |
| 87 | const acquired = yield* Deferred.make<void>(); | |
| 88 | let closedSession = false; | |
| 89 | let closedRequest = false; | |
| 90 | yield* harness.auth.withAccess( | |
| 91 | Effect.acquireRelease(Effect.succeed("session"), () => | |
| 92 | Effect.sync(() => { | |
| 93 | closedSession = true; | |
| 94 | }), | |
| 95 | ), | |
| 96 | ); | |
| 97 | const request = yield* harness.auth | |
| 98 | .withAccess( | |
| 99 | Effect.gen(function* () { | |
| 100 | yield* Effect.addFinalizer(() => | |
| 101 | Effect.sync(() => { | |
| 102 | closedRequest = true; | |
| 103 | }), | |
| 104 | ); | |
| 105 | yield* Deferred.succeed(acquired, undefined); | |
| 106 | return yield* Effect.never; | |
| 107 | }), | |
| 108 | ) | |
| 109 | .pipe(Effect.forkScoped); | |
| 110 | yield* Deferred.await(acquired); | |
| 111 | yield* harness.auth.controller.logout(Effect.void); | |
| 112 | expect(closedSession).toBe(true); | |
| 113 | expect(closedRequest).toBe(true); | |
| 114 | expect(Exit.isFailure(yield* Fiber.await(request))).toBe(true); | |
| 115 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 116 | }).pipe(Effect.scoped), | |
| 117 | ); | |
| 118 | ||
| 119 | it.effect("completes a remote browser login, saves its key and refreshes provider status", () => | |
| 120 | Effect.gen(function* () { | |
| 121 | const harness = yield* makeHarness(); | |
| 122 | let stopped = false; | |
| 123 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 124 | const starting = yield* harness.auth.controller.start( | |
| 125 | owner, | |
| 126 | Effect.sync(() => { | |
| 127 | stopped = true; | |
| 128 | }), | |
| 129 | ); | |
| 130 | const waiting = yield* phase(harness.auth, "waiting"); | |
| 131 | expect(stopped).toBe(true); | |
| 132 | expect(waiting.authorizationUrl).toBe(authorizationUrl); | |
| 133 | expect(waiting.flowId).toBe(starting.flowId); | |
| 134 | expect(harness.loginOptions()?.openBrowser).toBe(false); | |
| 135 | expect((yield* harness.auth.controller.start(owner)).flowId).toBe(starting.flowId); | |
| 136 | expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); | |
| 137 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 138 | const succeeded = yield* phase(harness.auth, "succeeded"); | |
| 139 | expect(succeeded.authorizationUrl).toBeNull(); | |
| 140 | expect(yield* harness.auth.requireApiKey).toBe("browser-key"); | |
| 141 | expect(harness.changes).toEqual([true]); | |
| 142 | expect(Object.values(succeeded)).not.toContain("browser-key"); | |
| 143 | expect(yield* Effect.promise(() => harness.store.load())).toEqual(harness.credentials); | |
| 144 | }).pipe(Effect.scoped), | |
| 145 | ); | |
| 146 | ||
| 147 | it.effect("keeps the login link private to its owner and rejects other clients' mutations", () => | |
| 148 | Effect.gen(function* () { | |
| 149 | const { auth } = yield* makeHarness(); | |
| 150 | yield* auth.controller.start(owner); | |
| 151 | const waiting = yield* phase(auth, "waiting"); | |
| 152 | const foreign = yield* phase(auth, "waiting", otherOwner); | |
| 153 | expect(foreign).toMatchObject({ flowId: null, authorizationUrl: null, expiresAt: null }); | |
| 154 | expect(Exit.isFailure(yield* Effect.exit(auth.controller.start(otherOwner)))).toBe(true); | |
| 155 | expect( | |
| 156 | Exit.isFailure(yield* Effect.exit(auth.controller.cancel(otherOwner, waiting.flowId!))), | |
| 157 | ).toBe(true); | |
| 158 | expect( | |
| 159 | Exit.isFailure( | |
| 160 | yield* Effect.exit( | |
| 161 | auth.controller.complete(owner, { | |
| 162 | flowId: waiting.flowId!, | |
| 163 | callbackUrl: "http://localhost/callback", | |
| 164 | }), | |
| 165 | ), | |
| 166 | ), | |
| 167 | ).toBe(true); | |
| 168 | expect((yield* phase(auth, "waiting")).authorizationUrl).toBe(authorizationUrl); | |
| 169 | }).pipe(Effect.scoped), | |
| 170 | ); | |
| 171 | ||
| 172 | it.effect("aborts cancellation and ignores a late successful SDK login", () => | |
| 173 | Effect.gen(function* () { | |
| 174 | const harness = yield* makeHarness(); | |
| 175 | yield* harness.auth.controller.start(owner); | |
| 176 | const waiting = yield* phase(harness.auth, "waiting"); | |
| 177 | const cancelled = yield* harness.auth.controller.cancel(owner, waiting.flowId!); | |
| 178 | expect(cancelled.phase).toBe("cancelled"); | |
| 179 | expect(cancelled.authorizationUrl).toBeNull(); | |
| 180 | expect(harness.loginOptions()?.signal?.aborted).toBe(true); | |
| 181 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 182 | yield* Effect.promise(() => harness.returned.promise); | |
| 183 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 184 | expect(harness.changes).toEqual([]); | |
| 185 | }).pipe(Effect.scoped), | |
| 186 | ); | |
| 187 | ||
| 188 | it.effect("times out pending logins and permits a retry", () => | |
| 189 | Effect.gen(function* () { | |
| 190 | const harness = yield* makeHarness(); | |
| 191 | yield* harness.auth.controller.start(owner); | |
| 192 | const waiting = yield* phase(harness.auth, "waiting"); | |
| 193 | yield* TestClock.adjust(300_000); | |
| 194 | expect((yield* phase(harness.auth, "failed")).authorizationUrl).toBeNull(); | |
| 195 | expect(harness.loginOptions()?.signal?.aborted).toBe(true); | |
| 196 | const retry = yield* harness.auth.controller.start(owner); | |
| 197 | expect(retry.flowId).not.toBe(waiting.flowId); | |
| 198 | }).pipe(Effect.scoped), | |
| 199 | ); | |
| 200 | ||
| 201 | it.effect("finishes saving a completed login atomically across the timeout", () => | |
| 202 | Effect.gen(function* () { | |
| 203 | const store = new InMemoryCredentialStore(); | |
| 204 | const saving = Promise.withResolvers<void>(); | |
| 205 | const finishSave = Promise.withResolvers<void>(); | |
| 206 | const scope = yield* Scope.make(); | |
| 207 | yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); | |
| 208 | const harness = yield* makeHarness({ | |
| 209 | store: { | |
| 210 | load: () => store.load(), | |
| 211 | clear: () => store.clear(), | |
| 212 | save: async (credentials) => { | |
| 213 | saving.resolve(); | |
| 214 | await finishSave.promise; | |
| 215 | await store.save(credentials); | |
| 216 | }, | |
| 217 | }, | |
| 218 | }).pipe(Effect.provideService(Scope.Scope, scope)); | |
| 219 | yield* Effect.addFinalizer(() => Effect.sync(() => finishSave.resolve())); | |
| 220 | yield* harness.auth.controller.start(owner); | |
| 221 | yield* phase(harness.auth, "waiting"); | |
| 222 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 223 | yield* Effect.promise(() => saving.promise); | |
| 224 | yield* TestClock.adjust(300_000); | |
| 225 | yield* Effect.sync(() => finishSave.resolve()); | |
| 226 | yield* phase(harness.auth, "succeeded"); | |
| 227 | expect(yield* harness.auth.requireApiKey).toBe("browser-key"); | |
| 228 | yield* Scope.close(scope, Exit.void); | |
| 229 | const state = yield* harness.auth.controller.subscribe(owner).pipe(Stream.runHead); | |
| 230 | expect(Option.getOrThrow(state).phase).toBe("succeeded"); | |
| 231 | expect(harness.changes).toEqual([true]); | |
| 232 | }).pipe(Effect.scoped), | |
| 233 | ); | |
| 234 | ||
| 235 | it.effect("discards a credential rejected by verification and permits a fresh login", () => | |
| 236 | Effect.gen(function* () { | |
| 237 | let rejected = true; | |
| 238 | const harness = yield* makeHarness({ | |
| 239 | onChanged: (signedIn) => | |
| 240 | signedIn && rejected | |
| 241 | ? Effect.fail( | |
| 242 | new ProviderSetupError({ | |
| 243 | instanceId, | |
| 244 | operation: "start", | |
| 245 | detail: "Could not verify the Cursor sign-in.", | |
| 246 | }), | |
| 247 | ) | |
| 248 | : Effect.void, | |
| 249 | }); | |
| 250 | yield* harness.auth.controller.start(owner); | |
| 251 | yield* phase(harness.auth, "waiting"); | |
| 252 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 253 | yield* phase(harness.auth, "failed"); | |
| 254 | expect(yield* Effect.promise(() => harness.store.load())).toBeUndefined(); | |
| 255 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 256 | expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); | |
| 257 | rejected = false; | |
| 258 | yield* harness.auth.controller.start(owner); | |
| 259 | yield* phase(harness.auth, "succeeded"); | |
| 260 | expect(yield* harness.auth.requireApiKey).toBe("browser-key"); | |
| 261 | }).pipe(Effect.scoped), | |
| 262 | ); | |
| 263 | ||
| 264 | it.effect("clears only this instance's credential after its sessions stop", () => | |
| 265 | Effect.gen(function* () { | |
| 266 | const harness = yield* makeHarness(); | |
| 267 | const other = yield* makeHarness({ instanceId: ProviderInstanceId.make("cursor-work") }); | |
| 268 | yield* Effect.promise(() => harness.store.save(harness.credentials)); | |
| 269 | yield* Effect.promise(() => other.store.save({ ...other.credentials, apiKey: "other-key" })); | |
| 270 | const state = yield* harness.auth.controller.logout( | |
| 271 | Effect.gen(function* () { | |
| 272 | expect(yield* harness.auth.readApiKey).toBe("browser-key"); | |
| 273 | expect(Exit.isFailure(yield* Effect.exit(harness.auth.requireApiKey))).toBe(true); | |
| 274 | }), | |
| 275 | ); | |
| 276 | expect(state.message).toBe("Signed out of Cursor."); | |
| 277 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 278 | expect(yield* other.auth.requireApiKey).toBe("other-key"); | |
| 279 | expect(harness.changes).toEqual([false]); | |
| 280 | }).pipe(Effect.scoped), | |
| 281 | ); | |
| 282 | ||
| 283 | it.effect("cancels a pending sign-in on logout without saving its eventual key", () => | |
| 284 | Effect.gen(function* () { | |
| 285 | const harness = yield* makeHarness(); | |
| 286 | yield* harness.auth.controller.start(owner); | |
| 287 | yield* phase(harness.auth, "waiting"); | |
| 288 | yield* harness.auth.controller.logout(Effect.void); | |
| 289 | expect(harness.loginOptions()?.signal?.aborted).toBe(true); | |
| 290 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 291 | yield* Effect.promise(() => harness.returned.promise); | |
| 292 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 293 | }).pipe(Effect.scoped), | |
| 294 | ); | |
| 295 | ||
| 296 | it.effect("retains credentials if sessions cannot be stopped for logout", () => | |
| 297 | Effect.gen(function* () { | |
| 298 | const harness = yield* makeHarness(); | |
| 299 | yield* Effect.promise(() => harness.store.save(harness.credentials)); | |
| 300 | const result = yield* Effect.exit( | |
| 301 | harness.auth.controller.logout( | |
| 302 | Effect.fail( | |
| 303 | new ProviderSetupError({ | |
| 304 | instanceId, | |
| 305 | operation: "stop", | |
| 306 | detail: "Unable to stop sessions", | |
| 307 | }), | |
| 308 | ), | |
| 309 | ), | |
| 310 | ); | |
| 311 | expect(Exit.isFailure(result)).toBe(true); | |
| 312 | expect(yield* harness.auth.requireApiKey).toBe("browser-key"); | |
| 313 | }).pipe(Effect.scoped), | |
| 314 | ); | |
| 315 | ||
| 316 | it.effect( | |
| 317 | "prefers explicit API keys and prevents browser login from silently overriding them", | |
| 318 | () => | |
| 319 | Effect.gen(function* () { | |
| 320 | const harness = yield* makeHarness({ apiKey: " explicit-key " }); | |
| 321 | yield* Effect.promise(() => harness.store.save(harness.credentials)); | |
| 322 | expect(yield* harness.auth.requireApiKey).toBe("explicit-key"); | |
| 323 | expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true); | |
| 324 | expect( | |
| 325 | Exit.isFailure(yield* Effect.exit(harness.auth.controller.logout(Effect.void))), | |
| 326 | ).toBe(true); | |
| 327 | expect(harness.loginOptions()).toBeUndefined(); | |
| 328 | expect((yield* Effect.promise(() => harness.store.load()))?.apiKey).toBe("browser-key"); | |
| 329 | }).pipe(Effect.scoped), | |
| 330 | ); | |
| 331 | ||
| 332 | it.effect("ignores expired browser credentials and rejects disabled sign-in", () => | |
| 333 | Effect.gen(function* () { | |
| 334 | const harness = yield* makeHarness({ enabled: false }); | |
| 335 | yield* Effect.promise(() => | |
| 336 | harness.store.save({ ...harness.credentials, apiKeyExpiresAtMs: 0 }), | |
| 337 | ); | |
| 338 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 339 | expect(Exit.isFailure(yield* Effect.exit(harness.auth.controller.start(owner)))).toBe(true); | |
| 340 | }).pipe(Effect.scoped), | |
| 341 | ); | |
| 342 | ||
| 343 | it.effect("aborts pending login when the provider instance is disposed", () => | |
| 344 | Effect.gen(function* () { | |
| 345 | const scope = yield* Scope.make(); | |
| 346 | const harness = yield* makeHarness().pipe(Effect.provideService(Scope.Scope, scope)); | |
| 347 | yield* harness.auth.controller.start(owner); | |
| 348 | yield* phase(harness.auth, "waiting"); | |
| 349 | yield* Scope.close(scope, Exit.void); | |
| 350 | expect(harness.loginOptions()?.signal?.aborted).toBe(true); | |
| 351 | yield* Effect.sync(() => harness.complete.resolve()); | |
| 352 | yield* Effect.promise(() => harness.returned.promise); | |
| 353 | expect(yield* harness.auth.readApiKey).toBeUndefined(); | |
| 354 | }), | |
| 355 | ); | |
| 356 | }); |
packages/provider-cursor/src/server/auth.ts created+495| ... | ... | @@ -0,0 +1,495 @@ |
| 1 | import type { SdkCredentialStore, SdkLoginOptions, SdkLoginResult } from "@cursor/sdk"; | |
| 2 | import { | |
| 3 | ProviderSetupError, | |
| 4 | type ProviderAuthState, | |
| 5 | type ProviderInstanceId, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Clock from "effect/Clock"; | |
| 8 | import * as Crypto from "effect/Crypto"; | |
| 9 | import * as DateTime from "effect/DateTime"; | |
| 10 | import * as Effect from "effect/Effect"; | |
| 11 | import * as Exit from "effect/Exit"; | |
| 12 | import * as Fiber from "effect/Fiber"; | |
| 13 | import * as Queue from "effect/Queue"; | |
| 14 | import * as Scope from "effect/Scope"; | |
| 15 | import * as Semaphore from "effect/Semaphore"; | |
| 16 | import * as Stream from "effect/Stream"; | |
| 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 18 | ||
| 19 | import { Cursor, InMemoryCredentialStore } from "./sdk.ts"; | |
| 20 | import type { ProviderAuthController } from "@t3tools/provider-core/server/auth"; | |
| 21 | ||
| 22 | const AUTH_TIMEOUT_MS = 300_000; | |
| 23 | ||
| 24 | interface AuthFlow { | |
| 25 | readonly id: string; | |
| 26 | readonly owner: string; | |
| 27 | fiber?: Fiber.Fiber<void>; | |
| 28 | } | |
| 29 | ||
| 30 | export interface CursorAuth { | |
| 31 | readonly controller: ProviderAuthController; | |
| 32 | readonly readApiKey: Effect.Effect<string | undefined, ProviderSetupError>; | |
| 33 | readonly requireApiKey: Effect.Effect<string, ProviderSetupError>; | |
| 34 | readonly usesApiKey: boolean; | |
| 35 | readonly withAccess: <A, E, R>( | |
| 36 | task: Effect.Effect<A, E, R>, | |
| 37 | ) => Effect.Effect<A, E | ProviderSetupError, R | Scope.Scope>; | |
| 38 | } | |
| 39 | ||
| 40 | export interface CursorAuthOptions { | |
| 41 | readonly instanceId: ProviderInstanceId; | |
| 42 | readonly displayName: string; | |
| 43 | readonly enabled: boolean; | |
| 44 | readonly apiKey?: string; | |
| 45 | readonly store: SdkCredentialStore; | |
| 46 | readonly credentialBinding?: NonNullable<ProviderAuthController["credentialBinding"]>; | |
| 47 | readonly onChanged: (signedIn: boolean) => Effect.Effect<void, ProviderSetupError>; | |
| 48 | readonly login?: (options: SdkLoginOptions) => Promise<SdkLoginResult>; | |
| 49 | } | |
| 50 | ||
| 51 | /** Each instance owns its browser credential; explicit API keys always take precedence. */ | |
| 52 | export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: CursorAuthOptions) { | |
| 53 | const scope = yield* Scope.Scope; | |
| 54 | const crypto = yield* Crypto.Crypto; | |
| 55 | const lock = yield* Semaphore.make(1); | |
| 56 | const configuredKey = options.apiKey?.trim() || undefined; | |
| 57 | const emptyState: ProviderAuthState = { | |
| 58 | instanceId: options.instanceId, | |
| 59 | phase: "idle", | |
| 60 | flowId: null, | |
| 61 | authorizationUrl: null, | |
| 62 | expiresAt: null, | |
| 63 | message: null, | |
| 64 | }; | |
| 65 | const snapshot = yield* SubscriptionRef.make({ owner: null as string | null, state: emptyState }); | |
| 66 | let active: AuthFlow | undefined; | |
| 67 | let operation: "idle" | "login" | "stopping" | "closed" = "idle"; | |
| 68 | const sessions = new Set<Scope.Closeable>(); | |
| 69 | ||
| 70 | const publish = (flow: AuthFlow, patch: Partial<ProviderAuthState>) => | |
| 71 | SubscriptionRef.update(snapshot, (current) => ({ | |
| 72 | owner: flow.owner, | |
| 73 | state: { ...current.state, ...patch }, | |
| 74 | })); | |
| 75 | ||
| 76 | const readApiKey = Effect.gen(function* () { | |
| 77 | if (configuredKey) return configuredKey; | |
| 78 | const credentials = yield* Effect.tryPromise({ | |
| 79 | try: () => options.store.load(), | |
| 80 | catch: (cause) => | |
| 81 | new ProviderSetupError({ | |
| 82 | instanceId: options.instanceId, | |
| 83 | operation: "credentials", | |
| 84 | detail: "Could not read the Cursor sign-in. Try signing in again.", | |
| 85 | cause, | |
| 86 | }), | |
| 87 | }); | |
| 88 | const now = yield* Clock.currentTimeMillis; | |
| 89 | return credentials && | |
| 90 | (credentials.apiKeyExpiresAtMs === undefined || credentials.apiKeyExpiresAtMs > now) | |
| 91 | ? credentials.apiKey | |
| 92 | : undefined; | |
| 93 | }); | |
| 94 | ||
| 95 | const requireApiKey = Effect.gen(function* () { | |
| 96 | if (operation !== "idle") { | |
| 97 | return yield* new ProviderSetupError({ | |
| 98 | instanceId: options.instanceId, | |
| 99 | operation: "credentials", | |
| 100 | detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.", | |
| 101 | }); | |
| 102 | } | |
| 103 | const apiKey = yield* readApiKey; | |
| 104 | if (!apiKey) { | |
| 105 | return yield* new ProviderSetupError({ | |
| 106 | instanceId: options.instanceId, | |
| 107 | operation: "credentials", | |
| 108 | detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | |
| 109 | }); | |
| 110 | } | |
| 111 | return apiKey; | |
| 112 | }); | |
| 113 | ||
| 114 | // Keep SDK sessions and one-shot text generation inside instance-owned scopes. | |
| 115 | // Closing admission before draining these scopes prevents cached credentials surviving sign-out. | |
| 116 | const withAccess: CursorAuth["withAccess"] = (task) => | |
| 117 | Effect.uninterruptibleMask((restore) => | |
| 118 | Effect.gen(function* () { | |
| 119 | const parent = yield* Scope.Scope; | |
| 120 | const child = yield* lock.withPermits(1)( | |
| 121 | Effect.gen(function* () { | |
| 122 | if (operation !== "idle") | |
| 123 | return yield* new ProviderSetupError({ | |
| 124 | instanceId: options.instanceId, | |
| 125 | operation: "session", | |
| 126 | detail: "Cursor sign-in or sign-out is in progress. Try again after it finishes.", | |
| 127 | }); | |
| 128 | const child = yield* Scope.make(); | |
| 129 | sessions.add(child); | |
| 130 | yield* Scope.addFinalizer( | |
| 131 | child, | |
| 132 | Effect.sync(() => { | |
| 133 | sessions.delete(child); | |
| 134 | }), | |
| 135 | ); | |
| 136 | yield* Scope.addFinalizer(parent, Scope.close(child, Exit.void)); | |
| 137 | return child; | |
| 138 | }), | |
| 139 | ); | |
| 140 | const fiber = yield* restore(task).pipe( | |
| 141 | Effect.provideService(Scope.Scope, child), | |
| 142 | Effect.forkIn(child), | |
| 143 | ); | |
| 144 | return yield* restore(Fiber.await(fiber)).pipe( | |
| 145 | Effect.flatMap((result) => result), | |
| 146 | Effect.onExit((result) => | |
| 147 | Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, | |
| 148 | ), | |
| 149 | ); | |
| 150 | }), | |
| 151 | ); | |
| 152 | const stopSessionsWithCredentials = Effect.suspend(() => | |
| 153 | Effect.forEach(Array.from(sessions), (session) => Scope.close(session, Exit.void), { | |
| 154 | discard: true, | |
| 155 | concurrency: "unbounded", | |
| 156 | }), | |
| 157 | ); | |
| 158 | ||
| 159 | const runLogin = (flow: AuthFlow, stopSessions: Effect.Effect<void, ProviderSetupError>) => | |
| 160 | Effect.gen(function* () { | |
| 161 | yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials)); | |
| 162 | // The SDK first writes into memory. Cancellation must not save a late login result. | |
| 163 | const pendingStore = new InMemoryCredentialStore(); | |
| 164 | const urls = yield* Queue.unbounded<string>(); | |
| 165 | yield* Queue.take(urls).pipe( | |
| 166 | Effect.flatMap((authorizationUrl) => | |
| 167 | lock.withPermits(1)( | |
| 168 | Effect.gen(function* () { | |
| 169 | if (active !== flow) return; | |
| 170 | yield* publish(flow, { | |
| 171 | phase: "waiting", | |
| 172 | authorizationUrl, | |
| 173 | message: | |
| 174 | "Open the Cursor sign-in page and finish signing in. This page updates automatically.", | |
| 175 | }); | |
| 176 | }), | |
| 177 | ), | |
| 178 | ), | |
| 179 | Effect.forever, | |
| 180 | Effect.forkScoped, | |
| 181 | ); | |
| 182 | yield* Effect.tryPromise({ | |
| 183 | try: (signal) => | |
| 184 | (options.login ?? Cursor.auth.login)({ | |
| 185 | openBrowser: false, | |
| 186 | store: pendingStore, | |
| 187 | signal, | |
| 188 | apiKeyName: `T3 Code - ${options.displayName}`, | |
| 189 | onLoginUrl: (authorizationUrl) => { | |
| 190 | if (active === flow) Queue.offerUnsafe(urls, authorizationUrl); | |
| 191 | }, | |
| 192 | }), | |
| 193 | catch: (cause) => | |
| 194 | new ProviderSetupError({ | |
| 195 | instanceId: options.instanceId, | |
| 196 | operation: "start", | |
| 197 | detail: "Cursor sign-in failed. Start sign-in again.", | |
| 198 | cause, | |
| 199 | }), | |
| 200 | }); | |
| 201 | // Commit and publish together; interrupting a store Promise cannot stop a late write. | |
| 202 | yield* lock.withPermits(1)( | |
| 203 | Effect.gen(function* () { | |
| 204 | if (active !== flow) return; | |
| 205 | yield* publish(flow, { | |
| 206 | phase: "verifying", | |
| 207 | authorizationUrl: null, | |
| 208 | message: "Checking Cursor sign-in.", | |
| 209 | }); | |
| 210 | yield* Effect.tryPromise({ | |
| 211 | try: async () => { | |
| 212 | const credentials = await pendingStore.load(); | |
| 213 | if (!credentials) throw new Error("Cursor login did not return credentials"); | |
| 214 | await options.store.save(credentials); | |
| 215 | }, | |
| 216 | catch: (cause) => | |
| 217 | new ProviderSetupError({ | |
| 218 | instanceId: options.instanceId, | |
| 219 | operation: "start", | |
| 220 | detail: "Could not save the Cursor sign-in. Try again.", | |
| 221 | cause, | |
| 222 | }), | |
| 223 | }); | |
| 224 | const verified = yield* options.onChanged(true).pipe(Effect.exit); | |
| 225 | if (Exit.isFailure(verified)) { | |
| 226 | yield* Effect.tryPromise({ | |
| 227 | try: () => options.store.clear(), | |
| 228 | catch: (cause) => | |
| 229 | new ProviderSetupError({ | |
| 230 | instanceId: options.instanceId, | |
| 231 | operation: "start", | |
| 232 | detail: "Could not clear the rejected Cursor sign-in. Try signing out.", | |
| 233 | cause, | |
| 234 | }), | |
| 235 | }); | |
| 236 | return yield* Effect.failCause(verified.cause); | |
| 237 | } | |
| 238 | active = undefined; | |
| 239 | operation = "idle"; | |
| 240 | yield* publish(flow, { | |
| 241 | phase: "succeeded", | |
| 242 | authorizationUrl: null, | |
| 243 | expiresAt: null, | |
| 244 | message: "Signed in with Cursor.", | |
| 245 | }); | |
| 246 | }).pipe(Effect.uninterruptible), | |
| 247 | ); | |
| 248 | }).pipe( | |
| 249 | Effect.scoped, | |
| 250 | Effect.timeoutOrElse({ | |
| 251 | duration: AUTH_TIMEOUT_MS, | |
| 252 | orElse: () => | |
| 253 | Effect.fail( | |
| 254 | new ProviderSetupError({ | |
| 255 | instanceId: options.instanceId, | |
| 256 | operation: "start", | |
| 257 | detail: "Cursor sign-in expired. Start sign-in again.", | |
| 258 | }), | |
| 259 | ), | |
| 260 | }), | |
| 261 | Effect.exit, | |
| 262 | Effect.flatMap((result) => | |
| 263 | lock.withPermits(1)( | |
| 264 | Effect.gen(function* () { | |
| 265 | if (active !== flow) return; | |
| 266 | active = undefined; | |
| 267 | operation = "idle"; | |
| 268 | yield* publish(flow, { | |
| 269 | phase: Exit.isSuccess(result) ? "succeeded" : "failed", | |
| 270 | authorizationUrl: null, | |
| 271 | expiresAt: null, | |
| 272 | message: Exit.isSuccess(result) | |
| 273 | ? "Signed in with Cursor." | |
| 274 | : "Cursor sign-in failed or expired. Start sign-in again.", | |
| 275 | }); | |
| 276 | }), | |
| 277 | ), | |
| 278 | ), | |
| 279 | ); | |
| 280 | ||
| 281 | const requireFlow = (owner: string, id: string, name: string) => | |
| 282 | Effect.gen(function* () { | |
| 283 | if (!active || active.owner !== owner || active.id !== id) { | |
| 284 | return yield* new ProviderSetupError({ | |
| 285 | instanceId: options.instanceId, | |
| 286 | operation: name, | |
| 287 | detail: "This sign-in is no longer active in this client.", | |
| 288 | }); | |
| 289 | } | |
| 290 | return active; | |
| 291 | }); | |
| 292 | ||
| 293 | const controller: ProviderAuthController = { | |
| 294 | credentialBinding: options.credentialBinding ?? { | |
| 295 | owner: "t3", | |
| 296 | key: `cursor:${options.instanceId}`, | |
| 297 | }, | |
| 298 | isChangingCredentials: Effect.sync(() => operation !== "idle"), | |
| 299 | start: (owner, stopSessions = Effect.void) => | |
| 300 | lock.withPermits(1)( | |
| 301 | Effect.uninterruptible( | |
| 302 | Effect.gen(function* () { | |
| 303 | if (!options.enabled) | |
| 304 | return yield* new ProviderSetupError({ | |
| 305 | instanceId: options.instanceId, | |
| 306 | operation: "start", | |
| 307 | detail: "Enable Cursor before signing in.", | |
| 308 | }); | |
| 309 | if (configuredKey) | |
| 310 | return yield* new ProviderSetupError({ | |
| 311 | instanceId: options.instanceId, | |
| 312 | operation: "start", | |
| 313 | detail: | |
| 314 | "Remove CURSOR_API_KEY from this provider's environment before using browser sign-in.", | |
| 315 | }); | |
| 316 | if (active?.owner === owner && operation === "login") return snapshot.value.state; | |
| 317 | if (operation !== "idle") | |
| 318 | return yield* new ProviderSetupError({ | |
| 319 | instanceId: options.instanceId, | |
| 320 | operation: "start", | |
| 321 | detail: "Cursor setup is already in progress.", | |
| 322 | }); | |
| 323 | const id = yield* crypto.randomUUIDv4.pipe( | |
| 324 | Effect.mapError( | |
| 325 | (cause) => | |
| 326 | new ProviderSetupError({ | |
| 327 | instanceId: options.instanceId, | |
| 328 | operation: "start", | |
| 329 | detail: "Could not start Cursor sign-in. Try again.", | |
| 330 | cause, | |
| 331 | }), | |
| 332 | ), | |
| 333 | ); | |
| 334 | const expiresAt = DateTime.formatIso( | |
| 335 | DateTime.makeUnsafe((yield* Clock.currentTimeMillis) + AUTH_TIMEOUT_MS), | |
| 336 | ); | |
| 337 | const flow: AuthFlow = { id, owner }; | |
| 338 | active = flow; | |
| 339 | operation = "login"; | |
| 340 | const state: ProviderAuthState = { | |
| 341 | ...emptyState, | |
| 342 | phase: "starting", | |
| 343 | flowId: id, | |
| 344 | expiresAt, | |
| 345 | message: "Starting Cursor sign-in.", | |
| 346 | }; | |
| 347 | yield* SubscriptionRef.set(snapshot, { owner, state }); | |
| 348 | flow.fiber = yield* runLogin(flow, stopSessions).pipe( | |
| 349 | Effect.interruptible, | |
| 350 | Effect.forkIn(scope), | |
| 351 | ); | |
| 352 | return state; | |
| 353 | }), | |
| 354 | ), | |
| 355 | ), | |
| 356 | complete: () => | |
| 357 | Effect.fail( | |
| 358 | new ProviderSetupError({ | |
| 359 | instanceId: options.instanceId, | |
| 360 | operation: "complete", | |
| 361 | detail: "Finish signing in on the Cursor website. No redirect URL is needed.", | |
| 362 | }), | |
| 363 | ), | |
| 364 | cancel: (owner, id) => | |
| 365 | Effect.uninterruptible( | |
| 366 | Effect.gen(function* () { | |
| 367 | const flow = yield* lock.withPermits(1)( | |
| 368 | Effect.gen(function* () { | |
| 369 | const flow = yield* requireFlow(owner, id, "cancel"); | |
| 370 | active = undefined; | |
| 371 | operation = "stopping"; | |
| 372 | yield* publish(flow, { | |
| 373 | phase: "cancelled", | |
| 374 | authorizationUrl: null, | |
| 375 | expiresAt: null, | |
| 376 | message: "Cursor sign-in was cancelled.", | |
| 377 | }); | |
| 378 | return flow; | |
| 379 | }), | |
| 380 | ); | |
| 381 | if (flow.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 382 | operation = "idle"; | |
| 383 | return snapshot.value.state; | |
| 384 | }), | |
| 385 | ), | |
| 386 | logout: (stopSessions) => | |
| 387 | Effect.uninterruptible( | |
| 388 | Effect.gen(function* () { | |
| 389 | const flow = yield* lock.withPermits(1)( | |
| 390 | Effect.gen(function* () { | |
| 391 | if (configuredKey) | |
| 392 | return yield* new ProviderSetupError({ | |
| 393 | instanceId: options.instanceId, | |
| 394 | operation: "logout", | |
| 395 | detail: | |
| 396 | "Remove CURSOR_API_KEY from this provider's environment to disconnect it.", | |
| 397 | }); | |
| 398 | if (operation !== "idle" && operation !== "login") | |
| 399 | return yield* new ProviderSetupError({ | |
| 400 | instanceId: options.instanceId, | |
| 401 | operation: "logout", | |
| 402 | detail: "Cursor setup is already stopping.", | |
| 403 | }); | |
| 404 | operation = "stopping"; | |
| 405 | const flow = active; | |
| 406 | active = undefined; | |
| 407 | return flow; | |
| 408 | }), | |
| 409 | ); | |
| 410 | const result = yield* Effect.gen(function* () { | |
| 411 | if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 412 | yield* stopSessions.pipe(Effect.ensuring(stopSessionsWithCredentials)); | |
| 413 | yield* Effect.tryPromise({ | |
| 414 | try: () => options.store.clear(), | |
| 415 | catch: (cause) => | |
| 416 | new ProviderSetupError({ | |
| 417 | instanceId: options.instanceId, | |
| 418 | operation: "logout", | |
| 419 | detail: "Could not clear the Cursor sign-in. Try again.", | |
| 420 | cause, | |
| 421 | }), | |
| 422 | }); | |
| 423 | yield* options.onChanged(false); | |
| 424 | }).pipe(Effect.exit); | |
| 425 | operation = "idle"; | |
| 426 | const state: ProviderAuthState = { | |
| 427 | ...emptyState, | |
| 428 | phase: Exit.isSuccess(result) ? "idle" : "failed", | |
| 429 | message: Exit.isSuccess(result) | |
| 430 | ? "Signed out of Cursor." | |
| 431 | : "Cursor sign-out failed. Try again.", | |
| 432 | }; | |
| 433 | yield* SubscriptionRef.set(snapshot, { owner: null, state }); | |
| 434 | if (Exit.isFailure(result)) return yield* Effect.failCause(result.cause); | |
| 435 | return state; | |
| 436 | }), | |
| 437 | ), | |
| 438 | subscribe: (owner) => | |
| 439 | SubscriptionRef.changes(snapshot).pipe( | |
| 440 | Stream.map((current) => { | |
| 441 | const state = { | |
| 442 | ...current.state, | |
| 443 | credentialOwner: "t3" as const, | |
| 444 | methods: [ | |
| 445 | { | |
| 446 | id: "browser", | |
| 447 | name: "Sign in with Cursor", | |
| 448 | description: null, | |
| 449 | type: "agent" as const, | |
| 450 | }, | |
| 451 | ], | |
| 452 | interaction: | |
| 453 | current.state.phase === "waiting" && | |
| 454 | current.state.authorizationUrl && | |
| 455 | current.state.flowId | |
| 456 | ? { | |
| 457 | type: "browser" as const, | |
| 458 | id: current.state.flowId, | |
| 459 | url: current.state.authorizationUrl, | |
| 460 | requiresConsent: false, | |
| 461 | } | |
| 462 | : null, | |
| 463 | }; | |
| 464 | if (current.owner === null || current.owner === owner) return state; | |
| 465 | return { | |
| 466 | ...state, | |
| 467 | interaction: null, | |
| 468 | flowId: null, | |
| 469 | authorizationUrl: null, | |
| 470 | expiresAt: null, | |
| 471 | message: active ? "Sign-in is in progress in another client." : current.state.message, | |
| 472 | }; | |
| 473 | }), | |
| 474 | ), | |
| 475 | isLogoutPrompt: (text, hasAttachments) => !hasAttachments && text.trim() === "/logout", | |
| 476 | }; | |
| 477 | ||
| 478 | yield* Effect.addFinalizer(() => | |
| 479 | Effect.gen(function* () { | |
| 480 | operation = "closed"; | |
| 481 | const flow = active; | |
| 482 | active = undefined; | |
| 483 | if (flow?.fiber) yield* Fiber.interrupt(flow.fiber); | |
| 484 | yield* stopSessionsWithCredentials; | |
| 485 | }), | |
| 486 | ); | |
| 487 | ||
| 488 | return { | |
| 489 | controller, | |
| 490 | readApiKey, | |
| 491 | requireApiKey, | |
| 492 | withAccess, | |
| 493 | usesApiKey: configuredKey !== undefined, | |
| 494 | } satisfies CursorAuth; | |
| 495 | }); |
packages/provider-cursor/src/server/credentialStore.test.ts created+106| ... | ... | @@ -0,0 +1,106 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { assert, it } from "@effect/vitest"; | |
| 3 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | ||
| 9 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 10 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import { makeCursorCredentialStore } from "./credentialStore.ts"; | |
| 13 | ||
| 14 | const layerHost = Layer.provideMerge(layerTestProviderHost(), NodeServices.layer); | |
| 15 | ||
| 16 | const legacyCredentials = { | |
| 17 | version: 1 as const, | |
| 18 | backendUrl: "https://api.cursor.com", | |
| 19 | apiKey: "test-only-legacy-key", | |
| 20 | createdAtMs: 100, | |
| 21 | }; | |
| 22 | // The SDK's FileCredentialStore writes pretty-printed JSON. | |
| 23 | const legacyFileText = `{ | |
| 24 | "version": 1, | |
| 25 | "backendUrl": "https://api.cursor.com", | |
| 26 | "apiKey": "test-only-legacy-key", | |
| 27 | "createdAtMs": 100 | |
| 28 | }`; | |
| 29 | ||
| 30 | it.effect.each([ | |
| 31 | { | |
| 32 | name: "imports a legacy sign-in", | |
| 33 | legacy: legacyFileText, | |
| 34 | stored: undefined, | |
| 35 | expected: "test-only-legacy-key", | |
| 36 | }, | |
| 37 | { | |
| 38 | name: "keeps a newer stored sign-in", | |
| 39 | legacy: legacyFileText, | |
| 40 | stored: legacyFileText.replace("test-only-legacy-key", "test-only-current-key"), | |
| 41 | expected: "test-only-current-key", | |
| 42 | }, | |
| 43 | { | |
| 44 | name: "replaces a damaged stored sign-in", | |
| 45 | legacy: legacyFileText, | |
| 46 | stored: "damaged", | |
| 47 | expected: "test-only-legacy-key", | |
| 48 | }, | |
| 49 | { | |
| 50 | name: "discards a damaged legacy file", | |
| 51 | legacy: "damaged", | |
| 52 | stored: undefined, | |
| 53 | expected: undefined, | |
| 54 | }, | |
| 55 | ])("$name and deletes the legacy file", ({ legacy, stored, expected }) => | |
| 56 | Effect.gen(function* () { | |
| 57 | const fileSystem = yield* FileSystem.FileSystem; | |
| 58 | const path = yield* Path.Path; | |
| 59 | const legacyFile = path.join(yield* fileSystem.makeTempDirectoryScoped(), "cursor.json"); | |
| 60 | yield* fileSystem.writeFileString(legacyFile, legacy); | |
| 61 | const host = yield* ProviderHost.ProviderHost; | |
| 62 | const instanceId = ProviderInstanceId.make("personal"); | |
| 63 | if (stored !== undefined) { | |
| 64 | yield* (yield* host.credentials("cursor", instanceId)).set(new TextEncoder().encode(stored)); | |
| 65 | } | |
| 66 | const migrated = yield* makeCursorCredentialStore(instanceId, legacyFile); | |
| 67 | assert.strictEqual((yield* Effect.tryPromise(() => migrated.store.load()))?.apiKey, expected); | |
| 68 | assert.isFalse(yield* fileSystem.exists(legacyFile)); | |
| 69 | }).pipe(Effect.scoped, Effect.provide(layerHost)), | |
| 70 | ); | |
| 71 | ||
| 72 | it.effect( | |
| 73 | "restores SDK credentials in a new controller and keeps another account when signing out", | |
| 74 | () => | |
| 75 | Effect.gen(function* () { | |
| 76 | const host = yield* ProviderHost.ProviderHost; | |
| 77 | const makeStore = (id: string) => makeCursorCredentialStore(ProviderInstanceId.make(id)); | |
| 78 | const personal = yield* makeStore("personal"); | |
| 79 | const work = yield* makeStore("work"); | |
| 80 | const credentials = { | |
| 81 | version: 1 as const, | |
| 82 | backendUrl: "https://api.cursor.com", | |
| 83 | apiKey: "test-only-key", | |
| 84 | createdAtMs: 100, | |
| 85 | apiKeyExpiresAtMs: 1000, | |
| 86 | email: "test@example.com", | |
| 87 | }; | |
| 88 | yield* Effect.tryPromise(() => personal.store.save(credentials)); | |
| 89 | yield* Effect.tryPromise(() => | |
| 90 | work.store.save({ ...credentials, apiKey: "test-only-work-key" }), | |
| 91 | ); | |
| 92 | const restored = yield* makeStore("personal"); | |
| 93 | assert.deepEqual(yield* Effect.tryPromise(() => restored.store.load()), credentials); | |
| 94 | yield* (yield* host.credentials("cursor", "personal")).set( | |
| 95 | new TextEncoder().encode("damaged credential"), | |
| 96 | ); | |
| 97 | assert.isUndefined(yield* Effect.tryPromise(() => restored.store.load())); | |
| 98 | yield* Effect.tryPromise(() => restored.store.save(credentials)); | |
| 99 | yield* Effect.tryPromise(() => restored.store.clear()); | |
| 100 | assert.isUndefined(yield* Effect.tryPromise(() => personal.store.load())); | |
| 101 | assert.strictEqual( | |
| 102 | (yield* Effect.tryPromise(() => work.store.load()))?.apiKey, | |
| 103 | "test-only-work-key", | |
| 104 | ); | |
| 105 | }).pipe(Effect.provide(layerHost)), | |
| 106 | ); |
packages/provider-cursor/src/server/credentialStore.ts created+91| ... | ... | @@ -0,0 +1,91 @@ |
| 1 | import type { SdkCredentialStore } from "@cursor/sdk"; | |
| 2 | import { ProviderSetupError, type ProviderInstanceId } from "@t3tools/contracts"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Schema from "effect/Schema"; | |
| 7 | ||
| 8 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 9 | ||
| 10 | const Credentials = Schema.fromJsonString( | |
| 11 | Schema.Struct({ | |
| 12 | version: Schema.Literal(1), | |
| 13 | backendUrl: Schema.String, | |
| 14 | apiKey: Schema.String, | |
| 15 | createdAtMs: Schema.Finite, | |
| 16 | apiKeyExpiresAtMs: Schema.optionalKey(Schema.Finite), | |
| 17 | email: Schema.optionalKey(Schema.String), | |
| 18 | }), | |
| 19 | ); | |
| 20 | ||
| 21 | const decodeCredentials = Schema.decodeUnknownEffect(Credentials); | |
| 22 | const encodeCredentials = Schema.encodeEffect(Credentials); | |
| 23 | ||
| 24 | /** | |
| 25 | * The SDK owns the credential format; persistence uses the environment's secret store. | |
| 26 | * `legacyFile` is the SDK file store earlier versions used; its sign-in moves into | |
| 27 | * the secret store once and the file is deleted. | |
| 28 | */ | |
| 29 | export const makeCursorCredentialStore = Effect.fn("makeCursorCredentialStore")(function* ( | |
| 30 | instanceId: ProviderInstanceId, | |
| 31 | legacyFile?: string, | |
| 32 | ) { | |
| 33 | const credentials = yield* (yield* ProviderHost.ProviderHost).credentials("cursor", instanceId); | |
| 34 | if (legacyFile !== undefined) { | |
| 35 | const fileSystem = yield* FileSystem.FileSystem; | |
| 36 | yield* Effect.gen(function* () { | |
| 37 | const legacy = yield* fileSystem.readFileString(legacyFile).pipe(Effect.option); | |
| 38 | if (Option.isNone(legacy)) return; | |
| 39 | // A stored sign-in wins unless it is damaged; the SDK would ignore it anyway. | |
| 40 | const stored = yield* credentials.get; | |
| 41 | const storedIsValid = | |
| 42 | Option.isSome(stored) && | |
| 43 | Option.isSome( | |
| 44 | yield* decodeCredentials(new TextDecoder().decode(stored.value)).pipe(Effect.option), | |
| 45 | ); | |
| 46 | if ( | |
| 47 | !storedIsValid && | |
| 48 | Option.isSome(yield* decodeCredentials(legacy.value).pipe(Effect.option)) | |
| 49 | ) { | |
| 50 | yield* credentials.set(new TextEncoder().encode(legacy.value)); | |
| 51 | } | |
| 52 | yield* fileSystem.remove(legacyFile); | |
| 53 | }).pipe( | |
| 54 | Effect.catch((error) => | |
| 55 | Effect.logWarning("Could not migrate legacy Cursor credentials", error), | |
| 56 | ), | |
| 57 | ); | |
| 58 | } | |
| 59 | const store: SdkCredentialStore = { | |
| 60 | load: () => | |
| 61 | Effect.runPromise( | |
| 62 | credentials.get.pipe( | |
| 63 | Effect.flatMap( | |
| 64 | Option.match({ | |
| 65 | onNone: () => Effect.succeed(undefined), | |
| 66 | onSome: (bytes) => | |
| 67 | decodeCredentials(new TextDecoder().decode(bytes)).pipe( | |
| 68 | Effect.orElseSucceed(() => undefined), | |
| 69 | ), | |
| 70 | }), | |
| 71 | ), | |
| 72 | ), | |
| 73 | ), | |
| 74 | save: (value) => | |
| 75 | Effect.runPromise( | |
| 76 | encodeCredentials(value).pipe( | |
| 77 | Effect.mapError( | |
| 78 | () => | |
| 79 | new ProviderSetupError({ | |
| 80 | instanceId, | |
| 81 | operation: "credentials", | |
| 82 | detail: "Cursor returned an unsupported credential format.", | |
| 83 | }), | |
| 84 | ), | |
| 85 | Effect.flatMap((encoded) => credentials.set(new TextEncoder().encode(encoded))), | |
| 86 | ), | |
| 87 | ), | |
| 88 | clear: () => Effect.runPromise(credentials.remove), | |
| 89 | }; | |
| 90 | return { store, binding: credentials.binding }; | |
| 91 | }); |
packages/provider-cursor/src/server/driver.test.ts created+169| ... | ... | @@ -0,0 +1,169 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { expect, it } from "@effect/vitest"; | |
| 4 | import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Stream from "effect/Stream"; | |
| 9 | import { vi } from "vite-plus/test"; | |
| 10 | import { HttpClient } from "effect/http"; | |
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | |
| 12 | ||
| 13 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | |
| 14 | import { layerTestProviderHost } from "@t3tools/provider-testing/host"; | |
| 15 | import { CursorDriver } from "./driver.ts"; | |
| 16 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | |
| 17 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | |
| 18 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | |
| 19 | import { Cursor } from "./sdk.ts"; | |
| 20 | ||
| 21 | const layerTest = layerTestProviderHost({ runBackgroundWork: false }).pipe( | |
| 22 | Layer.provideMerge(NodeServices.layer), | |
| 23 | Layer.provideMerge(IdAllocator.layer), | |
| 24 | Layer.provideMerge( | |
| 25 | Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({ | |
| 26 | open: () => Effect.die("Maintenance resolution must not open a Cursor session"), | |
| 27 | }), | |
| 28 | ), | |
| 29 | Layer.provideMerge( | |
| 30 | Layer.succeed( | |
| 31 | ProviderEventLoggers.ProviderEventLoggers, | |
| 32 | ProviderEventLoggers.NoOpProviderEventLoggers, | |
| 33 | ), | |
| 34 | ), | |
| 35 | Layer.provideMerge( | |
| 36 | Layer.succeed( | |
| 37 | HttpClient.HttpClient, | |
| 38 | HttpClient.make(() => Effect.die("Disabled Cursor must not make an HTTP request")), | |
| 39 | ), | |
| 40 | ), | |
| 41 | ); | |
| 42 | ||
| 43 | it.layer(layerTest)("CursorDriver", (it) => { | |
| 44 | it.effect( | |
| 45 | "persists browser credentials, uses them for chat, and closes the SDK session on logout", | |
| 46 | () => | |
| 47 | Effect.gen(function* () { | |
| 48 | const login = vi.spyOn(Cursor.auth, "login").mockImplementation(async (options) => { | |
| 49 | options?.onLoginUrl?.("https://cursor.com/loginDeepControl?challenge=test-only"); | |
| 50 | await options?.store?.save({ | |
| 51 | version: 1, | |
| 52 | backendUrl: "https://api2.cursor.sh", | |
| 53 | apiKey: "instance-browser-key", | |
| 54 | createdAtMs: 0, | |
| 55 | apiKeyExpiresAtMs: 4_000_000_000_000, | |
| 56 | email: "cursor@example.com", | |
| 57 | }); | |
| 58 | return { apiKey: "instance-browser-key", apiKeyExpiresAtMs: 4_000_000_000_000 }; | |
| 59 | }); | |
| 60 | const me = vi.spyOn(Cursor, "me").mockResolvedValue({ | |
| 61 | apiKeyName: "T3 Code", | |
| 62 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 63 | userEmail: "cursor@example.com", | |
| 64 | }); | |
| 65 | const models = vi | |
| 66 | .spyOn(Cursor.models, "list") | |
| 67 | .mockResolvedValue([{ id: "auto", displayName: "Auto" }]); | |
| 68 | yield* Effect.addFinalizer(() => | |
| 69 | Effect.sync(() => { | |
| 70 | login.mockRestore(); | |
| 71 | me.mockRestore(); | |
| 72 | models.mockRestore(); | |
| 73 | }), | |
| 74 | ); | |
| 75 | const input = { | |
| 76 | instanceId: ProviderInstanceId.make("cursor-browser-persisted"), | |
| 77 | displayName: "Personal Cursor", | |
| 78 | enabled: true, | |
| 79 | environment: [{ name: "CURSOR_API_KEY", value: "", sensitive: true }], | |
| 80 | config: CursorDriver.defaultConfig(), | |
| 81 | }; | |
| 82 | const openedKeys: Array<string | undefined> = []; | |
| 83 | let closed = 0; | |
| 84 | const instance = yield* CursorDriver.create(input).pipe( | |
| 85 | Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { | |
| 86 | assertComplete: Effect.void, | |
| 87 | open: (request) => | |
| 88 | Effect.sync(() => { | |
| 89 | openedKeys.push(request.options.apiKey); | |
| 90 | return { | |
| 91 | agentId: "browser-auth-agent", | |
| 92 | listMessages: Effect.succeed([]), | |
| 93 | send: () => Effect.die("This test only opens a session"), | |
| 94 | close: Effect.sync(() => { | |
| 95 | closed += 1; | |
| 96 | }), | |
| 97 | }; | |
| 98 | }), | |
| 99 | }), | |
| 100 | ); | |
| 101 | expect((yield* instance.snapshot.refresh).auth.status).toBe("unauthenticated"); | |
| 102 | yield* instance.auth!.start("client"); | |
| 103 | yield* instance.auth!.subscribe("client").pipe( | |
| 104 | Stream.filter((state) => state.phase === "succeeded"), | |
| 105 | Stream.runHead, | |
| 106 | Effect.map(Option.getOrThrow), | |
| 107 | ); | |
| 108 | expect(me).toHaveBeenCalledWith({ apiKey: "instance-browser-key" }); | |
| 109 | expect(yield* instance.snapshot.getSnapshot).toMatchObject({ | |
| 110 | status: "ready", | |
| 111 | auth: { | |
| 112 | status: "authenticated", | |
| 113 | type: "browser", | |
| 114 | canLogout: true, | |
| 115 | email: "cursor@example.com", | |
| 116 | }, | |
| 117 | setup: { canAuthenticate: true, canInstall: false }, | |
| 118 | }); | |
| 119 | const threadId = ThreadId.make("cursor-browser-thread"); | |
| 120 | const modelSelection = { instanceId: input.instanceId, model: "auto" }; | |
| 121 | const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ | |
| 122 | runtimeMode: "full-access", | |
| 123 | interactionMode: "default", | |
| 124 | cwd: process.cwd(), | |
| 125 | }); | |
| 126 | const runtime = yield* instance.orchestrationAdapter.openSession({ | |
| 127 | threadId, | |
| 128 | providerSessionId: ProviderSessionId.make("cursor-browser-session"), | |
| 129 | modelSelection, | |
| 130 | runtimePolicy, | |
| 131 | }); | |
| 132 | yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy }); | |
| 133 | expect(openedKeys).toEqual(["instance-browser-key"]); | |
| 134 | expect(closed).toBe(0); | |
| 135 | const recreated = yield* CursorDriver.create(input); | |
| 136 | expect((yield* recreated.snapshot.refresh).auth.status).toBe("authenticated"); | |
| 137 | yield* instance.auth!.logout(Effect.void); | |
| 138 | expect(closed).toBe(1); | |
| 139 | expect((yield* instance.snapshot.getSnapshot).auth.status).toBe("unauthenticated"); | |
| 140 | expect((yield* recreated.snapshot.refresh).auth.status).toBe("unauthenticated"); | |
| 141 | expect( | |
| 142 | (yield* Effect.exit(runtime.ensureThread({ threadId, modelSelection, runtimePolicy }))) | |
| 143 | ._tag, | |
| 144 | ).toBe("Failure"); | |
| 145 | expect(openedKeys).toEqual(["instance-browser-key"]); | |
| 146 | expect(closed).toBe(1); | |
| 147 | }).pipe(Effect.scoped), | |
| 148 | ); | |
| 149 | ||
| 150 | it.effect("keeps the bundled SDK manual-only without probing or updating cursor-agent", () => | |
| 151 | Effect.gen(function* () { | |
| 152 | const instance = yield* CursorDriver.create({ | |
| 153 | instanceId: ProviderInstanceId.make("cursor-sdk"), | |
| 154 | displayName: "Cursor test", | |
| 155 | enabled: false, | |
| 156 | environment: [], | |
| 157 | config: CursorDriver.defaultConfig(), | |
| 158 | }); | |
| 159 | expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); | |
| 160 | expect((yield* instance.snapshot.refresh).status).toBe("disabled"); | |
| 161 | }).pipe( | |
| 162 | Effect.provideService( | |
| 163 | ChildProcessSpawner.ChildProcessSpawner, | |
| 164 | ChildProcessSpawner.make(() => Effect.die("SDK maintenance must not spawn a process")), | |
| 165 | ), | |
| 166 | Effect.scoped, | |
| 167 | ), | |
| 168 | ); | |
| 169 | }); |
packages/provider-cursor/src/server/driver.ts created+279| ... | ... | @@ -0,0 +1,279 @@ |
| 1 | /** | |
| 2 | * CursorDriver — `ProviderDriver` for the Cursor Agent SDK runtime. | |
| 3 | * | |
| 4 | * Provider status, model discovery, orchestration, and text generation use the | |
| 5 | * official Cursor SDK with an instance browser login or CURSOR_API_KEY. | |
| 6 | * | |
| 7 | * @module provider/Drivers/CursorDriver | |
| 8 | */ | |
| 9 | import { ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; | |
| 10 | import { CursorSettings } from "../settings.ts"; | |
| 11 | import * as Effect from "effect/Effect"; | |
| 12 | import * as Crypto from "effect/Crypto"; | |
| 13 | import * as FileSystem from "effect/FileSystem"; | |
| 14 | import * as Path from "effect/Path"; | |
| 15 | import * as Schema from "effect/Schema"; | |
| 16 | import { HttpClient } from "effect/http"; | |
| 17 | import { readCursorUsageLimits } from "./usageLimits.ts"; | |
| 18 | ||
| 19 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | |
| 20 | import { makeCursorTextGeneration } from "./textGeneration.ts"; | |
| 21 | import { CursorAdapterV2Driver, type CursorAdapterV2DriverEnv } from "./adapter.ts"; | |
| 22 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; | |
| 23 | import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus } from "./status.ts"; | |
| 24 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | |
| 25 | import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; | |
| 26 | import { | |
| 27 | defaultProviderContinuationIdentity, | |
| 28 | type ProviderDriver, | |
| 29 | type ProviderInstance, | |
| 30 | } from "@t3tools/provider-core/server/driver"; | |
| 31 | import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; | |
| 32 | import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; | |
| 33 | import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; | |
| 34 | import { | |
| 35 | haveProviderSnapshotSettingsChanged, | |
| 36 | makeProviderSnapshotSettingsSource, | |
| 37 | type ProviderSnapshotSettings, | |
| 38 | } from "@t3tools/provider-core/server/snapshotSettings"; | |
| 39 | import { probeCursorSkills } from "./skills.ts"; | |
| 40 | import { makeCursorAuth } from "./auth.ts"; | |
| 41 | import * as CursorCredentialStore from "./credentialStore.ts"; | |
| 42 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | |
| 43 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); | |
| 44 | const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError); | |
| 45 | ||
| 46 | const DRIVER_KIND = ProviderDriverKind.make("cursor"); | |
| 47 | const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({ | |
| 48 | provider: DRIVER_KIND, | |
| 49 | packageName: null, | |
| 50 | }); | |
| 51 | ||
| 52 | export type CursorDriverEnv = | |
| 53 | | CursorAdapterV2DriverEnv | |
| 54 | | Crypto.Crypto | |
| 55 | | FileSystem.FileSystem | |
| 56 | | Path.Path | |
| 57 | | HttpClient.HttpClient | |
| 58 | | ProviderHost.ProviderHost; | |
| 59 | ||
| 60 | export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | |
| 61 | driverKind: DRIVER_KIND, | |
| 62 | metadata: { | |
| 63 | displayName: "Cursor", | |
| 64 | supportsMultipleInstances: true, | |
| 65 | }, | |
| 66 | configSchema: CursorSettings, | |
| 67 | defaultConfig: (): CursorSettings => decodeCursorSettings({}), | |
| 68 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | |
| 69 | Effect.gen(function* () { | |
| 70 | const host = yield* ProviderHost.ProviderHost; | |
| 71 | const fileSystem = yield* FileSystem.FileSystem; | |
| 72 | const path = yield* Path.Path; | |
| 73 | const httpClient = yield* HttpClient.HttpClient; | |
| 74 | const sdkRunner = yield* CursorAgentSdk.CursorAgentSdkRunner; | |
| 75 | const processEnv = mergeProviderInstanceEnvironment(environment); | |
| 76 | const continuationIdentity = defaultProviderContinuationIdentity({ | |
| 77 | driverKind: DRIVER_KIND, | |
| 78 | instanceId, | |
| 79 | }); | |
| 80 | const stampIdentity = withInstanceIdentity({ | |
| 81 | instanceId, | |
| 82 | driverKind: DRIVER_KIND, | |
| 83 | displayName: displayName ?? "Cursor", | |
| 84 | accentColor, | |
| 85 | continuationGroupKey: continuationIdentity.continuationKey, | |
| 86 | }); | |
| 87 | const effectiveConfig = { ...config, enabled } satisfies CursorSettings; | |
| 88 | const credentials = yield* CursorCredentialStore.makeCursorCredentialStore( | |
| 89 | instanceId, | |
| 90 | path.join( | |
| 91 | host.paths.stateDir, | |
| 92 | "provider-auth", | |
| 93 | encodeURIComponent(instanceId), | |
| 94 | "cursor.json", | |
| 95 | ), | |
| 96 | ).pipe( | |
| 97 | Effect.mapError( | |
| 98 | (cause) => | |
| 99 | new ProviderDriverError({ | |
| 100 | driver: DRIVER_KIND, | |
| 101 | instanceId, | |
| 102 | detail: "Could not open the Cursor credential store.", | |
| 103 | cause, | |
| 104 | }), | |
| 105 | ), | |
| 106 | ); | |
| 107 | const auth = yield* makeCursorAuth({ | |
| 108 | instanceId, | |
| 109 | displayName: displayName ?? "Cursor", | |
| 110 | enabled, | |
| 111 | ...(processEnv.CURSOR_API_KEY ? { apiKey: processEnv.CURSOR_API_KEY } : {}), | |
| 112 | store: credentials.store, | |
| 113 | credentialBinding: credentials.binding, | |
| 114 | onChanged: (signedIn): Effect.Effect<void, ProviderSetupError> => | |
| 115 | snapshot.refresh.pipe( | |
| 116 | Effect.flatMap((provider) => | |
| 117 | !signedIn || provider.auth.status === "authenticated" | |
| 118 | ? Effect.void | |
| 119 | : Effect.fail( | |
| 120 | new ProviderSetupError({ | |
| 121 | instanceId, | |
| 122 | operation: "start", | |
| 123 | detail: provider.message ?? "Could not verify the Cursor sign-in. Try again.", | |
| 124 | }), | |
| 125 | ), | |
| 126 | ), | |
| 127 | ), | |
| 128 | }); | |
| 129 | const stampSnapshot: typeof stampIdentity = (draft) => | |
| 130 | stampIdentity({ | |
| 131 | ...draft, | |
| 132 | setup: { canAuthenticate: !auth.usesApiKey, canInstall: false }, | |
| 133 | auth: { | |
| 134 | ...draft.auth, | |
| 135 | canLogout: !auth.usesApiKey, | |
| 136 | }, | |
| 137 | }); | |
| 138 | ||
| 139 | const orchestrationAdapter = yield* CursorAdapterV2Driver.create({ | |
| 140 | instanceId, | |
| 141 | displayName, | |
| 142 | accentColor, | |
| 143 | environment, | |
| 144 | enabled, | |
| 145 | config, | |
| 146 | }).pipe( | |
| 147 | Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { | |
| 148 | ...sdkRunner, | |
| 149 | open: (input) => | |
| 150 | auth.requireApiKey.pipe( | |
| 151 | Effect.flatMap((apiKey) => | |
| 152 | Effect.acquireRelease( | |
| 153 | sdkRunner | |
| 154 | .open({ ...input, options: { ...input.options, apiKey } }) | |
| 155 | .pipe( | |
| 156 | Effect.flatMap((session) => | |
| 157 | Effect.cached(session.close).pipe( | |
| 158 | Effect.map((close) => ({ ...session, close })), | |
| 159 | ), | |
| 160 | ), | |
| 161 | ), | |
| 162 | (session) => session.close.pipe(Effect.ignore), | |
| 163 | ), | |
| 164 | ), | |
| 165 | auth.withAccess, | |
| 166 | Effect.mapError((cause) => | |
| 167 | isSdkRunnerError(cause) | |
| 168 | ? cause | |
| 169 | : new CursorAgentSdk.CursorAgentSdkRunnerError({ method: "open", cause }), | |
| 170 | ), | |
| 171 | ), | |
| 172 | }), | |
| 173 | Effect.mapError( | |
| 174 | (cause) => | |
| 175 | new ProviderDriverError({ | |
| 176 | driver: DRIVER_KIND, | |
| 177 | instanceId, | |
| 178 | detail: "Failed to build Cursor orchestration adapter.", | |
| 179 | cause, | |
| 180 | }), | |
| 181 | ), | |
| 182 | ); | |
| 183 | const textGeneration = yield* makeCursorTextGeneration( | |
| 184 | effectiveConfig, | |
| 185 | processEnv, | |
| 186 | auth.requireApiKey, | |
| 187 | auth.withAccess, | |
| 188 | ); | |
| 189 | ||
| 190 | const checkProvider = auth.readApiKey.pipe( | |
| 191 | Effect.orElseSucceed(() => undefined), | |
| 192 | Effect.flatMap((apiKey) => | |
| 193 | checkCursorProviderStatus( | |
| 194 | effectiveConfig, | |
| 195 | { | |
| 196 | ...processEnv, | |
| 197 | CURSOR_API_KEY: apiKey, | |
| 198 | }, | |
| 199 | auth.usesApiKey ? "api-key" : "browser", | |
| 200 | ).pipe( | |
| 201 | Effect.flatMap((snapshot) => | |
| 202 | effectiveConfig.enabled && | |
| 203 | snapshot.installed && | |
| 204 | snapshot.auth.status === "authenticated" | |
| 205 | ? host.settings.get.pipe( | |
| 206 | Effect.flatMap((settings) => | |
| 207 | readCursorUsageLimits( | |
| 208 | effectiveConfig, | |
| 209 | { ...processEnv, CURSOR_API_KEY: apiKey }, | |
| 210 | settings.cursorKeychainUsageEnabled, | |
| 211 | ), | |
| 212 | ), | |
| 213 | Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), | |
| 214 | ) | |
| 215 | : Effect.succeed(snapshot), | |
| 216 | ), | |
| 217 | ), | |
| 218 | ), | |
| 219 | Effect.provideService(HttpClient.HttpClient, httpClient), | |
| 220 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 221 | Effect.provideService(Path.Path, path), | |
| 222 | Effect.map(stampSnapshot), | |
| 223 | Effect.provide(CursorSdkCatalog.layer), | |
| 224 | ); | |
| 225 | ||
| 226 | const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, host.settings); | |
| 227 | const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<CursorSettings>>({ | |
| 228 | resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES), | |
| 229 | getSettings: snapshotSettings.getSettings, | |
| 230 | streamSettings: snapshotSettings.streamSettings, | |
| 231 | haveSettingsChanged: haveProviderSnapshotSettingsChanged, | |
| 232 | initialSnapshot: (settings) => | |
| 233 | buildInitialCursorProviderSnapshot(settings.provider).pipe(Effect.map(stampSnapshot)), | |
| 234 | checkProvider, | |
| 235 | }).pipe( | |
| 236 | Effect.mapError( | |
| 237 | (cause) => | |
| 238 | new ProviderDriverError({ | |
| 239 | driver: DRIVER_KIND, | |
| 240 | instanceId, | |
| 241 | detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`, | |
| 242 | cause, | |
| 243 | }), | |
| 244 | ), | |
| 245 | ); | |
| 246 | ||
| 247 | return { | |
| 248 | instanceId, | |
| 249 | driverKind: DRIVER_KIND, | |
| 250 | continuationIdentity, | |
| 251 | displayName, | |
| 252 | accentColor, | |
| 253 | enabled, | |
| 254 | auth: auth.controller, | |
| 255 | snapshot, | |
| 256 | snapshotForCwd: (cwd) => | |
| 257 | !effectiveConfig.enabled | |
| 258 | ? snapshot.getSnapshot | |
| 259 | : Effect.all([ | |
| 260 | snapshot.getSnapshot, | |
| 261 | probeCursorSkills(cwd, processEnv).pipe( | |
| 262 | Effect.provideService(FileSystem.FileSystem, fileSystem), | |
| 263 | Effect.provideService(Path.Path, path), | |
| 264 | Effect.mapError( | |
| 265 | (cause) => | |
| 266 | new ProviderDriverError({ | |
| 267 | driver: DRIVER_KIND, | |
| 268 | instanceId, | |
| 269 | detail: `Failed to discover Cursor skills for '${cwd}'`, | |
| 270 | cause, | |
| 271 | }), | |
| 272 | ), | |
| 273 | ), | |
| 274 | ]).pipe(Effect.map(([machineSnapshot, skills]) => ({ ...machineSnapshot, skills }))), | |
| 275 | orchestrationAdapter, | |
| 276 | textGeneration, | |
| 277 | } satisfies ProviderInstance; | |
| 278 | }), | |
| 279 | }; |
packages/provider-cursor/src/server/keychainToken.test.ts created+43| ... | ... | @@ -0,0 +1,43 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | ||
| 3 | import { CursorKeychainTimeoutError, makeCachedCursorAccessTokenReader } from "./keychainToken.ts"; | |
| 4 | ||
| 5 | describe("Cursor Keychain reader", () => { | |
| 6 | it("shares concurrent reads and rechecks after the cache expires", async () => { | |
| 7 | let reads = 0; | |
| 8 | let time = 0; | |
| 9 | const read = makeCachedCursorAccessTokenReader( | |
| 10 | async () => { | |
| 11 | reads++; | |
| 12 | return `token-${reads}`; | |
| 13 | }, | |
| 14 | () => time, | |
| 15 | ); | |
| 16 | assert.deepStrictEqual(await Promise.all([read(), read()]), ["token-1", "token-1"]); | |
| 17 | assert.strictEqual(await read(), "token-1"); | |
| 18 | assert.strictEqual(reads, 1); | |
| 19 | time = 5 * 60_000; | |
| 20 | assert.strictEqual(await read(), "token-2"); | |
| 21 | }); | |
| 22 | ||
| 23 | it("gives up on an unanswered prompt and reuses it on the next read", async () => { | |
| 24 | let reads = 0; | |
| 25 | let allow: (token: string) => void = () => {}; | |
| 26 | const read = makeCachedCursorAccessTokenReader( | |
| 27 | () => { | |
| 28 | reads++; | |
| 29 | return new Promise((resolve) => { | |
| 30 | allow = resolve; | |
| 31 | }); | |
| 32 | }, | |
| 33 | () => 0, | |
| 34 | 1, | |
| 35 | ); | |
| 36 | const error = await read().catch((cause: unknown) => cause); | |
| 37 | assert.instanceOf(error, CursorKeychainTimeoutError); | |
| 38 | const retry = read(); | |
| 39 | allow("token"); | |
| 40 | assert.strictEqual(await retry, "token"); | |
| 41 | assert.strictEqual(reads, 1); | |
| 42 | }); | |
| 43 | }); |
packages/provider-cursor/src/server/keychainToken.ts created+55| ... | ... | @@ -0,0 +1,55 @@ |
| 1 | import * as NodeModule from "node:module"; | |
| 2 | ||
| 3 | const CACHE_MS = 5 * 60_000; | |
| 4 | ||
| 5 | const requireForKeyring = NodeModule.createRequire(import.meta.url); | |
| 6 | ||
| 7 | /** Rejected when nobody answers the macOS Keychain prompt in time. */ | |
| 8 | export class CursorKeychainTimeoutError extends Error { | |
| 9 | constructor() { | |
| 10 | super("Timed out waiting for Keychain access."); | |
| 11 | } | |
| 12 | } | |
| 13 | ||
| 14 | /** | |
| 15 | * Share one Keychain request across usage history and limits in this server process. | |
| 16 | * | |
| 17 | * macOS shows the access prompt on the server's own screen, which a remote | |
| 18 | * client cannot answer, so callers give up after `timeoutMs`. The read stays in | |
| 19 | * flight: the next call reuses it instead of stacking a second prompt, and picks | |
| 20 | * up the token once someone allows access. | |
| 21 | */ | |
| 22 | export function makeCachedCursorAccessTokenReader( | |
| 23 | read: () => Promise<string | null>, | |
| 24 | now: () => number = Date.now, | |
| 25 | timeoutMs = 30_000, | |
| 26 | ): () => Promise<string | null> { | |
| 27 | let cached: { token: string; until: number } | null = null; | |
| 28 | let pending: Promise<string | null> | null = null; | |
| 29 | return () => { | |
| 30 | if (cached && cached.until > now()) return Promise.resolve(cached.token); | |
| 31 | pending ??= read() | |
| 32 | .then((token) => { | |
| 33 | cached = token ? { token, until: now() + CACHE_MS } : null; | |
| 34 | return token; | |
| 35 | }) | |
| 36 | .finally(() => { | |
| 37 | pending = null; | |
| 38 | }); | |
| 39 | const deadline = AbortSignal.timeout(timeoutMs); | |
| 40 | return Promise.race([ | |
| 41 | pending, | |
| 42 | new Promise<never>((_, reject) => { | |
| 43 | deadline.addEventListener("abort", () => reject(new CursorKeychainTimeoutError()), { | |
| 44 | once: true, | |
| 45 | }); | |
| 46 | }), | |
| 47 | ]); | |
| 48 | }; | |
| 49 | } | |
| 50 | ||
| 51 | /** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ | |
| 52 | export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { | |
| 53 | const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); | |
| 54 | return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; | |
| 55 | }); |
packages/provider-cursor/src/server/sdk.test.ts created+193| ... | ... | @@ -0,0 +1,193 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off globalTimers:off -- The probe is a plain Node child; this deadline kills it if it hangs. | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | ||
| 5 | const cursorSdkUrl = new URL("./sdk.ts", import.meta.url).href; | |
| 6 | ||
| 7 | // Loads sdk.ts in a fresh process. @cursor/sdk is stubbed so the guard | |
| 8 | // can be tested without the real package. The vitest worker already has its | |
| 9 | // own unhandledRejection listener, which would hide the process-exit behavior. | |
| 10 | const probeProgram = ` | |
| 11 | import { mkdtempSync, writeFileSync } from "node:fs"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { registerHooks } from "node:module"; | |
| 15 | import { pathToFileURL } from "node:url"; | |
| 16 | import { spawn } from "node:child_process"; | |
| 17 | ||
| 18 | const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-")); | |
| 19 | const stub = join(dir, "stub.cjs"); | |
| 20 | writeFileSync( | |
| 21 | stub, | |
| 22 | "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };", | |
| 23 | ); | |
| 24 | registerHooks({ | |
| 25 | resolve(specifier, context, next) { | |
| 26 | if (specifier === "@cursor/sdk") { | |
| 27 | return { url: pathToFileURL(stub).href, shortCircuit: true }; | |
| 28 | } | |
| 29 | return next(specifier, context); | |
| 30 | }, | |
| 31 | }); | |
| 32 | ||
| 33 | const { isCursorShellSpawnFailure } = await import(${JSON.stringify(cursorSdkUrl)}); | |
| 34 | const mode = process.argv[1]; | |
| 35 | const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid); | |
| 36 | ||
| 37 | if (mode === "predicate") { | |
| 38 | const cursorShell = Object.assign(new Error("spawn /bin/zsh ENOENT"), { | |
| 39 | code: "ENOENT", | |
| 40 | syscall: "spawn /bin/zsh", | |
| 41 | path: "/bin/zsh", | |
| 42 | spawnargs: ["-c", "dump_zsh_state >&4", "--", "true"], | |
| 43 | }); | |
| 44 | const bashShell = Object.assign(new Error("spawn bash ENOENT"), { | |
| 45 | code: "ENOENT", | |
| 46 | syscall: "spawn bash", | |
| 47 | spawnargs: ["-c", "dump_bash_state >&4"], | |
| 48 | }); | |
| 49 | const sandboxRestore = Object.assign(new Error("spawn /bin/zsh ENOENT"), { | |
| 50 | code: "ENOENT", | |
| 51 | syscall: "spawn /bin/zsh", | |
| 52 | spawnargs: ["-c", "builtin eval \\"\${__CURSOR_SANDBOX_ENV_RESTORE:-}\\""], | |
| 53 | }); | |
| 54 | const gitSpawn = Object.assign(new Error("spawn git ENOENT"), { | |
| 55 | code: "ENOENT", | |
| 56 | syscall: "spawn git", | |
| 57 | path: "git", | |
| 58 | spawnargs: ["status"], | |
| 59 | }); | |
| 60 | const plainZsh = Object.assign(new Error("spawn /bin/zsh ENOENT"), { | |
| 61 | code: "ENOENT", | |
| 62 | syscall: "spawn /bin/zsh", | |
| 63 | path: "/bin/zsh", | |
| 64 | spawnargs: ["-lc", "true"], | |
| 65 | }); | |
| 66 | console.log( | |
| 67 | JSON.stringify({ | |
| 68 | cursorShell: isCursorShellSpawnFailure(cursorShell), | |
| 69 | bashShell: isCursorShellSpawnFailure(bashShell), | |
| 70 | sandboxRestore: isCursorShellSpawnFailure(sandboxRestore), | |
| 71 | gitSpawn: isCursorShellSpawnFailure(gitSpawn), | |
| 72 | plainZsh: isCursorShellSpawnFailure(plainZsh), | |
| 73 | open: isCursorShellSpawnFailure( | |
| 74 | Object.assign(new Error("open failed"), { code: "ENOENT", syscall: "open" }), | |
| 75 | ), | |
| 76 | plain: isCursorShellSpawnFailure(new Error("boom")), | |
| 77 | string: isCursorShellSpawnFailure("spawn ENOENT"), | |
| 78 | }), | |
| 79 | ); | |
| 80 | process.exit(0); | |
| 81 | } | |
| 82 | ||
| 83 | if (mode === "cursor-shell") { | |
| 84 | const child = spawn("/bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"], { | |
| 85 | cwd: missingCwd, | |
| 86 | }); | |
| 87 | child.on("error", (error) => { | |
| 88 | Promise.reject(error); | |
| 89 | }); | |
| 90 | setTimeout(() => process.exit(0), 500); | |
| 91 | } else if (mode === "other-spawn") { | |
| 92 | Promise.reject( | |
| 93 | Object.assign(new Error("spawn git ENOENT"), { | |
| 94 | code: "ENOENT", | |
| 95 | syscall: "spawn git", | |
| 96 | path: "git", | |
| 97 | spawnargs: ["status"], | |
| 98 | }), | |
| 99 | ); | |
| 100 | setTimeout(() => process.exit(0), 500); | |
| 101 | } else if (mode === "other-rejection") { | |
| 102 | Promise.reject(new Error("boom")); | |
| 103 | setTimeout(() => process.exit(0), 500); | |
| 104 | } else if (mode === "spawn-without-listener") { | |
| 105 | spawn(process.execPath, ["-e", "process.exit(0)"], { cwd: missingCwd }); | |
| 106 | setTimeout(() => process.exit(0), 500); | |
| 107 | } else { | |
| 108 | console.error("Unknown cursor shell spawn guard probe: " + mode); | |
| 109 | process.exit(2); | |
| 110 | } | |
| 111 | `; | |
| 112 | ||
| 113 | function runProbe(mode: string): Promise<{ code: number | null; stdout: string; stderr: string }> { | |
| 114 | return new Promise((resolve, reject) => { | |
| 115 | const child = NodeChildProcess.spawn( | |
| 116 | process.execPath, | |
| 117 | [ | |
| 118 | "--experimental-strip-types", | |
| 119 | "--disable-warning=ExperimentalWarning", | |
| 120 | "--input-type=module", | |
| 121 | "-e", | |
| 122 | probeProgram, | |
| 123 | mode, | |
| 124 | ], | |
| 125 | { stdio: ["ignore", "pipe", "pipe"] }, | |
| 126 | ); | |
| 127 | let stdout = ""; | |
| 128 | let stderr = ""; | |
| 129 | child.stdout.setEncoding("utf8"); | |
| 130 | child.stderr.setEncoding("utf8"); | |
| 131 | child.stdout.on("data", (chunk: string) => { | |
| 132 | stdout += chunk; | |
| 133 | }); | |
| 134 | child.stderr.on("data", (chunk: string) => { | |
| 135 | stderr += chunk; | |
| 136 | }); | |
| 137 | const timer = setTimeout(() => { | |
| 138 | child.kill(); | |
| 139 | reject(new Error(`probe ${mode} timed out`)); | |
| 140 | }, 5_000); | |
| 141 | child.on("error", reject); | |
| 142 | child.on("exit", (code) => { | |
| 143 | clearTimeout(timer); | |
| 144 | resolve({ code, stdout, stderr }); | |
| 145 | }); | |
| 146 | }); | |
| 147 | } | |
| 148 | ||
| 149 | describe("isCursorShellSpawnFailure", () => { | |
| 150 | it("matches only Cursor's shell wrapper", async () => { | |
| 151 | const result = await runProbe("predicate"); | |
| 152 | expect(result.code).toBe(0); | |
| 153 | expect(JSON.parse(result.stdout)).toEqual({ | |
| 154 | cursorShell: true, | |
| 155 | bashShell: true, | |
| 156 | sandboxRestore: true, | |
| 157 | gitSpawn: false, | |
| 158 | plainZsh: false, | |
| 159 | open: false, | |
| 160 | plain: false, | |
| 161 | string: false, | |
| 162 | }); | |
| 163 | }); | |
| 164 | }); | |
| 165 | ||
| 166 | describe("Cursor shell spawn guard", () => { | |
| 167 | it("keeps the process alive when Cursor's shell spawn rejects", async () => { | |
| 168 | const result = await runProbe("cursor-shell"); | |
| 169 | expect(result.code).toBe(0); | |
| 170 | expect(result.stderr).toContain("The server will keep running."); | |
| 171 | expect(result.stderr).toContain("ENOENT"); | |
| 172 | }); | |
| 173 | ||
| 174 | it("still exits when a different spawn failure is unhandled", async () => { | |
| 175 | const result = await runProbe("other-spawn"); | |
| 176 | expect(result.code).toBe(1); | |
| 177 | expect(result.stderr).toContain("spawn git ENOENT"); | |
| 178 | expect(result.stderr).not.toContain("The server will keep running."); | |
| 179 | }); | |
| 180 | ||
| 181 | it("still exits on unrelated unhandled rejections", async () => { | |
| 182 | const result = await runProbe("other-rejection"); | |
| 183 | expect(result.code).toBe(1); | |
| 184 | expect(result.stderr).toContain("boom"); | |
| 185 | }); | |
| 186 | ||
| 187 | it("leaves a spawn with no error listener fatal", async () => { | |
| 188 | const result = await runProbe("spawn-without-listener"); | |
| 189 | expect(result.code).toBe(1); | |
| 190 | expect(result.stderr).toContain("Unhandled"); | |
| 191 | expect(result.stderr).not.toContain("The server will keep running."); | |
| 192 | }); | |
| 193 | }); |
packages/provider-cursor/src/server/sdk.ts created+77| ... | ... | @@ -0,0 +1,77 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- Installed before the SDK loads, outside an Effect runtime. stderr must match Node's default unhandled-rejection print. | |
| 2 | import * as NodeModule from "node:module"; | |
| 3 | ||
| 4 | /** | |
| 5 | * The Cursor Agent SDK runs in this process and spawns a shell for tool | |
| 6 | * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`, | |
| 7 | * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory | |
| 8 | * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error, | |
| 9 | * then rejects an internal promise nothing awaits. Node exits on that | |
| 10 | * unhandled rejection and takes the server with it. | |
| 11 | * | |
| 12 | * This listener ignores only that rejection. Every other unhandled rejection | |
| 13 | * still prints and exits when this is the process's only listener, matching | |
| 14 | * Node's default. `child_process.spawn` itself is left alone, so git, | |
| 15 | * terminals, and other providers keep their own error handling. | |
| 16 | */ | |
| 17 | const CURSOR_SHELL_SPAWN_MARKERS = [ | |
| 18 | "dump_zsh_state", | |
| 19 | "dump_bash_state", | |
| 20 | "__CURSOR_SANDBOX_ENV_RESTORE", | |
| 21 | ] as const; | |
| 22 | ||
| 23 | export function isCursorShellSpawnFailure(reason: unknown): boolean { | |
| 24 | if (typeof reason !== "object" || reason === null) { | |
| 25 | return false; | |
| 26 | } | |
| 27 | const syscall = Reflect.get(reason, "syscall"); | |
| 28 | const code = Reflect.get(reason, "code"); | |
| 29 | const spawnargs = Reflect.get(reason, "spawnargs"); | |
| 30 | if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") { | |
| 31 | return false; | |
| 32 | } | |
| 33 | if (!Array.isArray(spawnargs)) { | |
| 34 | return false; | |
| 35 | } | |
| 36 | return spawnargs.some( | |
| 37 | (arg) => | |
| 38 | typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)), | |
| 39 | ); | |
| 40 | } | |
| 41 | ||
| 42 | let cursorShellSpawnGuardInstalled = false; | |
| 43 | ||
| 44 | function installCursorShellSpawnGuard(): void { | |
| 45 | if (cursorShellSpawnGuardInstalled) { | |
| 46 | return; | |
| 47 | } | |
| 48 | cursorShellSpawnGuardInstalled = true; | |
| 49 | ||
| 50 | process.on("unhandledRejection", (reason) => { | |
| 51 | if (isCursorShellSpawnFailure(reason)) { | |
| 52 | console.error("Cursor shell spawn failed. The server will keep running.", reason); | |
| 53 | return; | |
| 54 | } | |
| 55 | // Another handler registered besides this one owns the decision. | |
| 56 | if (process.listenerCount("unhandledRejection") > 1) { | |
| 57 | return; | |
| 58 | } | |
| 59 | console.error(reason); | |
| 60 | process.exit(1); | |
| 61 | }); | |
| 62 | } | |
| 63 | ||
| 64 | // Arm before the SDK loads. Its shell tools spawn from this process. | |
| 65 | installCursorShellSpawnGuard(); | |
| 66 | ||
| 67 | // Cursor's Webpack chunks and local helpers must stay beside the SDK entry. | |
| 68 | // createRequire also loads that disk-backed package from a Node SEA executable. | |
| 69 | const requireCursorSdk = NodeModule.createRequire(import.meta.url); | |
| 70 | export const { | |
| 71 | Agent, | |
| 72 | AuthenticationError, | |
| 73 | createAgentPlatform, | |
| 74 | Cursor, | |
| 75 | CursorSdkError, | |
| 76 | InMemoryCredentialStore, | |
| 77 | } = requireCursorSdk("@cursor/sdk") as typeof import("@cursor/sdk"); |
packages/provider-cursor/src/server/sdkModel.ts created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | import type { ModelSelection as CursorSdkModelSelection, ModelParameterValue } from "@cursor/sdk"; | |
| 2 | import type { ModelSelection } from "@t3tools/contracts"; | |
| 3 | ||
| 4 | const CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION: Readonly<Record<string, string>> = { | |
| 5 | context: "contextWindow", | |
| 6 | fast: "fastMode", | |
| 7 | }; | |
| 8 | ||
| 9 | const PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER: Readonly<Record<string, string>> = { | |
| 10 | contextWindow: "context", | |
| 11 | fastMode: "fast", | |
| 12 | }; | |
| 13 | ||
| 14 | export function cursorSdkProviderOptionId(parameterId: string): string { | |
| 15 | return CURSOR_SDK_PARAMETER_TO_PROVIDER_OPTION[parameterId] ?? parameterId; | |
| 16 | } | |
| 17 | ||
| 18 | function cursorSdkParameterId(providerOptionId: string): string { | |
| 19 | return PROVIDER_OPTION_TO_CURSOR_SDK_PARAMETER[providerOptionId] ?? providerOptionId; | |
| 20 | } | |
| 21 | ||
| 22 | export function cursorSdkParameterPriority(parameterId: string): number { | |
| 23 | switch (parameterId) { | |
| 24 | case "effort": | |
| 25 | case "reasoning": | |
| 26 | return 0; | |
| 27 | case "context": | |
| 28 | return 1; | |
| 29 | case "fast": | |
| 30 | return 2; | |
| 31 | case "thinking": | |
| 32 | return 3; | |
| 33 | default: | |
| 34 | return 4; | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | export function cursorSdkModelSelection(modelSelection: ModelSelection): CursorSdkModelSelection { | |
| 39 | return { | |
| 40 | id: modelSelection.model === "auto" ? "default" : modelSelection.model, | |
| 41 | ...(modelSelection.options === undefined || modelSelection.options.length === 0 | |
| 42 | ? {} | |
| 43 | : { | |
| 44 | params: modelSelection.options.map((option): ModelParameterValue => ({ | |
| 45 | id: cursorSdkParameterId(option.id), | |
| 46 | value: String(option.value), | |
| 47 | })), | |
| 48 | }), | |
| 49 | }; | |
| 50 | } |
packages/provider-cursor/src/server/skills.test.ts created+186| ... | ... | @@ -0,0 +1,186 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 3 | import { describe, expect, it } from "vite-plus/test"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import type * as Scope from "effect/Scope"; | |
| 8 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; | |
| 9 | ||
| 10 | import { | |
| 11 | discoverCursorSkills, | |
| 12 | hasCursorSkillMention, | |
| 13 | probeCursorSkills, | |
| 14 | rewriteCursorSkillMentions, | |
| 15 | } from "./skills.ts"; | |
| 16 | ||
| 17 | const runNode = <A, E>( | |
| 18 | effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>, | |
| 19 | ) => effect.pipe(Effect.scoped, Effect.provide(NodeServices.layer), Effect.runPromise); | |
| 20 | ||
| 21 | describe("Cursor skills", () => { | |
| 22 | it("discovers recursive project skills with project precedence", async () => | |
| 23 | await runNode( | |
| 24 | Effect.gen(function* () { | |
| 25 | const fileSystem = yield* FileSystem.FileSystem; | |
| 26 | const path = yield* Path.Path; | |
| 27 | const userHome = yield* fileSystem | |
| 28 | .makeTempDirectoryScoped({ | |
| 29 | directory: NodeOS.tmpdir(), | |
| 30 | prefix: "cursor-skills-home-", | |
| 31 | }) | |
| 32 | .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); | |
| 33 | const workspace = yield* fileSystem | |
| 34 | .makeTempDirectoryScoped({ | |
| 35 | directory: NodeOS.tmpdir(), | |
| 36 | prefix: "cursor-skills-workspace-", | |
| 37 | }) | |
| 38 | .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); | |
| 39 | const writeSkill = Effect.fn("writeCursorSkill")(function* ( | |
| 40 | root: string, | |
| 41 | name: string, | |
| 42 | contents: string, | |
| 43 | ) { | |
| 44 | const skillDirectory = path.join(root, name); | |
| 45 | yield* fileSystem.makeDirectory(skillDirectory, { recursive: true }); | |
| 46 | yield* fileSystem.writeFileString(path.join(skillDirectory, "SKILL.md"), contents); | |
| 47 | }); | |
| 48 | ||
| 49 | yield* writeSkill( | |
| 50 | path.join(userHome, ".cursor", "skills"), | |
| 51 | "review", | |
| 52 | "---\ndescription: user review\n---\n", | |
| 53 | ); | |
| 54 | yield* writeSkill( | |
| 55 | path.join(workspace, ".agents", "skills", "nested"), | |
| 56 | "review", | |
| 57 | "---\nname: Review changes\ndescription: project review\n---\n", | |
| 58 | ); | |
| 59 | yield* writeSkill( | |
| 60 | path.join(workspace, ".cursor", "skills"), | |
| 61 | "internal", | |
| 62 | "---\nuser-invocable: false\n---\n", | |
| 63 | ); | |
| 64 | yield* writeSkill( | |
| 65 | path.join(workspace, ".cursor", "skills"), | |
| 66 | "oversized", | |
| 67 | "x".repeat(1_000_001), | |
| 68 | ); | |
| 69 | yield* fileSystem.makeDirectory(path.join(userHome, ".codex"), { recursive: true }); | |
| 70 | yield* fileSystem.writeFileString( | |
| 71 | path.join(userHome, ".codex", "skills"), | |
| 72 | "not a directory", | |
| 73 | ); | |
| 74 | ||
| 75 | const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); | |
| 76 | expect(skills).toEqual([ | |
| 77 | { | |
| 78 | name: "internal", | |
| 79 | path: path.join(workspace, ".cursor", "skills", "internal", "SKILL.md"), | |
| 80 | scope: "project", | |
| 81 | enabled: true, | |
| 82 | userInvocable: false, | |
| 83 | }, | |
| 84 | { | |
| 85 | name: "oversized", | |
| 86 | path: path.join(workspace, ".cursor", "skills", "oversized", "SKILL.md"), | |
| 87 | scope: "project", | |
| 88 | enabled: true, | |
| 89 | }, | |
| 90 | { | |
| 91 | name: "review", | |
| 92 | displayName: "Review changes", | |
| 93 | description: "project review", | |
| 94 | path: path.join(workspace, ".agents", "skills", "nested", "review", "SKILL.md"), | |
| 95 | scope: "project", | |
| 96 | enabled: true, | |
| 97 | }, | |
| 98 | ]); | |
| 99 | expect( | |
| 100 | (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, | |
| 101 | ).toBe("Failure"); | |
| 102 | }), | |
| 103 | )); | |
| 104 | ||
| 105 | it.skipIf(!symlinksSupported)( | |
| 106 | "treats a symlinked skill outside the root as a package boundary", | |
| 107 | async () => | |
| 108 | await runNode( | |
| 109 | Effect.gen(function* () { | |
| 110 | const fileSystem = yield* FileSystem.FileSystem; | |
| 111 | const path = yield* Path.Path; | |
| 112 | const userHome = yield* fileSystem.makeTempDirectoryScoped({ | |
| 113 | directory: NodeOS.tmpdir(), | |
| 114 | prefix: "cursor-skills-home-", | |
| 115 | }); | |
| 116 | const workspace = yield* fileSystem | |
| 117 | .makeTempDirectoryScoped({ | |
| 118 | directory: NodeOS.tmpdir(), | |
| 119 | prefix: "cursor-skills-workspace-", | |
| 120 | }) | |
| 121 | .pipe(Effect.flatMap((directory) => fileSystem.realPath(directory))); | |
| 122 | const library = yield* fileSystem.makeTempDirectoryScoped({ | |
| 123 | directory: NodeOS.tmpdir(), | |
| 124 | prefix: "cursor-skills-library-", | |
| 125 | }); | |
| 126 | const writeSkill = Effect.fn("writeCursorSkill")(function* ( | |
| 127 | directory: string, | |
| 128 | contents: string, | |
| 129 | ) { | |
| 130 | yield* fileSystem.makeDirectory(directory, { recursive: true }); | |
| 131 | yield* fileSystem.writeFileString(path.join(directory, "SKILL.md"), contents); | |
| 132 | }); | |
| 133 | ||
| 134 | // A skill package managed in a config repo and installed by symlink. | |
| 135 | // Its own SKILL.md must be discovered under the link name, but nothing | |
| 136 | // below the target may be walked. | |
| 137 | yield* writeSkill(path.join(library, "shared-review"), "---\ndescription: shared\n---\n"); | |
| 138 | yield* writeSkill(path.join(library, "shared-review", "hidden"), "---\n---\n"); | |
| 139 | const root = path.join(workspace, ".cursor", "skills"); | |
| 140 | yield* fileSystem.makeDirectory(root, { recursive: true }); | |
| 141 | yield* fileSystem.symlink(path.join(library, "shared-review"), path.join(root, "review")); | |
| 142 | ||
| 143 | const skills = yield* discoverCursorSkills(workspace, { HOME: userHome }); | |
| 144 | expect(skills).toEqual([ | |
| 145 | { | |
| 146 | name: "review", | |
| 147 | description: "shared", | |
| 148 | path: path.join(root, "review", "SKILL.md"), | |
| 149 | scope: "project", | |
| 150 | enabled: true, | |
| 151 | }, | |
| 152 | ]); | |
| 153 | expect( | |
| 154 | (yield* probeCursorSkills(workspace, { HOME: userHome }).pipe(Effect.result))._tag, | |
| 155 | ).toBe("Success"); | |
| 156 | }), | |
| 157 | ), | |
| 158 | ); | |
| 159 | ||
| 160 | it("rewrites only discovered skill mentions into Cursor slash invocations", () => { | |
| 161 | expect(hasCursorSkillMention("use $Review_Pr:V2 here")).toBe(true); | |
| 162 | expect(hasCursorSkillMention("please $review this")).toBe(true); | |
| 163 | expect( | |
| 164 | rewriteCursorSkillMentions("use $review, keep $HOME and 5$review", new Set(["review"])), | |
| 165 | ).toBe("use $review, keep $HOME and 5$review"); | |
| 166 | expect(rewriteCursorSkillMentions("please $review this", new Set(["review"]))).toBe( | |
| 167 | "please /review this", | |
| 168 | ); | |
| 169 | }); | |
| 170 | it("detects and invokes digit-leading Cursor skills without rewriting money", () => { | |
| 171 | const names = new Set(["2spec", "20k", "100M", "1e6"]); | |
| 172 | expect(hasCursorSkillMention("use $2spec here")).toBe(true); | |
| 173 | expect(hasCursorSkillMention("use $2spec here")).toBe(true); | |
| 174 | expect(rewriteCursorSkillMentions("use $2spec here", names)).toBe("use /2spec here"); | |
| 175 | expect(rewriteCursorSkillMentions("use $2spec here", new Set())).toBe("use $2spec here"); | |
| 176 | for (const text of [ | |
| 177 | "pay $20 tomorrow", | |
| 178 | "budget $20k here", | |
| 179 | "cost $100M total", | |
| 180 | "limit $1e6 here", | |
| 181 | ]) { | |
| 182 | expect(hasCursorSkillMention(text)).toBe(false); | |
| 183 | expect(rewriteCursorSkillMentions(text, names)).toBe(text); | |
| 184 | } | |
| 185 | }); | |
| 186 | }); |
packages/provider-cursor/src/server/skills.ts created+290| ... | ... | @@ -0,0 +1,290 @@ |
| 1 | /** | |
| 2 | * CursorSkills — workspace-aware discovery and native invocation for Cursor. | |
| 3 | * | |
| 4 | * Cursor discovers Agent Skills recursively from user and project roots but | |
| 5 | * its ACP command catalog only appears after opening a real session. Scanning | |
| 6 | * the same roots avoids starting an agent and its MCP servers just to populate | |
| 7 | * a composer menu. | |
| 8 | * | |
| 9 | * @module provider/Drivers/CursorSkills | |
| 10 | */ | |
| 11 | import * as NodeOS from "node:os"; | |
| 12 | ||
| 13 | import type { ServerProviderSkill } from "@t3tools/contracts"; | |
| 14 | import * as ByteSize from "effect/ByteSize"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as FileSystem from "effect/FileSystem"; | |
| 17 | import * as Path from "effect/Path"; | |
| 18 | import * as PlatformError from "effect/PlatformError"; | |
| 19 | import * as Schema from "effect/Schema"; | |
| 20 | import { parse as parseYamlDocument } from "yaml"; | |
| 21 | ||
| 22 | const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; | |
| 23 | const SKILL_MENTION_PATTERN = | |
| 24 | /(^|\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; | |
| 25 | const HAS_SKILL_MENTION_PATTERN = new RegExp(SKILL_MENTION_PATTERN.source, "u"); | |
| 26 | const MAX_SKILL_DEPTH = 10; | |
| 27 | const MAX_SKILL_BYTES = ByteSize.bytes(1_000_000); | |
| 28 | const MAX_SKILL_SCAN_ENTRIES = 10_000; | |
| 29 | const MAX_SKILL_SCAN_BYTES = ByteSize.bytes(8_000_000); | |
| 30 | ||
| 31 | interface CursorSkillFrontmatter { | |
| 32 | readonly description?: string; | |
| 33 | readonly displayName?: string; | |
| 34 | readonly userInvocationOnly?: boolean; | |
| 35 | readonly userInvocable?: boolean; | |
| 36 | readonly cliVisible: boolean; | |
| 37 | } | |
| 38 | ||
| 39 | interface CursorSkillScanBudget { | |
| 40 | remainingEntries: number; | |
| 41 | remainingBytes: bigint; | |
| 42 | exhausted: boolean; | |
| 43 | incomplete: boolean; | |
| 44 | } | |
| 45 | ||
| 46 | class CursorSkillsProbeError extends Schema.TaggedError<CursorSkillsProbeError>()( | |
| 47 | "CursorSkillsProbeError", | |
| 48 | { | |
| 49 | reason: Schema.Literals(["scan-budget-exhausted", "filesystem-error"]), | |
| 50 | cwd: Schema.optional(Schema.String), | |
| 51 | }, | |
| 52 | ) { | |
| 53 | override get message(): string { | |
| 54 | const location = this.cwd === undefined ? "" : ` for '${this.cwd}'`; | |
| 55 | return `Cursor skill discovery${location} was incomplete (${this.reason}).`; | |
| 56 | } | |
| 57 | } | |
| 58 | ||
| 59 | const orUndefined = <A, R>( | |
| 60 | effect: Effect.Effect<A, PlatformError.PlatformError, R>, | |
| 61 | budget?: CursorSkillScanBudget, | |
| 62 | ): Effect.Effect<A | undefined, never, R> => | |
| 63 | effect.pipe( | |
| 64 | Effect.map((value): A | undefined => value), | |
| 65 | Effect.catchTags({ | |
| 66 | PlatformError: (error) => { | |
| 67 | if (error.reason._tag !== "NotFound" && budget) budget.incomplete = true; | |
| 68 | return Effect.void.pipe(Effect.as(undefined)); | |
| 69 | }, | |
| 70 | }), | |
| 71 | ); | |
| 72 | ||
| 73 | function parseFrontmatterBoolean(value: unknown): boolean | undefined { | |
| 74 | if (typeof value === "boolean") return value; | |
| 75 | if (typeof value === "number") return value === 1 ? true : value === 0 ? false : undefined; | |
| 76 | if (typeof value !== "string") return undefined; | |
| 77 | switch (value.trim().toLowerCase()) { | |
| 78 | case "true": | |
| 79 | case "yes": | |
| 80 | case "on": | |
| 81 | return true; | |
| 82 | case "false": | |
| 83 | case "no": | |
| 84 | case "off": | |
| 85 | return false; | |
| 86 | default: | |
| 87 | return undefined; | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | function parseSkillFrontmatter(contents: string): CursorSkillFrontmatter | undefined { | |
| 92 | const match = FRONTMATTER_PATTERN.exec(contents); | |
| 93 | if (!match) return { cliVisible: true }; | |
| 94 | ||
| 95 | let parsed: unknown; | |
| 96 | try { | |
| 97 | parsed = parseYamlDocument(match[1] ?? ""); | |
| 98 | } catch { | |
| 99 | return undefined; | |
| 100 | } | |
| 101 | if (typeof parsed !== "object" || parsed === null) return undefined; | |
| 102 | ||
| 103 | const record = parsed as Record<string, unknown>; | |
| 104 | const metadata = | |
| 105 | typeof record.metadata === "object" && record.metadata !== null | |
| 106 | ? (record.metadata as Record<string, unknown>) | |
| 107 | : undefined; | |
| 108 | const rawSurfaces = metadata?.surfaces; | |
| 109 | const surfaces = Array.isArray(rawSurfaces) | |
| 110 | ? rawSurfaces.filter((surface): surface is string => typeof surface === "string") | |
| 111 | : typeof rawSurfaces === "string" | |
| 112 | ? rawSurfaces.split(",") | |
| 113 | : []; | |
| 114 | const description = typeof record.description === "string" ? record.description.trim() : ""; | |
| 115 | const displayName = typeof record.name === "string" ? record.name.trim() : ""; | |
| 116 | return { | |
| 117 | cliVisible: | |
| 118 | surfaces.length === 0 || surfaces.some((surface) => surface.trim().toLowerCase() === "cli"), | |
| 119 | ...(description ? { description } : {}), | |
| 120 | ...(displayName ? { displayName } : {}), | |
| 121 | ...(parseFrontmatterBoolean(record["disable-model-invocation"]) === true | |
| 122 | ? { userInvocationOnly: true } | |
| 123 | : {}), | |
| 124 | ...(parseFrontmatterBoolean(record["user-invocable"]) === false | |
| 125 | ? { userInvocable: false } | |
| 126 | : {}), | |
| 127 | }; | |
| 128 | } | |
| 129 | ||
| 130 | const discoverSkillsInRoot = Effect.fn("discoverCursorSkillsInRoot")(function* (input: { | |
| 131 | readonly directory: string; | |
| 132 | readonly scope: "user" | "project"; | |
| 133 | readonly budget: CursorSkillScanBudget; | |
| 134 | }): Effect.fn.Return<ReadonlyArray<ServerProviderSkill>, never, FileSystem.FileSystem | Path.Path> { | |
| 135 | const fileSystem = yield* FileSystem.FileSystem; | |
| 136 | const path = yield* Path.Path; | |
| 137 | const skills: ServerProviderSkill[] = []; | |
| 138 | if (input.budget.exhausted) return skills; | |
| 139 | const rootDirectory = yield* orUndefined(fileSystem.realPath(input.directory), input.budget); | |
| 140 | if (!rootDirectory) return skills; | |
| 141 | const visitedDirectories = new Set<string>(); | |
| 142 | ||
| 143 | const visit = Effect.fn("visitCursorSkillDirectory")(function* ( | |
| 144 | directory: string, | |
| 145 | depth: number, | |
| 146 | ): Effect.fn.Return<void, never> { | |
| 147 | if (input.budget.exhausted) return; | |
| 148 | const resolvedDirectory = yield* orUndefined(fileSystem.realPath(directory), input.budget); | |
| 149 | if (!resolvedDirectory) { | |
| 150 | return; | |
| 151 | } | |
| 152 | if (visitedDirectories.has(resolvedDirectory)) { | |
| 153 | return; | |
| 154 | } | |
| 155 | visitedDirectories.add(resolvedDirectory); | |
| 156 | // A symlink whose target lives outside the root is a skill package | |
| 157 | // boundary: read its own SKILL.md so linked skill libraries show up, but | |
| 158 | // never walk the target tree. | |
| 159 | const insideRoot = | |
| 160 | resolvedDirectory === rootDirectory || | |
| 161 | resolvedDirectory.startsWith(`${rootDirectory}${path.sep}`); | |
| 162 | ||
| 163 | const skillPath = path.join(directory, "SKILL.md"); | |
| 164 | const skillInfo = yield* orUndefined(fileSystem.stat(skillPath), input.budget); | |
| 165 | if (skillInfo?.type === "File") { | |
| 166 | let frontmatter: CursorSkillFrontmatter | undefined = { cliVisible: true }; | |
| 167 | if (skillInfo.size <= MAX_SKILL_BYTES && skillInfo.size <= input.budget.remainingBytes) { | |
| 168 | const contents = yield* orUndefined(fileSystem.readFileString(skillPath)); | |
| 169 | if (contents !== undefined) { | |
| 170 | input.budget.remainingBytes -= skillInfo.size; | |
| 171 | frontmatter = parseSkillFrontmatter(contents); | |
| 172 | } | |
| 173 | } | |
| 174 | const name = path.basename(directory).trim(); | |
| 175 | if (frontmatter?.cliVisible && name) { | |
| 176 | skills.push({ | |
| 177 | name, | |
| 178 | path: skillPath, | |
| 179 | scope: input.scope, | |
| 180 | enabled: true, | |
| 181 | ...(frontmatter.displayName && frontmatter.displayName !== name | |
| 182 | ? { displayName: frontmatter.displayName } | |
| 183 | : {}), | |
| 184 | ...(frontmatter.description ? { description: frontmatter.description } : {}), | |
| 185 | ...(frontmatter.userInvocationOnly ? { userInvocationOnly: true } : {}), | |
| 186 | ...(frontmatter.userInvocable === false ? { userInvocable: false } : {}), | |
| 187 | }); | |
| 188 | } | |
| 189 | } | |
| 190 | ||
| 191 | if (!insideRoot) { | |
| 192 | return; | |
| 193 | } | |
| 194 | const entries = yield* orUndefined(fileSystem.readDirectory(directory), input.budget); | |
| 195 | if (!entries) { | |
| 196 | return; | |
| 197 | } | |
| 198 | for (const entry of [...entries].sort()) { | |
| 199 | if (input.budget.remainingEntries === 0) { | |
| 200 | input.budget.exhausted = true; | |
| 201 | return; | |
| 202 | } | |
| 203 | input.budget.remainingEntries -= 1; | |
| 204 | const child = path.join(directory, entry); | |
| 205 | const info = yield* orUndefined(fileSystem.stat(child), input.budget); | |
| 206 | if (info?.type !== "Directory") continue; | |
| 207 | if (depth >= MAX_SKILL_DEPTH) { | |
| 208 | input.budget.exhausted = true; | |
| 209 | return; | |
| 210 | } | |
| 211 | yield* visit(child, depth + 1); | |
| 212 | } | |
| 213 | }); | |
| 214 | ||
| 215 | yield* visit(rootDirectory, 0); | |
| 216 | return skills; | |
| 217 | }); | |
| 218 | ||
| 219 | const inspectCursorSkills = Effect.fn("inspectCursorSkills")(function* ( | |
| 220 | cwd?: string, | |
| 221 | environment: NodeJS.ProcessEnv = process.env, | |
| 222 | ) { | |
| 223 | const path = yield* Path.Path; | |
| 224 | const userHome = environment.HOME?.trim() || environment.USERPROFILE?.trim() || NodeOS.homedir(); | |
| 225 | const rootsBelow = (base: string, scope: "user" | "project") => [ | |
| 226 | { directory: path.join(base, ".cursor", "skills"), scope }, | |
| 227 | { directory: path.join(base, ".agents", "skills"), scope }, | |
| 228 | { directory: path.join(base, ".codex", "skills"), scope }, | |
| 229 | { directory: path.join(base, ".claude", "skills"), scope }, | |
| 230 | ]; | |
| 231 | const roots = [...(cwd ? rootsBelow(cwd, "project") : []), ...rootsBelow(userHome, "user")]; | |
| 232 | ||
| 233 | const skillsByName = new Map<string, ServerProviderSkill>(); | |
| 234 | const budget: CursorSkillScanBudget = { | |
| 235 | remainingEntries: MAX_SKILL_SCAN_ENTRIES, | |
| 236 | remainingBytes: MAX_SKILL_SCAN_BYTES, | |
| 237 | exhausted: false, | |
| 238 | incomplete: false, | |
| 239 | }; | |
| 240 | for (const root of roots) { | |
| 241 | if (budget.exhausted) break; | |
| 242 | const skills = yield* discoverSkillsInRoot({ ...root, budget }); | |
| 243 | for (const skill of skills) { | |
| 244 | if (!skillsByName.has(skill.name)) skillsByName.set(skill.name, skill); | |
| 245 | } | |
| 246 | } | |
| 247 | return { | |
| 248 | skills: [...skillsByName.values()].sort((left, right) => left.name.localeCompare(right.name)), | |
| 249 | failureReason: budget.exhausted | |
| 250 | ? ("scan-budget-exhausted" as const) | |
| 251 | : budget.incomplete | |
| 252 | ? ("filesystem-error" as const) | |
| 253 | : undefined, | |
| 254 | }; | |
| 255 | }); | |
| 256 | ||
| 257 | export const discoverCursorSkills = Effect.fn("discoverCursorSkills")(function* ( | |
| 258 | cwd?: string, | |
| 259 | environment: NodeJS.ProcessEnv = process.env, | |
| 260 | ) { | |
| 261 | return (yield* inspectCursorSkills(cwd, environment)).skills; | |
| 262 | }); | |
| 263 | ||
| 264 | export const probeCursorSkills = Effect.fn("probeCursorSkills")(function* ( | |
| 265 | cwd?: string, | |
| 266 | environment: NodeJS.ProcessEnv = process.env, | |
| 267 | ) { | |
| 268 | const inspection = yield* inspectCursorSkills(cwd, environment); | |
| 269 | if (inspection.failureReason) { | |
| 270 | return yield* new CursorSkillsProbeError({ | |
| 271 | reason: inspection.failureReason, | |
| 272 | ...(cwd ? { cwd } : {}), | |
| 273 | }); | |
| 274 | } | |
| 275 | return inspection.skills; | |
| 276 | }); | |
| 277 | ||
| 278 | /** Cursor invokes Agent Skills with `/name`; T3 composers insert `$name`. */ | |
| 279 | export function hasCursorSkillMention(prompt: string): boolean { | |
| 280 | return HAS_SKILL_MENTION_PATTERN.test(prompt); | |
| 281 | } | |
| 282 | ||
| 283 | export function rewriteCursorSkillMentions( | |
| 284 | prompt: string, | |
| 285 | skillNames: ReadonlySet<string>, | |
| 286 | ): string { | |
| 287 | return prompt.replace(SKILL_MENTION_PATTERN, (match, prefix: string, name: string) => | |
| 288 | skillNames.has(name) ? `${prefix}/${name}` : match, | |
| 289 | ); | |
| 290 | } |
packages/provider-cursor/src/server/status.test.ts created+252| ... | ... | @@ -0,0 +1,252 @@ |
| 1 | import type { SDKModel } from "@cursor/sdk"; | |
| 2 | import { describe, expect, it } from "@effect/vitest"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Schema from "effect/Schema"; | |
| 5 | import type { CursorSettings } from "../settings.ts"; | |
| 6 | import { CursorSettings as CursorSettingsSchema } from "../settings.ts"; | |
| 7 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 8 | ||
| 9 | import { | |
| 10 | buildCursorCapabilitiesFromSdkModel, | |
| 11 | buildCursorDiscoveredModelsFromSdk, | |
| 12 | buildCursorProviderSnapshot, | |
| 13 | buildInitialCursorProviderSnapshot, | |
| 14 | checkCursorProviderStatus, | |
| 15 | } from "./status.ts"; | |
| 16 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | |
| 17 | ||
| 18 | const decodeCursorSettings = Schema.decodeSync(CursorSettingsSchema); | |
| 19 | ||
| 20 | function selectDescriptor( | |
| 21 | id: string, | |
| 22 | label: string, | |
| 23 | options: ReadonlyArray<{ id: string; label: string; isDefault?: boolean }>, | |
| 24 | ) { | |
| 25 | return { | |
| 26 | id, | |
| 27 | label, | |
| 28 | type: "select" as const, | |
| 29 | options: [...options], | |
| 30 | ...(options.find((option) => option.isDefault)?.id | |
| 31 | ? { currentValue: options.find((option) => option.isDefault)?.id } | |
| 32 | : {}), | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | function booleanDescriptor(id: string, label: string, currentValue?: boolean) { | |
| 37 | return { | |
| 38 | id, | |
| 39 | label, | |
| 40 | type: "boolean" as const, | |
| 41 | ...(typeof currentValue === "boolean" ? { currentValue } : {}), | |
| 42 | }; | |
| 43 | } | |
| 44 | ||
| 45 | const baseCursorSettings: CursorSettings = decodeCursorSettings({ | |
| 46 | enabled: true, | |
| 47 | customModels: [], | |
| 48 | }); | |
| 49 | ||
| 50 | const sdkParameterizedModel = { | |
| 51 | id: "claude-opus-4-8", | |
| 52 | displayName: "Opus 4.8", | |
| 53 | parameters: [ | |
| 54 | { | |
| 55 | id: "thinking", | |
| 56 | displayName: "Thinking", | |
| 57 | values: [{ value: "false" }, { value: "true" }], | |
| 58 | }, | |
| 59 | { | |
| 60 | id: "context", | |
| 61 | displayName: "Context", | |
| 62 | values: [ | |
| 63 | { value: "300k", displayName: "300K" }, | |
| 64 | { value: "1m", displayName: "1M" }, | |
| 65 | ], | |
| 66 | }, | |
| 67 | { | |
| 68 | id: "effort", | |
| 69 | displayName: "Effort", | |
| 70 | values: [ | |
| 71 | { value: "low", displayName: "Low" }, | |
| 72 | { value: "high", displayName: "High" }, | |
| 73 | ], | |
| 74 | }, | |
| 75 | { | |
| 76 | id: "fast", | |
| 77 | displayName: "Fast", | |
| 78 | values: [{ value: "false" }, { value: "true", displayName: "Fast" }], | |
| 79 | }, | |
| 80 | ], | |
| 81 | variants: [ | |
| 82 | { | |
| 83 | displayName: "Opus 4.8", | |
| 84 | isDefault: true, | |
| 85 | params: [ | |
| 86 | { id: "thinking", value: "true" }, | |
| 87 | { id: "context", value: "1m" }, | |
| 88 | { id: "effort", value: "high" }, | |
| 89 | { id: "fast", value: "false" }, | |
| 90 | ], | |
| 91 | }, | |
| 92 | ], | |
| 93 | } satisfies SDKModel; | |
| 94 | ||
| 95 | describe("buildInitialCursorProviderSnapshot", () => { | |
| 96 | it.effect("uses SDK-specific pending status copy", () => | |
| 97 | Effect.gen(function* () { | |
| 98 | const provider = yield* buildInitialCursorProviderSnapshot(baseCursorSettings); | |
| 99 | ||
| 100 | expect(provider).toMatchObject({ | |
| 101 | status: "warning", | |
| 102 | message: "Checking Cursor SDK availability...", | |
| 103 | }); | |
| 104 | }), | |
| 105 | ); | |
| 106 | }); | |
| 107 | ||
| 108 | describe("buildCursorProviderSnapshot", () => { | |
| 109 | it("downgrades ready status to warning when SDK model discovery returns no models", () => { | |
| 110 | expect( | |
| 111 | buildCursorProviderSnapshot({ | |
| 112 | checkedAt: "2026-01-01T00:00:00.000Z", | |
| 113 | cursorSettings: baseCursorSettings, | |
| 114 | parsed: { | |
| 115 | version: null, | |
| 116 | status: "ready", | |
| 117 | auth: { status: "authenticated", type: "api-key", label: "Cursor API key" }, | |
| 118 | }, | |
| 119 | discoveryWarning: "Cursor SDK model discovery returned no built-in models.", | |
| 120 | }), | |
| 121 | ).toMatchObject({ | |
| 122 | status: "warning", | |
| 123 | message: "Cursor SDK model discovery returned no built-in models.", | |
| 124 | models: [], | |
| 125 | supportsConversationRollback: false, | |
| 126 | }); | |
| 127 | }); | |
| 128 | }); | |
| 129 | ||
| 130 | describe("Cursor SDK model discovery", () => { | |
| 131 | it("maps native SDK parameter ids and default variant values to model capabilities", () => { | |
| 132 | expect(buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel)).toEqual( | |
| 133 | createModelCapabilities({ | |
| 134 | optionDescriptors: [ | |
| 135 | selectDescriptor("effort", "Effort", [ | |
| 136 | { id: "low", label: "Low" }, | |
| 137 | { id: "high", label: "High", isDefault: true }, | |
| 138 | ]), | |
| 139 | selectDescriptor("contextWindow", "Context", [ | |
| 140 | { id: "300k", label: "300K" }, | |
| 141 | { id: "1m", label: "1M", isDefault: true }, | |
| 142 | ]), | |
| 143 | booleanDescriptor("fastMode", "Fast", false), | |
| 144 | booleanDescriptor("thinking", "Thinking", true), | |
| 145 | ], | |
| 146 | }), | |
| 147 | ); | |
| 148 | }); | |
| 149 | ||
| 150 | it("filters invalid and duplicate SDK model entries", () => { | |
| 151 | expect( | |
| 152 | buildCursorDiscoveredModelsFromSdk([ | |
| 153 | sdkParameterizedModel, | |
| 154 | { ...sdkParameterizedModel, displayName: "Duplicate" }, | |
| 155 | { id: "", displayName: "Invalid" }, | |
| 156 | ]), | |
| 157 | ).toEqual([ | |
| 158 | { | |
| 159 | slug: "claude-opus-4-8", | |
| 160 | name: "Opus 4.8", | |
| 161 | isCustom: false, | |
| 162 | capabilities: buildCursorCapabilitiesFromSdkModel(sdkParameterizedModel), | |
| 163 | }, | |
| 164 | ]); | |
| 165 | }); | |
| 166 | }); | |
| 167 | ||
| 168 | describe("checkCursorProviderStatus", () => { | |
| 169 | it.effect("uses the SDK catalog when CURSOR_API_KEY is configured", () => | |
| 170 | Effect.gen(function* () { | |
| 171 | const provider = yield* checkCursorProviderStatus( | |
| 172 | { | |
| 173 | ...baseCursorSettings, | |
| 174 | customModels: ["internal/cursor-model"], | |
| 175 | }, | |
| 176 | { CURSOR_API_KEY: "test-cursor-key" }, | |
| 177 | ).pipe( | |
| 178 | Effect.provide( | |
| 179 | CursorSdkCatalog.layerTest((apiKey) => { | |
| 180 | expect(apiKey).toBe("test-cursor-key"); | |
| 181 | return Effect.succeed({ | |
| 182 | user: { | |
| 183 | apiKeyName: "test-key", | |
| 184 | userEmail: "cursor@example.com", | |
| 185 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 186 | }, | |
| 187 | models: [sdkParameterizedModel], | |
| 188 | }); | |
| 189 | }), | |
| 190 | ), | |
| 191 | ); | |
| 192 | ||
| 193 | expect(provider).toMatchObject({ | |
| 194 | status: "ready", | |
| 195 | auth: { | |
| 196 | status: "authenticated", | |
| 197 | type: "api-key", | |
| 198 | label: "Cursor API key (test-key)", | |
| 199 | email: "cursor@example.com", | |
| 200 | }, | |
| 201 | models: [ | |
| 202 | { slug: "claude-opus-4-8", isCustom: false }, | |
| 203 | { slug: "internal/cursor-model", isCustom: true }, | |
| 204 | ], | |
| 205 | }); | |
| 206 | }), | |
| 207 | ); | |
| 208 | ||
| 209 | it.effect("surfaces SDK authentication failures", () => | |
| 210 | Effect.gen(function* () { | |
| 211 | const provider = yield* checkCursorProviderStatus(baseCursorSettings, { | |
| 212 | CURSOR_API_KEY: "invalid-test-key", | |
| 213 | }).pipe( | |
| 214 | Effect.provide( | |
| 215 | CursorSdkCatalog.layerTest(() => | |
| 216 | Effect.fail( | |
| 217 | new CursorSdkCatalog.CursorSdkCatalogError({ | |
| 218 | authenticationFailure: true, | |
| 219 | cause: new Error("unauthorized"), | |
| 220 | }), | |
| 221 | ), | |
| 222 | ), | |
| 223 | ), | |
| 224 | ); | |
| 225 | ||
| 226 | expect(provider).toMatchObject({ | |
| 227 | status: "error", | |
| 228 | auth: { status: "unauthenticated" }, | |
| 229 | message: "Cursor SDK authentication failed. Check CURSOR_API_KEY.", | |
| 230 | }); | |
| 231 | }), | |
| 232 | ); | |
| 233 | ||
| 234 | it.effect("requires a Cursor API key without probing any external Cursor binary", () => | |
| 235 | Effect.gen(function* () { | |
| 236 | const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe( | |
| 237 | Effect.provide( | |
| 238 | CursorSdkCatalog.layerTest(() => | |
| 239 | Effect.die("SDK catalog must not be used without CURSOR_API_KEY"), | |
| 240 | ), | |
| 241 | ), | |
| 242 | ); | |
| 243 | ||
| 244 | expect(provider).toMatchObject({ | |
| 245 | installed: true, | |
| 246 | status: "error", | |
| 247 | auth: { status: "unauthenticated" }, | |
| 248 | message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | |
| 249 | }); | |
| 250 | }), | |
| 251 | ); | |
| 252 | }); |
packages/provider-cursor/src/server/status.ts created+350| ... | ... | @@ -0,0 +1,350 @@ |
| 1 | import type { SDKModel, SDKUser } from "@cursor/sdk"; | |
| 2 | import type { | |
| 3 | ModelCapabilities, | |
| 4 | ProviderOptionDescriptor, | |
| 5 | ServerProviderAuth, | |
| 6 | ServerProviderModel, | |
| 7 | ServerProviderState, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import type { CursorSettings } from "../settings.ts"; | |
| 10 | import { createModelCapabilities } from "@t3tools/shared/model"; | |
| 11 | import * as DateTime from "effect/DateTime"; | |
| 12 | import * as Effect from "effect/Effect"; | |
| 13 | import * as Option from "effect/Option"; | |
| 14 | import * as Result from "effect/Result"; | |
| 15 | ||
| 16 | import { cursorSdkParameterPriority, cursorSdkProviderOptionId } from "./sdkModel.ts"; | |
| 17 | import { | |
| 18 | buildBooleanOptionDescriptor, | |
| 19 | buildSelectOptionDescriptor, | |
| 20 | buildServerProvider, | |
| 21 | providerModelsFromSettings, | |
| 22 | type ServerProviderDraft, | |
| 23 | } from "@t3tools/provider-core/server/snapshotProbe"; | |
| 24 | import * as CursorSdkCatalog from "./CursorSdkCatalog.ts"; | |
| 25 | ||
| 26 | const CURSOR_PRESENTATION = { | |
| 27 | displayName: "Cursor", | |
| 28 | supportsConversationRollback: false, | |
| 29 | showInteractionModeToggle: true, | |
| 30 | } as const; | |
| 31 | const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ | |
| 32 | optionDescriptors: [], | |
| 33 | }); | |
| 34 | ||
| 35 | const CURSOR_SDK_CATALOG_TIMEOUT_MS = 15_000; | |
| 36 | ||
| 37 | export function buildInitialCursorProviderSnapshot( | |
| 38 | cursorSettings: CursorSettings, | |
| 39 | ): Effect.Effect<ServerProviderDraft> { | |
| 40 | return Effect.gen(function* () { | |
| 41 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); | |
| 42 | const models = getCursorFallbackModels(cursorSettings); | |
| 43 | ||
| 44 | if (!cursorSettings.enabled) { | |
| 45 | return buildServerProvider({ | |
| 46 | presentation: CURSOR_PRESENTATION, | |
| 47 | enabled: false, | |
| 48 | checkedAt, | |
| 49 | models, | |
| 50 | probe: { | |
| 51 | installed: false, | |
| 52 | version: null, | |
| 53 | status: "warning", | |
| 54 | auth: { status: "unknown" }, | |
| 55 | message: "Cursor is disabled in T3 Code settings.", | |
| 56 | }, | |
| 57 | }); | |
| 58 | } | |
| 59 | ||
| 60 | return buildServerProvider({ | |
| 61 | presentation: CURSOR_PRESENTATION, | |
| 62 | enabled: true, | |
| 63 | checkedAt, | |
| 64 | models, | |
| 65 | probe: { | |
| 66 | installed: true, | |
| 67 | version: null, | |
| 68 | status: "warning", | |
| 69 | auth: { status: "unknown" }, | |
| 70 | message: "Checking Cursor SDK availability...", | |
| 71 | }, | |
| 72 | }); | |
| 73 | }); | |
| 74 | } | |
| 75 | ||
| 76 | function getCursorFallbackModels( | |
| 77 | cursorSettings: Pick<CursorSettings, "customModels">, | |
| 78 | ): ReadonlyArray<ServerProviderModel> { | |
| 79 | return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); | |
| 80 | } | |
| 81 | ||
| 82 | function toTitleCaseWords(value: string): string { | |
| 83 | const parts: Array<string> = []; | |
| 84 | for (const part of value.split(/[\s_-]+/g)) { | |
| 85 | if (part.length > 0) { | |
| 86 | parts.push(part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()); | |
| 87 | } | |
| 88 | } | |
| 89 | return parts.join(" "); | |
| 90 | } | |
| 91 | ||
| 92 | function cursorSdkDefaultParameterValue(model: SDKModel, parameterId: string): string | undefined { | |
| 93 | return model.variants | |
| 94 | ?.find((variant) => variant.isDefault) | |
| 95 | ?.params.find((parameter) => parameter.id === parameterId)?.value; | |
| 96 | } | |
| 97 | ||
| 98 | export function buildCursorCapabilitiesFromSdkModel(model: SDKModel): ModelCapabilities { | |
| 99 | const seen = new Set<string>(); | |
| 100 | const optionDescriptors: Array<ProviderOptionDescriptor> = []; | |
| 101 | const parameters = (model.parameters ?? []) | |
| 102 | .map((parameter, index) => ({ parameter, index })) | |
| 103 | .toSorted( | |
| 104 | (left, right) => | |
| 105 | cursorSdkParameterPriority(left.parameter.id) - | |
| 106 | cursorSdkParameterPriority(right.parameter.id) || left.index - right.index, | |
| 107 | ); | |
| 108 | for (const { parameter } of parameters) { | |
| 109 | const nativeId = parameter.id.trim(); | |
| 110 | const id = cursorSdkProviderOptionId(nativeId); | |
| 111 | if (!nativeId || !id || seen.has(id)) { | |
| 112 | continue; | |
| 113 | } | |
| 114 | seen.add(id); | |
| 115 | ||
| 116 | const values = parameter.values.flatMap((entry) => { | |
| 117 | const value = entry.value.trim(); | |
| 118 | if (!value) { | |
| 119 | return []; | |
| 120 | } | |
| 121 | return [ | |
| 122 | { | |
| 123 | value, | |
| 124 | label: entry.displayName?.trim() || value, | |
| 125 | }, | |
| 126 | ]; | |
| 127 | }); | |
| 128 | if (values.length === 0) { | |
| 129 | continue; | |
| 130 | } | |
| 131 | ||
| 132 | const label = parameter.displayName?.trim() || toTitleCaseWords(id); | |
| 133 | const defaultValue = cursorSdkDefaultParameterValue(model, nativeId); | |
| 134 | const normalizedValues = new Set(values.map((entry) => entry.value.toLowerCase())); | |
| 135 | if (values.length === 2 && normalizedValues.has("true") && normalizedValues.has("false")) { | |
| 136 | if (defaultValue === "true" || defaultValue === "false") { | |
| 137 | optionDescriptors.push( | |
| 138 | buildBooleanOptionDescriptor({ | |
| 139 | id, | |
| 140 | label, | |
| 141 | currentValue: defaultValue === "true", | |
| 142 | }), | |
| 143 | ); | |
| 144 | } else { | |
| 145 | optionDescriptors.push(buildBooleanOptionDescriptor({ id, label })); | |
| 146 | } | |
| 147 | continue; | |
| 148 | } | |
| 149 | ||
| 150 | optionDescriptors.push( | |
| 151 | buildSelectOptionDescriptor({ | |
| 152 | id, | |
| 153 | label, | |
| 154 | options: values.map((entry) => ({ | |
| 155 | ...entry, | |
| 156 | ...(entry.value === defaultValue ? { isDefault: true } : {}), | |
| 157 | })), | |
| 158 | }), | |
| 159 | ); | |
| 160 | } | |
| 161 | ||
| 162 | return createModelCapabilities({ optionDescriptors }); | |
| 163 | } | |
| 164 | ||
| 165 | export function buildCursorDiscoveredModelsFromSdk( | |
| 166 | models: ReadonlyArray<SDKModel>, | |
| 167 | ): ReadonlyArray<ServerProviderModel> { | |
| 168 | const seen = new Set<string>(); | |
| 169 | return models.flatMap((model) => { | |
| 170 | const slug = model.id.trim(); | |
| 171 | const name = model.displayName.trim(); | |
| 172 | if (!slug || !name || seen.has(slug)) { | |
| 173 | return []; | |
| 174 | } | |
| 175 | seen.add(slug); | |
| 176 | return [ | |
| 177 | { | |
| 178 | slug, | |
| 179 | name, | |
| 180 | isCustom: false, | |
| 181 | capabilities: buildCursorCapabilitiesFromSdkModel(model), | |
| 182 | } satisfies ServerProviderModel, | |
| 183 | ]; | |
| 184 | }); | |
| 185 | } | |
| 186 | ||
| 187 | function cursorSdkAuth(user: SDKUser, type: "api-key" | "browser"): ServerProviderAuth { | |
| 188 | const email = user.userEmail?.trim(); | |
| 189 | const apiKeyName = user.apiKeyName.trim(); | |
| 190 | return { | |
| 191 | status: "authenticated", | |
| 192 | type, | |
| 193 | label: | |
| 194 | type === "browser" | |
| 195 | ? "Cursor account" | |
| 196 | : apiKeyName | |
| 197 | ? `Cursor API key (${apiKeyName})` | |
| 198 | : "Cursor API key", | |
| 199 | ...(email ? { email } : {}), | |
| 200 | }; | |
| 201 | } | |
| 202 | ||
| 203 | interface CursorProviderProbeResult { | |
| 204 | readonly version: string | null; | |
| 205 | readonly status: Exclude<ServerProviderState, "disabled">; | |
| 206 | readonly auth: ServerProviderAuth; | |
| 207 | readonly message?: string; | |
| 208 | } | |
| 209 | ||
| 210 | function joinProviderMessages(...messages: ReadonlyArray<string | undefined>): string | undefined { | |
| 211 | const parts: Array<string> = []; | |
| 212 | for (const message of messages) { | |
| 213 | const trimmed = message?.trim(); | |
| 214 | if (trimmed) { | |
| 215 | parts.push(trimmed); | |
| 216 | } | |
| 217 | } | |
| 218 | return parts.length > 0 ? parts.join(" ") : undefined; | |
| 219 | } | |
| 220 | ||
| 221 | export function buildCursorProviderSnapshot(input: { | |
| 222 | readonly checkedAt: string; | |
| 223 | readonly cursorSettings: CursorSettings; | |
| 224 | readonly parsed: CursorProviderProbeResult; | |
| 225 | readonly discoveredModels?: ReadonlyArray<ServerProviderModel>; | |
| 226 | readonly discoveryWarning?: string; | |
| 227 | }): ServerProviderDraft { | |
| 228 | const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); | |
| 229 | return buildServerProvider({ | |
| 230 | presentation: CURSOR_PRESENTATION, | |
| 231 | enabled: input.cursorSettings.enabled, | |
| 232 | checkedAt: input.checkedAt, | |
| 233 | models: providerModelsFromSettings( | |
| 234 | input.discoveredModels ?? [], | |
| 235 | input.cursorSettings.customModels, | |
| 236 | EMPTY_CAPABILITIES, | |
| 237 | ), | |
| 238 | probe: { | |
| 239 | installed: true, | |
| 240 | version: input.parsed.version, | |
| 241 | status: | |
| 242 | input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, | |
| 243 | auth: input.parsed.auth, | |
| 244 | ...(message ? { message } : {}), | |
| 245 | }, | |
| 246 | }); | |
| 247 | } | |
| 248 | ||
| 249 | export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( | |
| 250 | cursorSettings: CursorSettings, | |
| 251 | environment?: NodeJS.ProcessEnv, | |
| 252 | authenticationType: "api-key" | "browser" = "api-key", | |
| 253 | ): Effect.fn.Return<ServerProviderDraft, never, CursorSdkCatalog.CursorSdkCatalog> { | |
| 254 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 255 | const fallbackModels = getCursorFallbackModels(cursorSettings); | |
| 256 | ||
| 257 | if (!cursorSettings.enabled) { | |
| 258 | return buildServerProvider({ | |
| 259 | presentation: CURSOR_PRESENTATION, | |
| 260 | enabled: false, | |
| 261 | checkedAt, | |
| 262 | models: fallbackModels, | |
| 263 | probe: { | |
| 264 | installed: false, | |
| 265 | version: null, | |
| 266 | status: "warning", | |
| 267 | auth: { status: "unknown" }, | |
| 268 | message: "Cursor is disabled in T3 Code settings.", | |
| 269 | }, | |
| 270 | }); | |
| 271 | } | |
| 272 | ||
| 273 | const sdkApiKey = environment?.CURSOR_API_KEY?.trim(); | |
| 274 | if (!sdkApiKey) { | |
| 275 | return buildServerProvider({ | |
| 276 | presentation: CURSOR_PRESENTATION, | |
| 277 | enabled: cursorSettings.enabled, | |
| 278 | checkedAt, | |
| 279 | models: fallbackModels, | |
| 280 | probe: { | |
| 281 | installed: true, | |
| 282 | version: null, | |
| 283 | status: "error", | |
| 284 | auth: { status: "unauthenticated" }, | |
| 285 | message: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | |
| 286 | }, | |
| 287 | }); | |
| 288 | } | |
| 289 | ||
| 290 | const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; | |
| 291 | const catalogResult = yield* sdkCatalog | |
| 292 | .read(sdkApiKey) | |
| 293 | .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); | |
| 294 | ||
| 295 | if (Result.isFailure(catalogResult)) { | |
| 296 | yield* Effect.logWarning("Cursor SDK catalog probe failed", { | |
| 297 | cause: catalogResult.failure.cause, | |
| 298 | }); | |
| 299 | const authenticationFailure = catalogResult.failure.authenticationFailure; | |
| 300 | return buildServerProvider({ | |
| 301 | presentation: CURSOR_PRESENTATION, | |
| 302 | enabled: cursorSettings.enabled, | |
| 303 | checkedAt, | |
| 304 | models: fallbackModels, | |
| 305 | probe: { | |
| 306 | installed: true, | |
| 307 | version: null, | |
| 308 | status: "error", | |
| 309 | auth: { status: authenticationFailure ? "unauthenticated" : "unknown" }, | |
| 310 | message: authenticationFailure | |
| 311 | ? authenticationType === "browser" | |
| 312 | ? "Cursor sign-in expired or was rejected. Sign in again in provider settings." | |
| 313 | : "Cursor SDK authentication failed. Check CURSOR_API_KEY." | |
| 314 | : "Cursor SDK catalog request failed. Check server logs for details.", | |
| 315 | }, | |
| 316 | }); | |
| 317 | } | |
| 318 | ||
| 319 | if (Option.isNone(catalogResult.success)) { | |
| 320 | return buildServerProvider({ | |
| 321 | presentation: CURSOR_PRESENTATION, | |
| 322 | enabled: cursorSettings.enabled, | |
| 323 | checkedAt, | |
| 324 | models: fallbackModels, | |
| 325 | probe: { | |
| 326 | installed: true, | |
| 327 | version: null, | |
| 328 | status: "error", | |
| 329 | auth: { status: "unknown" }, | |
| 330 | message: `Cursor SDK catalog request timed out after ${CURSOR_SDK_CATALOG_TIMEOUT_MS}ms.`, | |
| 331 | }, | |
| 332 | }); | |
| 333 | } | |
| 334 | ||
| 335 | const snapshot = catalogResult.success.value; | |
| 336 | const discoveredModels = buildCursorDiscoveredModelsFromSdk(snapshot.models); | |
| 337 | return buildCursorProviderSnapshot({ | |
| 338 | checkedAt, | |
| 339 | cursorSettings, | |
| 340 | parsed: { | |
| 341 | version: null, | |
| 342 | status: "ready", | |
| 343 | auth: cursorSdkAuth(snapshot.user, authenticationType), | |
| 344 | }, | |
| 345 | discoveredModels, | |
| 346 | ...(discoveredModels.length === 0 | |
| 347 | ? { discoveryWarning: "Cursor SDK model discovery returned no built-in models." } | |
| 348 | : {}), | |
| 349 | }); | |
| 350 | }); |
packages/provider-cursor/src/server/textGeneration.test.ts created+416| ... | ... | @@ -0,0 +1,416 @@ |
| 1 | import type { RunResult } from "@cursor/sdk"; | |
| 2 | import { ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; | |
| 3 | import { CursorSettings } from "../settings.ts"; | |
| 4 | import { describe, expect, it } from "@effect/vitest"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Fiber from "effect/Fiber"; | |
| 8 | import * as TestClock from "effect/testing/TestClock"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | import { createModelSelection } from "@t3tools/shared/model"; | |
| 11 | import { beforeEach, vi } from "vite-plus/test"; | |
| 12 | ||
| 13 | import { makeCursorTextGeneration } from "./textGeneration.ts"; | |
| 14 | ||
| 15 | const cursorSdkMock = vi.hoisted(() => ({ | |
| 16 | create: vi.fn<(options: unknown) => Promise<unknown>>(), | |
| 17 | send: vi.fn<(prompt: string) => Promise<unknown>>(), | |
| 18 | close: vi.fn(), | |
| 19 | cancel: vi.fn(async () => {}), | |
| 20 | prompt: vi.fn<(prompt: string, options: unknown) => Promise<RunResult>>(async () => ({ | |
| 21 | id: "run-cursor-text-generation-test", | |
| 22 | status: "finished", | |
| 23 | result: | |
| 24 | '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}', | |
| 25 | })), | |
| 26 | })); | |
| 27 | ||
| 28 | vi.mock("./sdk.ts", () => ({ Agent: { create: cursorSdkMock.create } })); | |
| 29 | ||
| 30 | let hasCustomPolicy = false; | |
| 31 | const layerFs = FileSystem.layerNoop({ | |
| 32 | exists: () => Effect.succeed(hasCustomPolicy), | |
| 33 | makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"), | |
| 34 | }); | |
| 35 | ||
| 36 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); | |
| 37 | const cursorSettings = decodeCursorSettings({ enabled: true }); | |
| 38 | ||
| 39 | beforeEach(() => { | |
| 40 | hasCustomPolicy = false; | |
| 41 | cursorSdkMock.create.mockReset(); | |
| 42 | cursorSdkMock.send.mockReset(); | |
| 43 | cursorSdkMock.create.mockImplementation(async (options) => { | |
| 44 | cursorSdkMock.send.mockImplementation(async (prompt) => ({ | |
| 45 | status: "running", | |
| 46 | cancel: cursorSdkMock.cancel, | |
| 47 | wait: () => cursorSdkMock.prompt(prompt, options), | |
| 48 | })); | |
| 49 | return { | |
| 50 | close: cursorSdkMock.close, | |
| 51 | [Symbol.asyncDispose]: async () => { | |
| 52 | cursorSdkMock.close(); | |
| 53 | }, | |
| 54 | send: cursorSdkMock.send, | |
| 55 | }; | |
| 56 | }); | |
| 57 | cursorSdkMock.close.mockClear(); | |
| 58 | cursorSdkMock.cancel.mockClear(); | |
| 59 | cursorSdkMock.prompt.mockReset(); | |
| 60 | cursorSdkMock.prompt.mockResolvedValue({ | |
| 61 | id: "run-cursor-text-generation-test", | |
| 62 | status: "finished", | |
| 63 | result: | |
| 64 | '{"subject":"Add generated commit message","body":"- verify cursor sdk text generation"}', | |
| 65 | }); | |
| 66 | }); | |
| 67 | ||
| 68 | describe("CursorTextGeneration", () => { | |
| 69 | it.effect("resolves the browser credential for every request after an account change", () => | |
| 70 | Effect.gen(function* () { | |
| 71 | let apiKey = "first-browser-key"; | |
| 72 | const generation = yield* makeCursorTextGeneration( | |
| 73 | cursorSettings, | |
| 74 | {}, | |
| 75 | Effect.sync(() => apiKey), | |
| 76 | ); | |
| 77 | const input = { | |
| 78 | cwd: process.cwd(), | |
| 79 | branch: "feature/cursor", | |
| 80 | stagedSummary: "M file.ts", | |
| 81 | stagedPatch: "diff", | |
| 82 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "auto"), | |
| 83 | }; | |
| 84 | yield* generation.generateCommitMessage(input); | |
| 85 | expect(cursorSdkMock.create).toHaveBeenLastCalledWith( | |
| 86 | expect.objectContaining({ apiKey: "first-browser-key" }), | |
| 87 | ); | |
| 88 | apiKey = "second-browser-key"; | |
| 89 | yield* generation.generateCommitMessage(input); | |
| 90 | expect(cursorSdkMock.create).toHaveBeenLastCalledWith( | |
| 91 | expect.objectContaining({ apiKey: "second-browser-key" }), | |
| 92 | ); | |
| 93 | }).pipe(Effect.provide(layerFs)), | |
| 94 | ); | |
| 95 | ||
| 96 | it.effect("uses the Cursor SDK prompt API with model parameters and API key", () => | |
| 97 | Effect.gen(function* () { | |
| 98 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { | |
| 99 | CURSOR_API_KEY: "test-cursor-key", | |
| 100 | }); | |
| 101 | ||
| 102 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 103 | cwd: process.cwd(), | |
| 104 | branch: "feature/cursor-text-generation", | |
| 105 | stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", | |
| 106 | stagedPatch: | |
| 107 | "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts b/apps/server/src/textGeneration/CursorTextGeneration.ts", | |
| 108 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "gpt-5.4", [ | |
| 109 | { id: "thinking", value: "high" }, | |
| 110 | { id: "contextWindow", value: "1m" }, | |
| 111 | { id: "fastMode", value: true }, | |
| 112 | ]), | |
| 113 | }); | |
| 114 | ||
| 115 | expect(generated.subject).toBe("Add generated commit message"); | |
| 116 | expect(generated.body).toBe("- verify cursor sdk text generation"); | |
| 117 | ||
| 118 | expect(cursorSdkMock.prompt).toHaveBeenCalledTimes(1); | |
| 119 | const [prompt, options] = ( | |
| 120 | cursorSdkMock.prompt.mock.calls as unknown as Array<[string, unknown]> | |
| 121 | )[0]!; | |
| 122 | expect(prompt).toContain("Staged patch:"); | |
| 123 | expect(options).toEqual({ | |
| 124 | apiKey: "test-cursor-key", | |
| 125 | mode: "plan", | |
| 126 | model: { | |
| 127 | id: "gpt-5.4", | |
| 128 | params: [ | |
| 129 | { id: "thinking", value: "high" }, | |
| 130 | { id: "context", value: "1m" }, | |
| 131 | { id: "fast", value: "true" }, | |
| 132 | ], | |
| 133 | }, | |
| 134 | local: { | |
| 135 | cwd: "/isolated-text-generation", | |
| 136 | autoReview: false, | |
| 137 | sandboxOptions: { enabled: true }, | |
| 138 | settingSources: [], | |
| 139 | enableAgentRetries: true, | |
| 140 | }, | |
| 141 | }); | |
| 142 | }).pipe(Effect.provide(layerFs)), | |
| 143 | ); | |
| 144 | ||
| 145 | it.effect("continues in the temp directory when the SDK cannot sandbox", () => | |
| 146 | Effect.gen(function* () { | |
| 147 | cursorSdkMock.create.mockImplementationOnce(async () => { | |
| 148 | throw new Error( | |
| 149 | "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.", | |
| 150 | ); | |
| 151 | }); | |
| 152 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { | |
| 153 | CURSOR_API_KEY: "test-cursor-key", | |
| 154 | }); | |
| 155 | ||
| 156 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 157 | cwd: process.cwd(), | |
| 158 | branch: "feature/cursor-text-generation", | |
| 159 | stagedSummary: "M apps/server/src/textGeneration/CursorTextGeneration.ts", | |
| 160 | stagedPatch: "diff --git a/apps/server/src/textGeneration/CursorTextGeneration.ts", | |
| 161 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), | |
| 162 | }); | |
| 163 | ||
| 164 | expect(generated.subject).toBe("Add generated commit message"); | |
| 165 | expect(cursorSdkMock.create).toHaveBeenCalledTimes(2); | |
| 166 | expect(cursorSdkMock.create.mock.calls[0]?.[0]).toMatchObject({ | |
| 167 | local: { sandboxOptions: { enabled: true } }, | |
| 168 | }); | |
| 169 | expect(cursorSdkMock.create.mock.calls[1]?.[0]).toMatchObject({ | |
| 170 | local: { | |
| 171 | cwd: "/isolated-text-generation", | |
| 172 | autoReview: false, | |
| 173 | sandboxOptions: { enabled: false }, | |
| 174 | settingSources: [], | |
| 175 | enableAgentRetries: true, | |
| 176 | }, | |
| 177 | }); | |
| 178 | }).pipe(Effect.provide(layerFs)), | |
| 179 | ); | |
| 180 | ||
| 181 | it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () => | |
| 182 | Effect.gen(function* () { | |
| 183 | cursorSdkMock.create.mockImplementationOnce(async () => { | |
| 184 | throw new Error("Cursor SDK network down"); | |
| 185 | }); | |
| 186 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { | |
| 187 | CURSOR_API_KEY: "test-cursor-key", | |
| 188 | }); | |
| 189 | ||
| 190 | const error = yield* Effect.flip( | |
| 191 | textGeneration.generateCommitMessage({ | |
| 192 | cwd: process.cwd(), | |
| 193 | branch: "feature/cursor-text-generation", | |
| 194 | stagedSummary: "M README.md", | |
| 195 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 196 | modelSelection: createModelSelection(ProviderInstanceId.make("cursor"), "composer-2"), | |
| 197 | }), | |
| 198 | ); | |
| 199 | ||
| 200 | expect(error.detail).toBe("Cursor SDK text generation failed."); | |
| 201 | expect(cursorSdkMock.create).toHaveBeenCalledTimes(1); | |
| 202 | }).pipe(Effect.provide(layerFs)), | |
| 203 | ); | |
| 204 | ||
| 205 | it.effect("accepts json objects with extra assistant text around them", () => | |
| 206 | Effect.gen(function* () { | |
| 207 | cursorSdkMock.prompt.mockResolvedValueOnce({ | |
| 208 | id: "run-cursor-text-generation-test", | |
| 209 | status: "finished", | |
| 210 | result: | |
| 211 | 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', | |
| 212 | }); | |
| 213 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { | |
| 214 | CURSOR_API_KEY: "test-cursor-key", | |
| 215 | }); | |
| 216 | ||
| 217 | const generated = yield* textGeneration.generateCommitMessage({ | |
| 218 | cwd: process.cwd(), | |
| 219 | branch: "feature/cursor-noisy-json", | |
| 220 | stagedSummary: "M README.md", | |
| 221 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 222 | modelSelection: { | |
| 223 | instanceId: ProviderInstanceId.make("cursor"), | |
| 224 | model: "composer-2", | |
| 225 | }, | |
| 226 | }); | |
| 227 | ||
| 228 | expect(generated.subject).toBe("Update README dummy comment with attribution and date"); | |
| 229 | expect(generated.body).toBe(""); | |
| 230 | }).pipe(Effect.provide(layerFs)), | |
| 231 | ); | |
| 232 | ||
| 233 | it.effect("generates thread titles through Cursor SDK text generation", () => | |
| 234 | Effect.gen(function* () { | |
| 235 | cursorSdkMock.prompt.mockResolvedValueOnce({ | |
| 236 | id: "run-cursor-title-generation-test", | |
| 237 | status: "finished", | |
| 238 | result: '{"title":"\\"Trim reconnect spinner status after resume.\\""}', | |
| 239 | }); | |
| 240 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { | |
| 241 | CURSOR_API_KEY: "test-cursor-key", | |
| 242 | }); | |
| 243 | ||
| 244 | const generated = yield* textGeneration.generateThreadTitle({ | |
| 245 | cwd: process.cwd(), | |
| 246 | message: "Fix the reconnect spinner after a resumed session.", | |
| 247 | modelSelection: { | |
| 248 | instanceId: ProviderInstanceId.make("cursor"), | |
| 249 | model: "composer-2", | |
| 250 | }, | |
| 251 | }); | |
| 252 | ||
| 253 | expect(generated.title).toBe("Trim reconnect spinner status after resume."); | |
| 254 | }).pipe(Effect.provide(layerFs)), | |
| 255 | ); | |
| 256 | ||
| 257 | it.effect.each(["error", "cancelled"] as const)( | |
| 258 | "rejects a %s Cursor SDK run that includes valid title JSON", | |
| 259 | (status) => | |
| 260 | Effect.gen(function* () { | |
| 261 | const promptResult = { | |
| 262 | id: "run-cursor-partial-title-test", | |
| 263 | status, | |
| 264 | result: '{"title":"Partial title from a failed run."}', | |
| 265 | } satisfies RunResult; | |
| 266 | cursorSdkMock.prompt.mockResolvedValueOnce(promptResult); | |
| 267 | const generation = yield* makeCursorTextGeneration(cursorSettings, { | |
| 268 | CURSOR_API_KEY: "test-cursor-key", | |
| 269 | }); | |
| 270 | const failure = yield* Effect.flip( | |
| 271 | generation.generateThreadTitle({ | |
| 272 | cwd: process.cwd(), | |
| 273 | message: "Fix the reconnect spinner after a resumed session.", | |
| 274 | modelSelection: { | |
| 275 | instanceId: ProviderInstanceId.make("cursor"), | |
| 276 | model: "composer-2", | |
| 277 | }, | |
| 278 | }), | |
| 279 | ); | |
| 280 | expect(failure).toBeInstanceOf(TextGenerationError); | |
| 281 | expect(failure.operation).toBe("generateThreadTitle"); | |
| 282 | expect(failure.detail).toBe( | |
| 283 | status === "cancelled" | |
| 284 | ? "Cursor SDK request was cancelled." | |
| 285 | : "Cursor SDK request finished with an error.", | |
| 286 | ); | |
| 287 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); | |
| 288 | }).pipe(Effect.provide(layerFs)), | |
| 289 | ); | |
| 290 | ||
| 291 | it.effect("fails closed when ambient sandbox policy can expand write access", () => | |
| 292 | Effect.gen(function* () { | |
| 293 | hasCustomPolicy = true; | |
| 294 | const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" }); | |
| 295 | const failure = yield* Effect.flip( | |
| 296 | generation.generateThreadTitle({ | |
| 297 | cwd: "/real-workspace", | |
| 298 | message: "Title", | |
| 299 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, | |
| 300 | }), | |
| 301 | ); | |
| 302 | expect(failure.detail).toContain("custom ~/.cursor/sandbox.json"); | |
| 303 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); | |
| 304 | }).pipe(Effect.provide(layerFs)), | |
| 305 | ); | |
| 306 | ||
| 307 | it.effect("cancels the native run when text generation times out", () => | |
| 308 | Effect.gen(function* () { | |
| 309 | let started!: () => void; | |
| 310 | const called = new Promise<void>((resolve) => { | |
| 311 | started = resolve; | |
| 312 | }); | |
| 313 | cursorSdkMock.prompt.mockImplementationOnce(() => { | |
| 314 | started(); | |
| 315 | return new Promise(() => {}); | |
| 316 | }); | |
| 317 | const generation = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "key" }); | |
| 318 | const result = yield* generation | |
| 319 | .generateThreadTitle({ | |
| 320 | cwd: "/real-workspace", | |
| 321 | message: "Title", | |
| 322 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, | |
| 323 | }) | |
| 324 | .pipe(Effect.flip, Effect.forkScoped); | |
| 325 | yield* Effect.promise(() => called); | |
| 326 | yield* TestClock.adjust("180 seconds"); | |
| 327 | expect((yield* Fiber.join(result)).detail).toContain("timed out"); | |
| 328 | expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); | |
| 329 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); | |
| 330 | }).pipe(Effect.provide(layerFs), Effect.scoped), | |
| 331 | ); | |
| 332 | ||
| 333 | it.effect.each(["create", "send"] as const)( | |
| 334 | "times out pending %s and releases its late SDK resource", | |
| 335 | (phase) => | |
| 336 | Effect.gen(function* () { | |
| 337 | let started!: () => void; | |
| 338 | const called = new Promise<void>((resolve) => { | |
| 339 | started = resolve; | |
| 340 | }); | |
| 341 | let resolveLate!: (resource: unknown) => void; | |
| 342 | const pending = new Promise<unknown>((resolve) => { | |
| 343 | resolveLate = resolve; | |
| 344 | }); | |
| 345 | const wait = vi.fn(); | |
| 346 | const agent = { | |
| 347 | close: cursorSdkMock.close, | |
| 348 | [Symbol.asyncDispose]: async () => { | |
| 349 | cursorSdkMock.close(); | |
| 350 | }, | |
| 351 | send: async () => { | |
| 352 | started(); | |
| 353 | return pending; | |
| 354 | }, | |
| 355 | }; | |
| 356 | cursorSdkMock.create.mockImplementation(async () => { | |
| 357 | if (phase === "create") { | |
| 358 | started(); | |
| 359 | return pending; | |
| 360 | } | |
| 361 | return agent; | |
| 362 | }); | |
| 363 | const generation = yield* makeCursorTextGeneration(cursorSettings, { | |
| 364 | CURSOR_API_KEY: "key", | |
| 365 | }); | |
| 366 | const result = yield* generation | |
| 367 | .generateThreadTitle({ | |
| 368 | cwd: "/real-workspace", | |
| 369 | message: "Title", | |
| 370 | modelSelection: { instanceId: ProviderInstanceId.make("cursor"), model: "composer-2" }, | |
| 371 | }) | |
| 372 | .pipe(Effect.flip, Effect.forkScoped); | |
| 373 | yield* Effect.promise(() => called); | |
| 374 | yield* TestClock.adjust("180 seconds"); | |
| 375 | expect((yield* Fiber.join(result)).detail).toContain("timed out"); | |
| 376 | let cleanup!: () => void; | |
| 377 | const cleaned = new Promise<void>((resolve) => { | |
| 378 | cleanup = resolve; | |
| 379 | }); | |
| 380 | if (phase === "create") cursorSdkMock.close.mockImplementationOnce(cleanup); | |
| 381 | else | |
| 382 | cursorSdkMock.cancel.mockImplementationOnce(async () => { | |
| 383 | cleanup(); | |
| 384 | }); | |
| 385 | resolveLate( | |
| 386 | phase === "create" ? agent : { status: "running", cancel: cursorSdkMock.cancel, wait }, | |
| 387 | ); | |
| 388 | yield* Effect.promise(() => cleaned); | |
| 389 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); | |
| 390 | expect(wait).not.toHaveBeenCalled(); | |
| 391 | if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); | |
| 392 | }).pipe(Effect.provide(layerFs), Effect.scoped), | |
| 393 | ); | |
| 394 | ||
| 395 | it.effect("requires CURSOR_API_KEY before calling the SDK", () => | |
| 396 | Effect.gen(function* () { | |
| 397 | const textGeneration = yield* makeCursorTextGeneration(cursorSettings, {}); | |
| 398 | ||
| 399 | const error = yield* Effect.flip( | |
| 400 | textGeneration.generateCommitMessage({ | |
| 401 | cwd: process.cwd(), | |
| 402 | branch: "feature/cursor-api-key", | |
| 403 | stagedSummary: "M README.md", | |
| 404 | stagedPatch: "diff --git a/README.md b/README.md", | |
| 405 | modelSelection: { | |
| 406 | instanceId: ProviderInstanceId.make("cursor"), | |
| 407 | model: "composer-2", | |
| 408 | }, | |
| 409 | }), | |
| 410 | ); | |
| 411 | ||
| 412 | expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings."); | |
| 413 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); | |
| 414 | }).pipe(Effect.provide(layerFs)), | |
| 415 | ); | |
| 416 | }); |
packages/provider-cursor/src/server/textGeneration.ts created+201| ... | ... | @@ -0,0 +1,201 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | import * as FileSystem from "effect/FileSystem"; | |
| 3 | ||
| 4 | import type { AgentOptions, RunResult } from "@cursor/sdk"; | |
| 5 | import { Agent } from "./sdk.ts"; | |
| 6 | import * as Effect from "effect/Effect"; | |
| 7 | import * as Option from "effect/Option"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | ||
| 10 | import { type ProviderSetupError, TextGenerationError } from "@t3tools/contracts"; | |
| 11 | import type { CursorSettings } from "../settings.ts"; | |
| 12 | ||
| 13 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | |
| 14 | import { cursorSdkModelSelection } from "./sdkModel.ts"; | |
| 15 | import type { CursorAuth } from "./auth.ts"; | |
| 16 | ||
| 17 | const CURSOR_TIMEOUT_MS = 180_000; | |
| 18 | ||
| 19 | const isTextGenerationError = Schema.is(TextGenerationError); | |
| 20 | ||
| 21 | function cursorSdkResultDetail(result: RunResult): string { | |
| 22 | switch (result.status) { | |
| 23 | case "cancelled": | |
| 24 | return "Cursor SDK request was cancelled."; | |
| 25 | case "error": | |
| 26 | return "Cursor SDK request finished with an error."; | |
| 27 | case "finished": | |
| 28 | return "Cursor SDK returned empty output."; | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | /** | |
| 33 | * The SDK throws this when `sandboxOptions.enabled` is set and local sandboxing | |
| 34 | * is unavailable. That happens on hosts that cannot launch `cursorsandbox`, and | |
| 35 | * also after an unsandboxed run caches "unsupported" for the process. | |
| 36 | */ | |
| 37 | function cursorSandboxUnsupported(cause: unknown): boolean { | |
| 38 | const seen = new Set<object>(); | |
| 39 | let current = cause; | |
| 40 | while (typeof current === "object" && current !== null && !seen.has(current)) { | |
| 41 | seen.add(current); | |
| 42 | if (current instanceof Error && current.message.includes("sandboxing is not supported")) { | |
| 43 | return true; | |
| 44 | } | |
| 45 | current = Reflect.get(current, "cause"); | |
| 46 | } | |
| 47 | return false; | |
| 48 | } | |
| 49 | ||
| 50 | /** | |
| 51 | * Build a Cursor text-generation closure bound to a specific `CursorSettings` | |
| 52 | * payload. See `makeCodexAdapter` for the overall per-instance rationale. | |
| 53 | */ | |
| 54 | export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(function* ( | |
| 55 | cursorSettings: CursorSettings, | |
| 56 | environment?: NodeJS.ProcessEnv, | |
| 57 | resolveApiKey?: Effect.Effect<string, ProviderSetupError>, | |
| 58 | withAccess?: CursorAuth["withAccess"], | |
| 59 | ) { | |
| 60 | const fs = yield* FileSystem.FileSystem; | |
| 61 | const resolvedEnvironment = environment ?? process.env; | |
| 62 | ||
| 63 | const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) => | |
| 64 | Effect.gen(function* () { | |
| 65 | if (!cursorSettings.enabled) { | |
| 66 | return yield* new TextGenerationError({ | |
| 67 | operation, | |
| 68 | detail: "Cursor is disabled in T3 Code settings.", | |
| 69 | }); | |
| 70 | } | |
| 71 | ||
| 72 | const apiKey = resolveApiKey | |
| 73 | ? yield* resolveApiKey | |
| 74 | : resolvedEnvironment.CURSOR_API_KEY?.trim(); | |
| 75 | if (!apiKey) { | |
| 76 | return yield* new TextGenerationError({ | |
| 77 | operation, | |
| 78 | detail: "Sign in with Cursor or add CURSOR_API_KEY in provider settings.", | |
| 79 | }); | |
| 80 | } | |
| 81 | ||
| 82 | return apiKey; | |
| 83 | }); | |
| 84 | ||
| 85 | // Ignores `cwd`: the agent runs in an empty temp directory, away from the project. | |
| 86 | const runCursorJson: TextGenerationOperations.Runner = (input) => { | |
| 87 | const { operation, prompt, modelSelection } = input; | |
| 88 | return Effect.gen(function* () { | |
| 89 | const apiKey = yield* resolveCursorApiKey(operation); | |
| 90 | // The SDK loads sandbox.json independently of settingSources and lets it | |
| 91 | // expand the writable paths. Its public API cannot override that policy. | |
| 92 | if (yield* fs.exists(`${NodeOS.homedir()}/.cursor/sandbox.json`)) { | |
| 93 | return yield* new TextGenerationError({ | |
| 94 | operation, | |
| 95 | detail: | |
| 96 | "Cursor text generation cannot enforce workspace isolation with a custom ~/.cursor/sandbox.json. Use another text-generation provider.", | |
| 97 | }); | |
| 98 | } | |
| 99 | const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" }); | |
| 100 | const agentOptions = { | |
| 101 | apiKey, | |
| 102 | mode: "plan", | |
| 103 | model: cursorSdkModelSelection(modelSelection), | |
| 104 | local: { | |
| 105 | cwd, | |
| 106 | autoReview: false, | |
| 107 | sandboxOptions: { enabled: true }, | |
| 108 | settingSources: [], | |
| 109 | enableAgentRetries: true, | |
| 110 | }, | |
| 111 | } satisfies AgentOptions; | |
| 112 | const createCursorAgent = (sandboxEnabled: boolean) => | |
| 113 | Effect.tryPromise((signal) => | |
| 114 | Agent.create( | |
| 115 | sandboxEnabled | |
| 116 | ? agentOptions | |
| 117 | : { | |
| 118 | ...agentOptions, | |
| 119 | local: { | |
| 120 | ...agentOptions.local, | |
| 121 | sandboxOptions: { enabled: false }, | |
| 122 | }, | |
| 123 | }, | |
| 124 | ).then((agent) => { | |
| 125 | if (signal.aborted) agent.close(); | |
| 126 | return agent; | |
| 127 | }), | |
| 128 | ); | |
| 129 | ||
| 130 | const request = Effect.gen(function* () { | |
| 131 | // Prefer the sandbox. When the SDK refuses it, the empty temp directory | |
| 132 | // and empty setting sources still keep this run off the user's project. | |
| 133 | const agent = yield* Effect.acquireRelease( | |
| 134 | createCursorAgent(true).pipe( | |
| 135 | Effect.catchIf(cursorSandboxUnsupported, () => createCursorAgent(false)), | |
| 136 | ), | |
| 137 | (agent) => | |
| 138 | Effect.tryPromise(() => agent[Symbol.asyncDispose]()).pipe( | |
| 139 | Effect.timeout("5 seconds"), | |
| 140 | Effect.ignore({ log: true }), | |
| 141 | ), | |
| 142 | { interruptible: true }, | |
| 143 | ); | |
| 144 | const run = yield* Effect.tryPromise((signal) => | |
| 145 | agent.send(prompt).then((run) => { | |
| 146 | if (signal.aborted) void run.cancel().catch(() => undefined); | |
| 147 | return run; | |
| 148 | }), | |
| 149 | ); | |
| 150 | yield* Effect.addFinalizer(() => | |
| 151 | run.status === "running" | |
| 152 | ? Effect.tryPromise(() => run.cancel()).pipe( | |
| 153 | Effect.timeout("5 seconds"), | |
| 154 | Effect.ignore({ log: true }), | |
| 155 | ) | |
| 156 | : Effect.void, | |
| 157 | ); | |
| 158 | return yield* Effect.tryPromise(() => run.wait()); | |
| 159 | }).pipe(Effect.scoped); | |
| 160 | const promptResult = yield* request.pipe( | |
| 161 | Effect.timeoutOption(CURSOR_TIMEOUT_MS), | |
| 162 | Effect.flatMap( | |
| 163 | Option.match({ | |
| 164 | onNone: () => | |
| 165 | Effect.fail( | |
| 166 | new TextGenerationError({ | |
| 167 | operation, | |
| 168 | detail: "Cursor SDK request timed out.", | |
| 169 | }), | |
| 170 | ), | |
| 171 | onSome: (value) => Effect.succeed(value), | |
| 172 | }), | |
| 173 | ), | |
| 174 | ); | |
| 175 | ||
| 176 | const rawResult = promptResult.result?.trim() ?? ""; | |
| 177 | if (promptResult.status !== "finished" || !rawResult) { | |
| 178 | return yield* new TextGenerationError({ | |
| 179 | operation, | |
| 180 | detail: cursorSdkResultDetail(promptResult), | |
| 181 | }); | |
| 182 | } | |
| 183 | ||
| 184 | return yield* TextGenerationOperations.decodeJsonReply(input, "Cursor SDK", rawResult); | |
| 185 | }).pipe( | |
| 186 | (effect) => (withAccess ? withAccess(effect) : effect), | |
| 187 | Effect.scoped, | |
| 188 | Effect.mapError((cause) => | |
| 189 | isTextGenerationError(cause) | |
| 190 | ? cause | |
| 191 | : new TextGenerationError({ | |
| 192 | operation, | |
| 193 | detail: "Cursor SDK text generation failed.", | |
| 194 | cause, | |
| 195 | }), | |
| 196 | ), | |
| 197 | ); | |
| 198 | }; | |
| 199 | ||
| 200 | return TextGenerationOperations.fromRunner("CursorTextGeneration", runCursorJson); | |
| 201 | }); |
packages/provider-cursor/src/server/transportFailure.test.ts created+60| ... | ... | @@ -0,0 +1,60 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { CursorTransportFailure } from "./transportFailure.ts"; | |
| 4 | ||
| 5 | const diagnostic = "Error: RetriableError: WritableIterable is closed"; | |
| 6 | ||
| 7 | function failureFor(chunks: string[]) { | |
| 8 | const reply = new CursorTransportFailure(); | |
| 9 | for (const chunk of chunks) reply.push(chunk); | |
| 10 | return reply.failure; | |
| 11 | } | |
| 12 | ||
| 13 | describe("CursorTransportFailure", () => { | |
| 14 | it.each([ | |
| 15 | diagnostic, | |
| 16 | "Error: ConnectError: [unavailable] transport closed", | |
| 17 | "Error: ConnectError: [aborted] aborted", | |
| 18 | "Error: ConnectError: [deadline_exceeded] timed out", | |
| 19 | "Something went wrong communicating with the server. Please try again.", | |
| 20 | ])("recognizes a terminal diagnostic: %s", (message) => { | |
| 21 | expect(failureFor([message])).toBe(message); | |
| 22 | expect(failureFor([...message])).toBe(message); | |
| 23 | }); | |
| 24 | ||
| 25 | it("recognizes a standalone failure with a stack trace", () => { | |
| 26 | expect(failureFor(["\n", diagnostic, "\n at send (cli.js:1:2)\n\n"])).toBe(diagnostic); | |
| 27 | }); | |
| 28 | ||
| 29 | it.each([ | |
| 30 | "The error was " + diagnostic, | |
| 31 | "This is an example of a transport error:\n" + diagnostic, | |
| 32 | "I inspected the files.\n" + diagnostic, | |
| 33 | "> " + diagnostic, | |
| 34 | " " + diagnostic, | |
| 35 | "```text\n" + diagnostic + "\n```", | |
| 36 | "~~~\n" + diagnostic + "\n~~~", | |
| 37 | diagnostic + "\nThis is an example of a transport error.", | |
| 38 | "Error: ConnectError: [unauthenticated] sign in", | |
| 39 | "Error: ConnectError: [permission_denied] subscription required", | |
| 40 | "Error: HTTP 500 from the application being debugged", | |
| 41 | "Error: RetriableError: [internal] Failed to run step, exceeded max retries", | |
| 42 | "Error: RetriableError: [internal] Failed to run step, exceeded max retries\n at step (cli.js:1:2)", | |
| 43 | ])("preserves prose, code and non-transport errors: %s", (message) => { | |
| 44 | expect(failureFor([...message])).toBeUndefined(); | |
| 45 | }); | |
| 46 | ||
| 47 | it("tracks fences across long lines without retaining the answer", () => { | |
| 48 | expect(failureFor(["```\n", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); | |
| 49 | expect(failureFor(["```", "x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); | |
| 50 | expect(failureFor(["x".repeat(100_000), "\n", diagnostic])).toBeUndefined(); | |
| 51 | }); | |
| 52 | ||
| 53 | it("does not retain a previous failure after new output", () => { | |
| 54 | const reply = new CursorTransportFailure(); | |
| 55 | reply.push(diagnostic); | |
| 56 | expect(reply.failure).toBe(diagnostic); | |
| 57 | reply.push("\nRecovered successfully."); | |
| 58 | expect(reply.failure).toBeUndefined(); | |
| 59 | }); | |
| 60 | }); |
packages/provider-cursor/src/server/transportFailure.ts created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | const maxLineLength = 4096; | |
| 2 | // Cursor also uses RetriableError for agent-loop failures; preserve those diagnostics. | |
| 3 | const transportError = | |
| 4 | /^Error: (?:RetriableError: (?!\[internal\]).+|ConnectError: \[(?:unavailable|aborted|deadline_exceeded)\].*)$/; | |
| 5 | const serverError = "Something went wrong communicating with the server. Please try again."; | |
| 6 | ||
| 7 | interface ReplyState { | |
| 8 | disqualified: boolean; | |
| 9 | failure: string | undefined; | |
| 10 | } | |
| 11 | ||
| 12 | function consumeLine(state: ReplyState, line: string) { | |
| 13 | if (state.disqualified) return; | |
| 14 | const text = line.trimEnd(); | |
| 15 | if (transportError.test(text) || text === serverError) { | |
| 16 | state.failure = text; | |
| 17 | } else if (text.trim() !== "" && !(state.failure && /^\s+at\s/.test(text))) { | |
| 18 | // An explanation or code sample can quote the same diagnostic. Only | |
| 19 | // classify an assistant item consisting entirely of a transport dump. | |
| 20 | state.disqualified = true; | |
| 21 | state.failure = undefined; | |
| 22 | } | |
| 23 | } | |
| 24 | ||
| 25 | /** Tracks a standalone Cursor diagnostic without retaining an entire streamed answer. */ | |
| 26 | export class CursorTransportFailure { | |
| 27 | private state: ReplyState = { disqualified: false, failure: undefined }; | |
| 28 | private line = ""; | |
| 29 | ||
| 30 | push(text: string) { | |
| 31 | for (const [index, part] of text.split("\n").entries()) { | |
| 32 | if (this.state.disqualified) return; | |
| 33 | if (index > 0) { | |
| 34 | consumeLine(this.state, this.line); | |
| 35 | this.line = ""; | |
| 36 | } | |
| 37 | if (this.line.length + part.length > maxLineLength) { | |
| 38 | this.state.disqualified = true; | |
| 39 | this.state.failure = undefined; | |
| 40 | this.line = ""; | |
| 41 | return; | |
| 42 | } | |
| 43 | this.line += part; | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | get failure() { | |
| 48 | const state = { ...this.state }; | |
| 49 | consumeLine(state, this.line); | |
| 50 | return state.failure; | |
| 51 | } | |
| 52 | } |
packages/provider-cursor/src/server/usageLimits.test.ts created+264| ... | ... | @@ -0,0 +1,264 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { NodeServices } from "@effect/platform-node"; | |
| 3 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as FileSystem from "effect/FileSystem"; | |
| 6 | import * as Path from "effect/Path"; | |
| 7 | import { HttpClient, HttpClientResponse } from "effect/http"; | |
| 8 | import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; | |
| 9 | ||
| 10 | const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) => | |
| 11 | effect.pipe(Effect.provide(NodeServices.layer)); | |
| 12 | ||
| 13 | describe("Cursor usage limits", () => { | |
| 14 | const checkedAt = "2026-09-16T00:00:00.000Z"; | |
| 15 | ||
| 16 | it("uses the advertised percentages and billing-cycle reset", () => { | |
| 17 | const limits = cursorUsageResponseToLimits( | |
| 18 | { | |
| 19 | billingCycleEnd: "1789876386000", | |
| 20 | planUsage: { totalPercentUsed: 72.4, autoPercentUsed: 69.5, apiPercentUsed: 100 }, | |
| 21 | }, | |
| 22 | checkedAt, | |
| 23 | ); | |
| 24 | expect(limits.windows).toEqual( | |
| 25 | expect.arrayContaining([ | |
| 26 | { | |
| 27 | id: "totalPercentUsed", | |
| 28 | kind: "monthly", | |
| 29 | label: "Overall", | |
| 30 | usedPercent: 72.4, | |
| 31 | resetsAt: "2026-09-20T03:53:06.000Z", | |
| 32 | }, | |
| 33 | { | |
| 34 | id: "autoPercentUsed", | |
| 35 | kind: "monthly", | |
| 36 | label: "Cursor Models", | |
| 37 | usedPercent: 69.5, | |
| 38 | resetsAt: "2026-09-20T03:53:06.000Z", | |
| 39 | }, | |
| 40 | { | |
| 41 | id: "apiPercentUsed", | |
| 42 | kind: "monthly", | |
| 43 | label: "Other Models", | |
| 44 | usedPercent: 100, | |
| 45 | resetsAt: "2026-09-20T03:53:06.000Z", | |
| 46 | }, | |
| 47 | ]), | |
| 48 | ); | |
| 49 | }); | |
| 50 | ||
| 51 | it("does not invent unused allowance for absent buckets", () => { | |
| 52 | expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( | |
| 53 | "unsupported", | |
| 54 | ); | |
| 55 | expect( | |
| 56 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 0 } }, checkedAt).windows, | |
| 57 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 0 }]); | |
| 58 | expect( | |
| 59 | cursorUsageResponseToLimits({ planUsage: { totalPercentUsed: 150 } }, checkedAt).windows, | |
| 60 | ).toEqual([{ id: "totalPercentUsed", kind: "monthly", label: "Overall", usedPercent: 100 }]); | |
| 61 | }); | |
| 62 | ||
| 63 | it.effect("reads the instance's credentials and endpoint even when usage enabled is false", () => | |
| 64 | Effect.gen(function* () { | |
| 65 | yield* withNodeServices( | |
| 66 | Effect.gen(function* () { | |
| 67 | const fs = yield* FileSystem.FileSystem; | |
| 68 | const path = yield* Path.Path; | |
| 69 | const directory = yield* fs.makeTempDirectoryScoped(); | |
| 70 | yield* fs.makeDirectory(path.join(directory, "cursor")); | |
| 71 | yield* fs.writeFileString( | |
| 72 | path.join(directory, "cursor", "auth.json"), | |
| 73 | '{"accessToken":"instance-token"}', | |
| 74 | ); | |
| 75 | const client = HttpClient.make((request) => { | |
| 76 | expect(request.url).toBe( | |
| 77 | "https://cursor.example/aiserver.v1.DashboardService/GetCurrentPeriodUsage", | |
| 78 | ); | |
| 79 | expect(request.method).toBe("POST"); | |
| 80 | expect(request.headers.authorization).toBe("Bearer instance-token"); | |
| 81 | expect(request.headers["connect-protocol-version"]).toBe("1"); | |
| 82 | return Effect.succeed( | |
| 83 | HttpClientResponse.fromWeb( | |
| 84 | request, | |
| 85 | Response.json({ enabled: false, planUsage: { totalPercentUsed: 42 } }), | |
| 86 | ), | |
| 87 | ); | |
| 88 | }); | |
| 89 | yield* fs.makeDirectory(path.join(directory, ".cursor")); | |
| 90 | yield* fs.writeFileString( | |
| 91 | path.join(directory, ".cursor", "auth.json"), | |
| 92 | '{"accessToken":"instance-token"}', | |
| 93 | ); | |
| 94 | for (const platform of ["linux", "darwin"] as const) { | |
| 95 | const limits = yield* readCursorUsageLimits( | |
| 96 | { apiEndpoint: "https://cursor.example/" }, | |
| 97 | { XDG_CONFIG_HOME: directory, HOME: directory, AGENT_CLI_CREDENTIAL_STORE: "file" }, | |
| 98 | ).pipe( | |
| 99 | Effect.provideService(HostProcessPlatform, platform), | |
| 100 | Effect.provideService(HttpClient.HttpClient, client), | |
| 101 | ); | |
| 102 | expect(limits.windows[0]?.usedPercent).toBe(42); | |
| 103 | } | |
| 104 | }).pipe(Effect.scoped), | |
| 105 | ); | |
| 106 | }), | |
| 107 | ); | |
| 108 | ||
| 109 | it.effect( | |
| 110 | "never reads stale files for keychain or memory logins, but accepts an explicit auth token", | |
| 111 | () => | |
| 112 | Effect.gen(function* () { | |
| 113 | for (const platform of ["linux", "darwin"] as const) { | |
| 114 | for (const token of [undefined, "explicit-token"]) { | |
| 115 | const limits = yield* withNodeServices( | |
| 116 | readCursorUsageLimits( | |
| 117 | { apiEndpoint: "" }, | |
| 118 | { | |
| 119 | AGENT_CLI_CREDENTIAL_STORE: platform === "linux" ? "memory" : "default", | |
| 120 | ...(token ? { CURSOR_AUTH_TOKEN: token } : {}), | |
| 121 | }, | |
| 122 | false, | |
| 123 | async () => { | |
| 124 | throw new Error("must not read Keychain before opt-in"); | |
| 125 | }, | |
| 126 | ).pipe( | |
| 127 | Effect.provideService(HostProcessPlatform, platform), | |
| 128 | Effect.provideService( | |
| 129 | FileSystem.FileSystem, | |
| 130 | FileSystem.makeNoop({ | |
| 131 | readFileString: () => Effect.die("must not read an unrelated credential file"), | |
| 132 | }), | |
| 133 | ), | |
| 134 | Effect.provideService( | |
| 135 | HttpClient.HttpClient, | |
| 136 | HttpClient.make((request) => { | |
| 137 | expect(token).toBe("explicit-token"); | |
| 138 | expect(request.headers.authorization).toBe("Bearer explicit-token"); | |
| 139 | return Effect.succeed( | |
| 140 | HttpClientResponse.fromWeb( | |
| 141 | request, | |
| 142 | Response.json({ planUsage: { totalPercentUsed: 10 } }), | |
| 143 | ), | |
| 144 | ); | |
| 145 | }), | |
| 146 | ), | |
| 147 | ), | |
| 148 | ); | |
| 149 | if (token) expect(limits.windows[0]?.usedPercent).toBe(10); | |
| 150 | else expect(limits.unavailable?.reason).toBe("unsupported"); | |
| 151 | } | |
| 152 | } | |
| 153 | }), | |
| 154 | ); | |
| 155 | ||
| 156 | it.effect("reports failed requests without exposing credentials or response bodies", () => | |
| 157 | Effect.gen(function* () { | |
| 158 | const limits = yield* withNodeServices( | |
| 159 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_AUTH_TOKEN: "private-token" }).pipe( | |
| 160 | Effect.provideService( | |
| 161 | HttpClient.HttpClient, | |
| 162 | HttpClient.make((request) => | |
| 163 | Effect.succeed( | |
| 164 | HttpClientResponse.fromWeb( | |
| 165 | request, | |
| 166 | new Response("private response", { status: 401 }), | |
| 167 | ), | |
| 168 | ), | |
| 169 | ), | |
| 170 | ), | |
| 171 | ), | |
| 172 | ); | |
| 173 | expect(limits.unavailable).toEqual({ | |
| 174 | reason: "probeFailed", | |
| 175 | message: "Cursor could not read usage limits.", | |
| 176 | }); | |
| 177 | }), | |
| 178 | ); | |
| 179 | ||
| 180 | it.effect("does not use a stored login for an explicit API key", () => | |
| 181 | Effect.gen(function* () { | |
| 182 | const limits = yield* withNodeServices( | |
| 183 | readCursorUsageLimits({ apiEndpoint: "" }, { CURSOR_API_KEY: "different-account" }).pipe( | |
| 184 | Effect.provideService( | |
| 185 | HttpClient.HttpClient, | |
| 186 | HttpClient.make(() => Effect.die("must not request usage")), | |
| 187 | ), | |
| 188 | ), | |
| 189 | ); | |
| 190 | expect(limits.unavailable?.reason).toBe("unsupported"); | |
| 191 | }), | |
| 192 | ); | |
| 193 | ||
| 194 | it.effect("reads the default macOS Cursor login from Keychain for limits", () => | |
| 195 | Effect.gen(function* () { | |
| 196 | const limits = yield* withNodeServices( | |
| 197 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => "keychain-token").pipe( | |
| 198 | Effect.provideService(HostProcessPlatform, "darwin"), | |
| 199 | Effect.provideService( | |
| 200 | FileSystem.FileSystem, | |
| 201 | FileSystem.makeNoop({ | |
| 202 | readFileString: () => Effect.die("must not read a stale credential file"), | |
| 203 | }), | |
| 204 | ), | |
| 205 | Effect.provideService( | |
| 206 | HttpClient.HttpClient, | |
| 207 | HttpClient.make((request) => { | |
| 208 | expect(request.headers.authorization).toBe("Bearer keychain-token"); | |
| 209 | return Effect.succeed( | |
| 210 | HttpClientResponse.fromWeb( | |
| 211 | request, | |
| 212 | Response.json({ planUsage: { totalPercentUsed: 42 } }), | |
| 213 | ), | |
| 214 | ); | |
| 215 | }), | |
| 216 | ), | |
| 217 | ), | |
| 218 | ); | |
| 219 | expect(limits.windows[0]?.usedPercent).toBe(42); | |
| 220 | }), | |
| 221 | ); | |
| 222 | ||
| 223 | it.effect("reports a Keychain initialization failure without failing the provider refresh", () => | |
| 224 | Effect.gen(function* () { | |
| 225 | const limits = yield* withNodeServices( | |
| 226 | readCursorUsageLimits({ apiEndpoint: "" }, {}, true, async () => { | |
| 227 | throw new Error("Keychain initialization failed"); | |
| 228 | }).pipe( | |
| 229 | Effect.provideService(HostProcessPlatform, "darwin"), | |
| 230 | Effect.provideService( | |
| 231 | HttpClient.HttpClient, | |
| 232 | HttpClient.make(() => Effect.die("must not request limits without a login")), | |
| 233 | ), | |
| 234 | ), | |
| 235 | ); | |
| 236 | expect(limits.unavailable?.reason).toBe("probeFailed"); | |
| 237 | }), | |
| 238 | ); | |
| 239 | ||
| 240 | it.effect("does not read Keychain or send its token to a custom endpoint", () => | |
| 241 | Effect.gen(function* () { | |
| 242 | for (const [apiEndpoint, environment] of [ | |
| 243 | ["http://localhost:3000", {}], | |
| 244 | ["", { CURSOR_API_ENDPOINT: "http://localhost:3000" }], | |
| 245 | ["https://cursor-proxy.example", {}], | |
| 246 | ["", { CURSOR_API_ENDPOINT: "https://cursor-proxy.example" }], | |
| 247 | ] as const) { | |
| 248 | const limits = yield* withNodeServices( | |
| 249 | readCursorUsageLimits({ apiEndpoint }, environment, true, async () => { | |
| 250 | throw new Error("must not read Keychain for a custom endpoint"); | |
| 251 | }).pipe( | |
| 252 | Effect.provideService(HostProcessPlatform, "darwin"), | |
| 253 | Effect.provideService( | |
| 254 | HttpClient.HttpClient, | |
| 255 | HttpClient.make(() => Effect.die("must not send a Keychain credential to a proxy")), | |
| 256 | ), | |
| 257 | ), | |
| 258 | ); | |
| 259 | expect(limits.unavailable?.reason).toBe("unsupported"); | |
| 260 | expect(limits.unavailable?.message).toContain("default Cursor endpoint"); | |
| 261 | } | |
| 262 | }), | |
| 263 | ); | |
| 264 | }); |
packages/provider-cursor/src/server/usageLimits.ts created+152| ... | ... | @@ -0,0 +1,152 @@ |
| 1 | import * as NodeOS from "node:os"; | |
| 2 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | |
| 3 | import type { CursorSettings } from "../settings.ts"; | |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | import { CURSOR_USAGE_WINDOWS } from "@t3tools/shared/usageLimits"; | |
| 6 | import * as DateTime from "effect/DateTime"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Option from "effect/Option"; | |
| 10 | import * as Path from "effect/Path"; | |
| 11 | import * as Schema from "effect/Schema"; | |
| 12 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | |
| 13 | import { | |
| 14 | clampPercent, | |
| 15 | makeUnavailableUsageLimits, | |
| 16 | makeUsageLimits, | |
| 17 | } from "@t3tools/provider-core/server/usageLimits"; | |
| 18 | import { readMacCursorAccessToken } from "./keychainToken.ts"; | |
| 19 | ||
| 20 | const CursorCredentials = Schema.Struct({ accessToken: Schema.optional(Schema.String) }); | |
| 21 | const DEFAULT_CURSOR_API_ENDPOINT = "https://api2.cursor.sh"; | |
| 22 | const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(CursorCredentials)); | |
| 23 | const CursorUsageResponse = Schema.Struct({ | |
| 24 | billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), | |
| 25 | planUsage: Schema.optional( | |
| 26 | Schema.Struct({ | |
| 27 | totalPercentUsed: Schema.optional(Schema.Number), | |
| 28 | autoPercentUsed: Schema.optional(Schema.Number), | |
| 29 | apiPercentUsed: Schema.optional(Schema.Number), | |
| 30 | }), | |
| 31 | ), | |
| 32 | }); | |
| 33 | ||
| 34 | /** Cursor's dashboard percentages include bonus usage; spend / limit does not. */ | |
| 35 | export function cursorUsageResponseToLimits( | |
| 36 | response: typeof CursorUsageResponse.Type, | |
| 37 | checkedAt: string, | |
| 38 | ) { | |
| 39 | const reset = DateTime.make(Number(response.billingCycleEnd)); | |
| 40 | const resetsAt = | |
| 41 | Number(response.billingCycleEnd) > 0 && Option.isSome(reset) | |
| 42 | ? DateTime.formatIso(reset.value) | |
| 43 | : undefined; | |
| 44 | const windows: ServerProviderUsageWindow[] = []; | |
| 45 | if (response.planUsage) { | |
| 46 | for (const { id, label } of CURSOR_USAGE_WINDOWS) { | |
| 47 | const usedPercent = response.planUsage[id]; | |
| 48 | if (usedPercent === undefined || !Number.isFinite(usedPercent)) continue; | |
| 49 | windows.push({ | |
| 50 | id, | |
| 51 | kind: "monthly", | |
| 52 | label, | |
| 53 | usedPercent: clampPercent(usedPercent), | |
| 54 | ...(resetsAt ? { resetsAt } : {}), | |
| 55 | }); | |
| 56 | } | |
| 57 | } | |
| 58 | return windows.length > 0 | |
| 59 | ? makeUsageLimits({ checkedAt, windows }) | |
| 60 | : makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 61 | } | |
| 62 | ||
| 63 | export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function* ( | |
| 64 | settings: Pick<CursorSettings, "apiEndpoint">, | |
| 65 | environment: NodeJS.ProcessEnv = process.env, | |
| 66 | allowKeychain = false, | |
| 67 | keychainToken: () => Promise<string | null> = readMacCursorAccessToken, | |
| 68 | ) { | |
| 69 | const checkedAt = DateTime.formatIso(yield* DateTime.now); | |
| 70 | return yield* Effect.gen(function* () { | |
| 71 | const fs = yield* FileSystem.FileSystem; | |
| 72 | const path = yield* Path.Path; | |
| 73 | const platform = yield* HostProcessPlatform; | |
| 74 | const endpoint = ( | |
| 75 | settings.apiEndpoint?.trim() || | |
| 76 | environment.CURSOR_API_ENDPOINT?.trim() || | |
| 77 | DEFAULT_CURSOR_API_ENDPOINT | |
| 78 | ).replace(/\/$/, ""); | |
| 79 | let token = environment.CURSOR_AUTH_TOKEN?.trim(); | |
| 80 | // An explicit API key can name a different account from the stored login. | |
| 81 | if (!token && environment.CURSOR_API_KEY?.trim()) { | |
| 82 | return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 83 | } | |
| 84 | const credentialStore = environment.AGENT_CLI_CREDENTIAL_STORE; | |
| 85 | if (!token && credentialStore === "memory") { | |
| 86 | return makeUnavailableUsageLimits({ | |
| 87 | checkedAt, | |
| 88 | reason: "unsupported", | |
| 89 | message: "Cursor usage requires a CLI login or CURSOR_AUTH_TOKEN.", | |
| 90 | }); | |
| 91 | } | |
| 92 | if (!token && platform === "darwin" && credentialStore !== "file") { | |
| 93 | if (!allowKeychain) { | |
| 94 | return makeUnavailableUsageLimits({ | |
| 95 | checkedAt, | |
| 96 | reason: "unsupported", | |
| 97 | message: "Enable Cursor account usage in T3 Code to read its Keychain login.", | |
| 98 | }); | |
| 99 | } | |
| 100 | if (endpoint !== DEFAULT_CURSOR_API_ENDPOINT) { | |
| 101 | return makeUnavailableUsageLimits({ | |
| 102 | checkedAt, | |
| 103 | reason: "unsupported", | |
| 104 | message: "Cursor account usage requires the default Cursor endpoint when using Keychain.", | |
| 105 | }); | |
| 106 | } | |
| 107 | token = (yield* Effect.tryPromise(keychainToken))?.trim(); | |
| 108 | } else if (!token) { | |
| 109 | const home = | |
| 110 | (platform === "win32" ? environment.USERPROFILE : environment.HOME) || NodeOS.homedir(); | |
| 111 | const directory = | |
| 112 | platform === "win32" | |
| 113 | ? path.join(environment.APPDATA || path.join(home, "AppData", "Roaming"), "Cursor") | |
| 114 | : platform === "darwin" | |
| 115 | ? path.join(home, ".cursor") | |
| 116 | : path.join(environment.XDG_CONFIG_HOME || path.join(home, ".config"), "cursor"); | |
| 117 | const credentials = yield* fs.readFileString(path.join(directory, "auth.json")).pipe( | |
| 118 | Effect.catchTags({ | |
| 119 | PlatformError: (error) => | |
| 120 | error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), | |
| 121 | }), | |
| 122 | Effect.flatMap(decodeCredentials), | |
| 123 | ); | |
| 124 | token = credentials.accessToken?.trim(); | |
| 125 | } | |
| 126 | if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); | |
| 127 | const client = yield* HttpClient.HttpClient; | |
| 128 | const response = yield* client.execute( | |
| 129 | HttpClientRequest.post(`${endpoint}/aiserver.v1.DashboardService/GetCurrentPeriodUsage`).pipe( | |
| 130 | HttpClientRequest.bearerToken(token), | |
| 131 | HttpClientRequest.setHeaders({ | |
| 132 | "connect-protocol-version": "1", | |
| 133 | "x-cursor-client-type": "cli", | |
| 134 | }), | |
| 135 | HttpClientRequest.bodyJsonUnsafe({}), | |
| 136 | ), | |
| 137 | ); | |
| 138 | const body = yield* HttpClientResponse.schemaBodyJson(CursorUsageResponse)( | |
| 139 | yield* HttpClientResponse.filterStatusOk(response), | |
| 140 | ); | |
| 141 | return cursorUsageResponseToLimits(body, checkedAt); | |
| 142 | }).pipe( | |
| 143 | Effect.timeout("10 seconds"), | |
| 144 | Effect.orElseSucceed(() => | |
| 145 | makeUnavailableUsageLimits({ | |
| 146 | checkedAt, | |
| 147 | reason: "probeFailed", | |
| 148 | message: "Cursor could not read usage limits.", | |
| 149 | }), | |
| 150 | ), | |
| 151 | ); | |
| 152 | }); |
packages/provider-cursor/src/settings.ts created+35| ... | ... | @@ -0,0 +1,35 @@ |
| 1 | /** | |
| 2 | * Cursor instance settings. Shared by the server driver and the client | |
| 3 | * settings form, so it holds only browser-safe schema code. | |
| 4 | * | |
| 5 | * @module provider-cursor/settings | |
| 6 | */ | |
| 7 | import { CustomModelSetting, makeProviderSettingsSchema, TrimmedString } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Schema from "effect/Schema"; | |
| 10 | ||
| 11 | export const CursorSettings = makeProviderSettingsSchema( | |
| 12 | { | |
| 13 | // Off by default like Grok and OpenCode. Users opt in from Settings. | |
| 14 | enabled: Schema.Boolean.pipe( | |
| 15 | Schema.withDecodingDefault(Effect.succeed(false)), | |
| 16 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 17 | ), | |
| 18 | // Keep V1's CLI configuration when V2 rewrites the shared settings file. | |
| 19 | // V2's Cursor SDK does not use these fields. | |
| 20 | binaryPath: Schema.optionalKey(TrimmedString).pipe( | |
| 21 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 22 | ), | |
| 23 | apiEndpoint: Schema.optionalKey(TrimmedString).pipe( | |
| 24 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 25 | ), | |
| 26 | customModels: Schema.Array(CustomModelSetting).pipe( | |
| 27 | Schema.withDecodingDefault(Effect.succeed([])), | |
| 28 | Schema.annotateKey({ providerSettingsForm: { hidden: true } }), | |
| 29 | ), | |
| 30 | }, | |
| 31 | { | |
| 32 | order: [], | |
| 33 | }, | |
| 34 | ); | |
| 35 | export type CursorSettings = typeof CursorSettings.Type; |
packages/provider-cursor/src/testing.ts created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | /** | |
| 2 | * Cursor internals the server's replay testkit, fixture recorder, and | |
| 3 | * orchestration tests use directly. | |
| 4 | * | |
| 5 | * @module provider-cursor/testing | |
| 6 | */ | |
| 7 | export { | |
| 8 | CURSOR_DEFAULT_INSTANCE_ID, | |
| 9 | CURSOR_DRIVER_KIND, | |
| 10 | CursorProviderCapabilitiesV2, | |
| 11 | cursorSdkModelSelection, | |
| 12 | makeCursorAgentOptions, | |
| 13 | } from "./server/adapter.ts"; |
packages/provider-cursor/tsconfig.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "extends": "../../tsconfig.base.json", | |
| 3 | "compilerOptions": { | |
| 4 | "types": ["node"] | |
| 5 | }, | |
| 6 | "include": ["src"] | |
| 7 | } |
packages/provider-testing/src/host.ts+13| ... | ... | @@ -10,6 +10,7 @@ import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 10 | 10 | import * as Effect from "effect/Effect"; |
| 11 | 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Option from "effect/Option"; | |
| 13 | 14 | import * as Path from "effect/Path"; |
| 14 | 15 | import * as Stream from "effect/Stream"; |
| 15 | 16 | |
| ... | ... | @@ -39,6 +40,7 @@ export const layerTestProviderHost = ( |
| 39 | 40 | yield* fileSystem.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie); |
| 40 | 41 | } |
| 41 | 42 | const settings = options.settings ?? DEFAULT_SERVER_SETTINGS; |
| 43 | const credentials = new Map<string, Uint8Array>(); | |
| 42 | 44 | return ProviderHost.ProviderHost.of({ |
| 43 | 45 | paths: { cwd: options.cwd ?? process.cwd(), baseDir, stateDir, providerStatusCacheDir }, |
| 44 | 46 | settings: { |
| ... | ... | @@ -49,6 +51,17 @@ export const layerTestProviderHost = ( |
| 49 | 51 | shouldRunBackgroundWork: () => Effect.succeed(options.runBackgroundWork ?? true), |
| 50 | 52 | // Tests store attachments flat under the attachments directory by id. |
| 51 | 53 | resolveAttachmentPath: (attachment) => path.join(attachmentsDir, attachment.id), |
| 54 | // Credentials live in memory for the layer's lifetime. | |
| 55 | credentials: (namespace, bindingId) => | |
| 56 | Effect.sync(() => { | |
| 57 | const key = `${namespace}:${bindingId}`; | |
| 58 | return { | |
| 59 | binding: { owner: "t3" as const, key }, | |
| 60 | get: Effect.sync(() => Option.fromUndefinedOr(credentials.get(key))), | |
| 61 | set: (value: Uint8Array) => Effect.sync(() => void credentials.set(key, value)), | |
| 62 | remove: Effect.sync(() => void credentials.delete(key)), | |
| 63 | }; | |
| 64 | }), | |
| 52 | 65 | }); |
| 53 | 66 | }), |
| 54 | 67 | ); |
pnpm-lock.yaml+49| ... | ... | @@ -356,6 +356,9 @@ importers: |
| 356 | 356 | '@t3tools/provider-core': |
| 357 | 357 | specifier: workspace:* |
| 358 | 358 | version: link:../../packages/provider-core |
| 359 | '@t3tools/provider-cursor': | |
| 360 | specifier: workspace:* | |
| 361 | version: link:../../packages/provider-cursor | |
| 359 | 362 | '@t3tools/provider-muse': |
| 360 | 363 | specifier: workspace:* |
| 361 | 364 | version: link:../../packages/provider-muse |
| ... | ... | @@ -600,6 +603,9 @@ importers: |
| 600 | 603 | '@t3tools/provider-core': |
| 601 | 604 | specifier: workspace:* |
| 602 | 605 | version: link:../../packages/provider-core |
| 606 | '@t3tools/provider-cursor': | |
| 607 | specifier: workspace:* | |
| 608 | version: link:../../packages/provider-cursor | |
| 603 | 609 | '@t3tools/provider-muse': |
| 604 | 610 | specifier: workspace:* |
| 605 | 611 | version: link:../../packages/provider-muse |
| ... | ... | @@ -736,6 +742,9 @@ importers: |
| 736 | 742 | '@t3tools/provider-core': |
| 737 | 743 | specifier: workspace:* |
| 738 | 744 | version: link:../../packages/provider-core |
| 745 | '@t3tools/provider-cursor': | |
| 746 | specifier: workspace:* | |
| 747 | version: link:../../packages/provider-cursor | |
| 739 | 748 | '@t3tools/provider-muse': |
| 740 | 749 | specifier: workspace:* |
| 741 | 750 | version: link:../../packages/provider-muse |
| ... | ... | @@ -1108,6 +1117,46 @@ importers: |
| 1108 | 1117 | specifier: 1.0.0 |
| 1109 | 1118 | 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) |
| 1110 | 1119 | |
| 1120 | packages/provider-cursor: | |
| 1121 | dependencies: | |
| 1122 | '@cursor/sdk': | |
| 1123 | specifier: 1.0.35 | |
| 1124 | version: 1.0.35 | |
| 1125 | '@napi-rs/keyring': | |
| 1126 | specifier: ^1.3.0 | |
| 1127 | version: 1.3.0 | |
| 1128 | '@t3tools/contracts': | |
| 1129 | specifier: workspace:* | |
| 1130 | version: link:../contracts | |
| 1131 | '@t3tools/provider-core': | |
| 1132 | specifier: workspace:* | |
| 1133 | version: link:../provider-core | |
| 1134 | '@t3tools/shared': | |
| 1135 | specifier: workspace:* | |
| 1136 | version: link:../shared | |
| 1137 | effect: | |
| 1138 | specifier: 4.0.1 | |
| 1139 | version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) | |
| 1140 | yaml: | |
| 1141 | specifier: ^2.9.0 | |
| 1142 | version: 2.9.0 | |
| 1143 | devDependencies: | |
| 1144 | '@effect/platform-node': | |
| 1145 | specifier: 4.0.1 | |
| 1146 | 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) | |
| 1147 | '@effect/vitest': | |
| 1148 | specifier: 4.0.1 | |
| 1149 | 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) | |
| 1150 | '@t3tools/provider-testing': | |
| 1151 | specifier: workspace:* | |
| 1152 | version: link:../provider-testing | |
| 1153 | '@types/node': | |
| 1154 | specifier: 24.12.4 | |
| 1155 | version: 24.12.4 | |
| 1156 | vite-plus: | |
| 1157 | specifier: 1.0.0 | |
| 1158 | 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) | |
| 1159 | ||
| 1111 | 1160 | packages/provider-muse: |
| 1112 | 1161 | dependencies: |
| 1113 | 1162 | '@muse-code/sdk': |
scripts/lib/cursor-sdk-packaging.test.ts+1-1| ... | ... | @@ -79,7 +79,7 @@ it("loads packaged Cursor catalog chunks without credentials or checkout depende |
| 79 | 79 | entry, |
| 80 | 80 | ` |
| 81 | 81 | import assert from 'node:assert/strict'; |
| 82 | import { Cursor } from ${JSON.stringify(NodePath.join(repoRoot, "apps/server/src/provider/cursorSdk.ts"))}; | |
| 82 | import { Cursor } from ${JSON.stringify(NodePath.join(repoRoot, "packages/provider-cursor/src/server/sdk.ts"))}; | |
| 83 | 83 | for (const [operation, request] of [ |
| 84 | 84 | ['Cursor.models.list', () => Cursor.models.list({ apiKey: '' })], |
| 85 | 85 | ['Cursor.me', () => Cursor.me({ apiKey: '' })], |
scripts/release-smoke.ts+1| ... | ... | @@ -31,6 +31,7 @@ const workspaceFiles = [ |
| 31 | 31 | "packages/provider-core/package.json", |
| 32 | 32 | "packages/provider-testing/package.json", |
| 33 | 33 | "packages/provider-pi/package.json", |
| 34 | "packages/provider-cursor/package.json", | |
| 34 | 35 | "packages/provider-muse/package.json", |
| 35 | 36 | "packages/provider-opencode/package.json", |
| 36 | 37 | "packages/effect-acp/package.json", |