| 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 TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 26 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 27 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 28 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| 29 | import { |
| 30 | cursorMcpServers, |
| 31 | cursorRuntimeAgentPolicy, |
| 32 | cursorSdkModelSelection, |
| 33 | makeCursorAgentOptions, |
| 34 | makeCursorAdapterV2, |
| 35 | nestedToolCallFromEnvelope, |
| 36 | } from "./adapter.ts"; |
| 37 | import * as CursorAgentSdk 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 workspace = yield* fileSystem.makeTempDirectoryScoped({ |
| 54 | prefix: "cursor-v2-lifecycle-", |
| 55 | }); |
| 56 | const instanceId = ProviderInstanceId.make("cursor"); |
| 57 | const threadId = ThreadId.make("cursor-lifecycle-thread"); |
| 58 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| 59 | yield* mcpSessions.set({ |
| 60 | environmentId: EnvironmentId.make("cursor-lifecycle-environment"), |
| 61 | threadId, |
| 62 | providerSessionId: "cursor-lifecycle-session", |
| 63 | providerInstanceId: instanceId, |
| 64 | endpoint: "http://127.0.0.1:43123/mcp", |
| 65 | authorizationHeader: "Bearer synthetic-cursor-token", |
| 66 | browserToolsAvailable: true, |
| 67 | }); |
| 68 | let sentWithMcpOverride = true; |
| 69 | const modelSelection = { instanceId, model: "composer-2.5" }; |
| 70 | const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ |
| 71 | runtimeMode: "full-access", |
| 72 | interactionMode: "default", |
| 73 | cwd: workspace, |
| 74 | }); |
| 75 | const adapter = yield* makeCursorAdapterV2({ |
| 76 | instanceId, |
| 77 | settings: yield* decodeCursorSettings({}), |
| 78 | environment: { HOME: workspace }, |
| 79 | }).pipe( |
| 80 | Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { |
| 81 | assertComplete: Effect.void, |
| 82 | open: () => |
| 83 | Effect.succeed({ |
| 84 | agentId: "native-cursor-lifecycle", |
| 85 | listMessages: Effect.succeed([]), |
| 86 | close: Effect.void, |
| 87 | send: (input) => |
| 88 | Effect.gen(function* () { |
| 89 | sentWithMcpOverride = Object.hasOwn(input.options ?? {}, "mcpServers"); |
| 90 | // Recorded Cursor task calls (see fixtures/subagent) stream a |
| 91 | // partial-tool-call before tool-call-started with the same args. |
| 92 | // The run then ends without tool-call-completed, which a live |
| 93 | // run cannot produce on demand. |
| 94 | const taskToolCall = { |
| 95 | type: "task" as const, |
| 96 | args: { |
| 97 | description: "Review", |
| 98 | prompt: "Review the code.", |
| 99 | subagentType: { kind: "unspecified" }, |
| 100 | ...(model === undefined ? {} : { model }), |
| 101 | agentId: "cursor-task-agent", |
| 102 | mode: "unspecified" as const, |
| 103 | }, |
| 104 | }; |
| 105 | const updates = |
| 106 | lateModel !== undefined |
| 107 | ? (["tool-call-started", "tool-call-completed"] as const) |
| 108 | : (["partial-tool-call", "tool-call-started"] as const); |
| 109 | for (const type of updates) { |
| 110 | yield* input.onDelta!({ |
| 111 | type, |
| 112 | modelCallId: "model-call", |
| 113 | callId: "task-call", |
| 114 | toolCall: { |
| 115 | ...taskToolCall, |
| 116 | args: { |
| 117 | ...taskToolCall.args, |
| 118 | ...(type === "tool-call-completed" |
| 119 | ? { model: lateModel ?? undefined } |
| 120 | : {}), |
| 121 | }, |
| 122 | }, |
| 123 | }).pipe(Effect.orDie); |
| 124 | } |
| 125 | return { |
| 126 | agentId: "native-cursor-lifecycle", |
| 127 | runId: "native-cursor-run", |
| 128 | wait: Effect.succeed({ |
| 129 | id: "native-cursor-run", |
| 130 | requestId: "native-request", |
| 131 | status, |
| 132 | model: { id: "composer-2.5" }, |
| 133 | durationMs: 1, |
| 134 | }), |
| 135 | cancel: Effect.void, |
| 136 | }; |
| 137 | }), |
| 138 | }), |
| 139 | }), |
| 140 | Effect.provide(TestProviderHost.layer({ cwd: workspace })), |
| 141 | ); |
| 142 | const runtime = yield* adapter.openSession({ |
| 143 | threadId, |
| 144 | providerSessionId: ProviderSessionId.make("cursor-lifecycle-session"), |
| 145 | modelSelection, |
| 146 | runtimePolicy, |
| 147 | }); |
| 148 | const providerThread = yield* runtime.ensureThread({ |
| 149 | threadId, |
| 150 | modelSelection, |
| 151 | runtimePolicy, |
| 152 | }); |
| 153 | const now = yield* DateTime.now; |
| 154 | yield* runtime.startTurn({ |
| 155 | threadId, |
| 156 | providerThread, |
| 157 | modelSelection, |
| 158 | runtimePolicy, |
| 159 | runId: RunId.make("cursor-lifecycle-run"), |
| 160 | runOrdinal: 1, |
| 161 | providerTurnOrdinal: 1, |
| 162 | attemptId: RunAttemptId.make("cursor-lifecycle-attempt"), |
| 163 | rootNodeId: NodeId.make("cursor-lifecycle-root"), |
| 164 | appThread: { |
| 165 | id: threadId, |
| 166 | projectId: ProjectId.make("cursor-lifecycle-project"), |
| 167 | createdBy: "user", |
| 168 | creationSource: "web", |
| 169 | title: "Cursor lifecycle", |
| 170 | providerInstanceId: instanceId, |
| 171 | modelSelection, |
| 172 | runtimeMode: "full-access", |
| 173 | interactionMode: "default", |
| 174 | branch: null, |
| 175 | worktreePath: null, |
| 176 | activeProviderThreadId: providerThread.id, |
| 177 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, |
| 178 | forkedFrom: null, |
| 179 | createdAt: now, |
| 180 | updatedAt: now, |
| 181 | archivedAt: null, |
| 182 | settledOverride: null, |
| 183 | settledAt: null, |
| 184 | lastVisitedAt: null, |
| 185 | deletedAt: null, |
| 186 | }, |
| 187 | message: { |
| 188 | messageId: MessageId.make("cursor-lifecycle-message"), |
| 189 | createdBy: "user", |
| 190 | creationSource: "web", |
| 191 | text: "Review the code.", |
| 192 | attachments: [], |
| 193 | }, |
| 194 | }); |
| 195 | const events = yield* runtime.events.pipe( |
| 196 | Stream.takeUntil((event) => event.type === "turn.terminal"), |
| 197 | Stream.runCollect, |
| 198 | ); |
| 199 | // Keep injected MCP at agent creation so sends preserve Cursor's ambient servers. |
| 200 | assert.isFalse(sentWithMcpOverride); |
| 201 | const rows = events.filter((event) => event.type === "subagent.updated"); |
| 202 | assert.equal(rows[0]?.subagent.status, "running"); |
| 203 | assert.equal(rows[0]?.subagent.model, model ?? null); |
| 204 | assert.equal(rows.at(-1)?.subagent.model, lateModel ?? model ?? null); |
| 205 | assert.equal( |
| 206 | rows.at(-1)?.subagent.status, |
| 207 | lateModel !== undefined |
| 208 | ? "completed" |
| 209 | : status === "finished" |
| 210 | ? "idle" |
| 211 | : status === "cancelled" |
| 212 | ? "cancelled" |
| 213 | : "failed", |
| 214 | ); |
| 215 | assert.isNotNull(rows.at(-1)?.subagent.completedAt); |
| 216 | }).pipe( |
| 217 | Effect.scoped, |
| 218 | Effect.provide( |
| 219 | Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), |
| 220 | ), |
| 221 | ), |
| 222 | ); |
| 223 | |
| 224 | it.effect("fails standalone SDK transport diagnostics and sends compaction as /compress", () => |
| 225 | Effect.gen(function* () { |
| 226 | const fileSystem = yield* FileSystem.FileSystem; |
| 227 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-errors-" }); |
| 228 | const sentMessages: Array<string> = []; |
| 229 | let sendCalls = 0; |
| 230 | const instanceId = ProviderInstanceId.make("cursor"); |
| 231 | const threadId = ThreadId.make("cursor-transport-error-thread"); |
| 232 | const modelSelection = { instanceId, model: "composer-2.5" }; |
| 233 | const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ |
| 234 | runtimeMode: "full-access", |
| 235 | interactionMode: "default", |
| 236 | cwd: workspace, |
| 237 | }); |
| 238 | const adapter = yield* makeCursorAdapterV2({ |
| 239 | instanceId, |
| 240 | settings: yield* decodeCursorSettings({}), |
| 241 | environment: { HOME: workspace }, |
| 242 | }).pipe( |
| 243 | Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { |
| 244 | assertComplete: Effect.void, |
| 245 | open: () => |
| 246 | Effect.succeed({ |
| 247 | agentId: "native-cursor-error", |
| 248 | listMessages: Effect.succeed([]), |
| 249 | close: Effect.void, |
| 250 | send: (input) => |
| 251 | Effect.sync(() => { |
| 252 | sendCalls += 1; |
| 253 | sentMessages.push( |
| 254 | typeof input.message === "string" ? input.message : input.message.text, |
| 255 | ); |
| 256 | const runId = `native-cursor-run-${sendCalls}`; |
| 257 | return { |
| 258 | agentId: "native-cursor-error", |
| 259 | runId, |
| 260 | wait: Effect.succeed({ |
| 261 | id: runId, |
| 262 | requestId: `native-request-${sendCalls}`, |
| 263 | status: "finished" as const, |
| 264 | model: { id: "composer-2.5" }, |
| 265 | durationMs: 1, |
| 266 | ...(sendCalls === 1 |
| 267 | ? { result: "Error: RetriableError: WritableIterable is closed" } |
| 268 | : {}), |
| 269 | }), |
| 270 | cancel: Effect.void, |
| 271 | }; |
| 272 | }), |
| 273 | }), |
| 274 | }), |
| 275 | Effect.provide(TestProviderHost.layer({ cwd: workspace })), |
| 276 | ); |
| 277 | const runtime = yield* adapter.openSession({ |
| 278 | threadId, |
| 279 | providerSessionId: ProviderSessionId.make("cursor-error-session"), |
| 280 | modelSelection, |
| 281 | runtimePolicy, |
| 282 | }); |
| 283 | const providerThread = yield* runtime.ensureThread({ |
| 284 | threadId, |
| 285 | modelSelection, |
| 286 | runtimePolicy, |
| 287 | }); |
| 288 | const now = yield* DateTime.now; |
| 289 | const appThread = { |
| 290 | id: threadId, |
| 291 | projectId: ProjectId.make("cursor-error-project"), |
| 292 | createdBy: "user" as const, |
| 293 | creationSource: "web" as const, |
| 294 | title: "Cursor transport failure", |
| 295 | providerInstanceId: instanceId, |
| 296 | modelSelection, |
| 297 | runtimeMode: "full-access" as const, |
| 298 | interactionMode: "default" as const, |
| 299 | branch: null, |
| 300 | worktreePath: null, |
| 301 | activeProviderThreadId: providerThread.id, |
| 302 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, |
| 303 | forkedFrom: null, |
| 304 | createdAt: now, |
| 305 | updatedAt: now, |
| 306 | archivedAt: null, |
| 307 | settledOverride: null, |
| 308 | settledAt: null, |
| 309 | lastVisitedAt: null, |
| 310 | deletedAt: null, |
| 311 | }; |
| 312 | yield* runtime.startTurn({ |
| 313 | threadId, |
| 314 | providerThread, |
| 315 | modelSelection, |
| 316 | runtimePolicy, |
| 317 | runId: RunId.make("cursor-error-run"), |
| 318 | runOrdinal: 1, |
| 319 | providerTurnOrdinal: 1, |
| 320 | attemptId: RunAttemptId.make("cursor-error-attempt"), |
| 321 | rootNodeId: NodeId.make("cursor-error-root"), |
| 322 | appThread, |
| 323 | message: { |
| 324 | messageId: MessageId.make("cursor-error-message"), |
| 325 | createdBy: "user", |
| 326 | creationSource: "web", |
| 327 | text: "continue", |
| 328 | attachments: [], |
| 329 | }, |
| 330 | }); |
| 331 | const first = yield* runtime.events.pipe( |
| 332 | Stream.filter((event) => event.type === "turn.terminal"), |
| 333 | Stream.runHead, |
| 334 | ); |
| 335 | assert.isTrue(Option.isSome(first)); |
| 336 | if (Option.isSome(first)) { |
| 337 | assert.equal(first.value.status, "failed"); |
| 338 | assert.equal(first.value.failure?.class, "transport_error"); |
| 339 | } |
| 340 | |
| 341 | assert.isDefined(runtime.compactThread); |
| 342 | yield* runtime.compactThread!({ |
| 343 | threadId, |
| 344 | providerThread, |
| 345 | modelSelection, |
| 346 | runtimePolicy, |
| 347 | runId: RunId.make("cursor-compact-run"), |
| 348 | runOrdinal: 2, |
| 349 | providerTurnOrdinal: 2, |
| 350 | attemptId: RunAttemptId.make("cursor-compact-attempt"), |
| 351 | rootNodeId: NodeId.make("cursor-compact-root"), |
| 352 | appThread, |
| 353 | message: { |
| 354 | messageId: MessageId.make("cursor-compact-message"), |
| 355 | createdBy: "user", |
| 356 | creationSource: "web", |
| 357 | text: "ignored by compactThread", |
| 358 | attachments: [], |
| 359 | }, |
| 360 | }); |
| 361 | yield* runtime.events.pipe( |
| 362 | Stream.filter((event) => event.type === "turn.terminal"), |
| 363 | Stream.runHead, |
| 364 | ); |
| 365 | assert.equal(sentMessages[1], "/compress"); |
| 366 | }).pipe( |
| 367 | Effect.scoped, |
| 368 | Effect.provide( |
| 369 | Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), |
| 370 | ), |
| 371 | ), |
| 372 | ); |
| 373 | |
| 374 | it.effect("projects Cursor directory trees and lint diagnostics as file search results", () => |
| 375 | Effect.gen(function* () { |
| 376 | const fileSystem = yield* FileSystem.FileSystem; |
| 377 | const path = yield* Path.Path; |
| 378 | const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-search-" }); |
| 379 | const instanceId = ProviderInstanceId.make("cursor"); |
| 380 | const threadId = ThreadId.make("cursor-search-thread"); |
| 381 | const modelSelection = { instanceId, model: "composer-2.5" }; |
| 382 | const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ |
| 383 | runtimeMode: "full-access", |
| 384 | interactionMode: "default", |
| 385 | cwd: workspace, |
| 386 | }); |
| 387 | const emptyLsNode = { |
| 388 | childrenDirs: [], |
| 389 | childrenFiles: [], |
| 390 | childrenWereProcessed: true, |
| 391 | fullSubtreeExtensionCounts: {}, |
| 392 | numFiles: 0, |
| 393 | }; |
| 394 | const updates: ReadonlyArray<InteractionUpdate> = [ |
| 395 | ...(["tool-call-started", "tool-call-completed"] as const).map((type) => ({ |
| 396 | type, |
| 397 | modelCallId: "native-model-call", |
| 398 | callId: "mcp-weather", |
| 399 | toolCall: { |
| 400 | type: "mcp" as const, |
| 401 | args: { |
| 402 | providerIdentifier: "weather", |
| 403 | toolName: "get_weather", |
| 404 | args: { city: "Berlin" }, |
| 405 | }, |
| 406 | ...(type === "tool-call-completed" |
| 407 | ? { result: { status: "success" as const, value: { content: [], isError: false } } } |
| 408 | : {}), |
| 409 | }, |
| 410 | })), |
| 411 | { |
| 412 | type: "tool-call-completed", |
| 413 | modelCallId: "native-model-call", |
| 414 | callId: "read-file", |
| 415 | toolCall: { |
| 416 | type: "read", |
| 417 | args: { path: "src/env.ts" }, |
| 418 | result: { |
| 419 | status: "success", |
| 420 | value: { fileSize: 12, content: "---\nfile body", totalLines: 2 }, |
| 421 | }, |
| 422 | }, |
| 423 | }, |
| 424 | { |
| 425 | type: "tool-call-completed", |
| 426 | modelCallId: "native-model-call", |
| 427 | callId: "ls-nested", |
| 428 | toolCall: { |
| 429 | type: "ls", |
| 430 | args: { path: workspace }, |
| 431 | result: { |
| 432 | status: "success", |
| 433 | value: { |
| 434 | directoryTreeRoot: { |
| 435 | ...emptyLsNode, |
| 436 | absPath: workspace, |
| 437 | childrenFiles: [{ name: "README.md" }], |
| 438 | childrenDirs: [ |
| 439 | { |
| 440 | ...emptyLsNode, |
| 441 | absPath: path.join(workspace, "src"), |
| 442 | childrenFiles: [{ name: "index.ts" }], |
| 443 | childrenDirs: [ |
| 444 | { |
| 445 | ...emptyLsNode, |
| 446 | absPath: path.join(workspace, "src", "nested"), |
| 447 | childrenFiles: [{ name: "util.ts" }], |
| 448 | }, |
| 449 | ], |
| 450 | }, |
| 451 | ], |
| 452 | }, |
| 453 | }, |
| 454 | }, |
| 455 | }, |
| 456 | }, |
| 457 | { |
| 458 | type: "tool-call-completed", |
| 459 | modelCallId: "native-model-call", |
| 460 | callId: "ls-empty", |
| 461 | toolCall: { |
| 462 | type: "ls", |
| 463 | args: { path: path.join(workspace, "empty") }, |
| 464 | result: { |
| 465 | status: "success", |
| 466 | value: { |
| 467 | directoryTreeRoot: { ...emptyLsNode, absPath: path.join(workspace, "empty") }, |
| 468 | }, |
| 469 | }, |
| 470 | }, |
| 471 | }, |
| 472 | { |
| 473 | type: "tool-call-completed", |
| 474 | modelCallId: "native-model-call", |
| 475 | callId: "ls-failed", |
| 476 | toolCall: { |
| 477 | type: "ls", |
| 478 | args: { path: path.join(workspace, "missing") }, |
| 479 | result: { status: "error", error: "ENOENT" }, |
| 480 | }, |
| 481 | }, |
| 482 | { |
| 483 | type: "tool-call-completed", |
| 484 | modelCallId: "native-model-call", |
| 485 | callId: "grep-failed", |
| 486 | toolCall: { |
| 487 | type: "grep", |
| 488 | args: { pattern: "TODO", path: "src" }, |
| 489 | result: { status: "error", error: "search failed" }, |
| 490 | }, |
| 491 | }, |
| 492 | { |
| 493 | type: "tool-call-completed", |
| 494 | modelCallId: "native-model-call", |
| 495 | callId: "glob-failed", |
| 496 | toolCall: { |
| 497 | type: "glob", |
| 498 | args: { globPattern: "*.ts" }, |
| 499 | result: { status: "error", error: "search failed" }, |
| 500 | }, |
| 501 | }, |
| 502 | { |
| 503 | type: "tool-call-completed", |
| 504 | modelCallId: "native-model-call", |
| 505 | callId: "lints", |
| 506 | toolCall: { |
| 507 | type: "readLints", |
| 508 | args: { paths: ["src/a.ts", "src/b.ts"] }, |
| 509 | result: { |
| 510 | status: "success", |
| 511 | value: { |
| 512 | totalFiles: 2, |
| 513 | totalDiagnostics: 3, |
| 514 | fileDiagnostics: [ |
| 515 | { |
| 516 | path: "src/a.ts", |
| 517 | diagnosticsCount: 2, |
| 518 | diagnostics: [ |
| 519 | { |
| 520 | message: "Unused variable", |
| 521 | code: "TS6133", |
| 522 | source: "ts", |
| 523 | severity: "warning", |
| 524 | range: { start: { line: 0, character: 4 } }, |
| 525 | }, |
| 526 | { |
| 527 | message: "Cannot find name", |
| 528 | code: "TS2304", |
| 529 | source: "ts", |
| 530 | severity: "error", |
| 531 | range: { start: { line: 11 } }, |
| 532 | }, |
| 533 | ], |
| 534 | }, |
| 535 | { |
| 536 | path: "src/b.ts", |
| 537 | diagnosticsCount: 1, |
| 538 | diagnostics: [ |
| 539 | { |
| 540 | message: "File-level diagnostic", |
| 541 | code: "TS0", |
| 542 | source: "ts", |
| 543 | severity: "information", |
| 544 | }, |
| 545 | ], |
| 546 | }, |
| 547 | ], |
| 548 | }, |
| 549 | }, |
| 550 | }, |
| 551 | }, |
| 552 | { |
| 553 | type: "tool-call-completed", |
| 554 | modelCallId: "native-model-call", |
| 555 | callId: "lints-empty", |
| 556 | toolCall: { |
| 557 | type: "readLints", |
| 558 | args: { paths: ["src/clean.ts"] }, |
| 559 | result: { |
| 560 | status: "success", |
| 561 | value: { |
| 562 | totalFiles: 1, |
| 563 | totalDiagnostics: 0, |
| 564 | fileDiagnostics: [{ path: "src/clean.ts", diagnosticsCount: 0, diagnostics: [] }], |
| 565 | }, |
| 566 | }, |
| 567 | }, |
| 568 | }, |
| 569 | { |
| 570 | type: "tool-call-completed", |
| 571 | modelCallId: "native-model-call", |
| 572 | callId: "lints-failed", |
| 573 | toolCall: { |
| 574 | type: "readLints", |
| 575 | args: { paths: ["src/a.ts"] }, |
| 576 | result: { status: "error", error: "lint failed" }, |
| 577 | }, |
| 578 | }, |
| 579 | ]; |
| 580 | const adapter = yield* makeCursorAdapterV2({ |
| 581 | instanceId, |
| 582 | settings: yield* decodeCursorSettings({}), |
| 583 | environment: { HOME: workspace }, |
| 584 | }).pipe( |
| 585 | Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { |
| 586 | assertComplete: Effect.void, |
| 587 | open: () => |
| 588 | Effect.succeed({ |
| 589 | agentId: "native-cursor-search", |
| 590 | listMessages: Effect.succeed([]), |
| 591 | close: Effect.void, |
| 592 | send: (input) => |
| 593 | Effect.gen(function* () { |
| 594 | for (const update of updates) { |
| 595 | if (input.onDelta !== undefined) { |
| 596 | yield* input.onDelta(update).pipe(Effect.orDie); |
| 597 | } |
| 598 | } |
| 599 | return { |
| 600 | agentId: "native-cursor-search", |
| 601 | runId: "native-cursor-run", |
| 602 | wait: Effect.succeed({ |
| 603 | id: "native-cursor-run", |
| 604 | requestId: "native-request", |
| 605 | status: "finished" as const, |
| 606 | model: { id: "composer-2.5" }, |
| 607 | durationMs: 1, |
| 608 | }), |
| 609 | cancel: Effect.void, |
| 610 | }; |
| 611 | }), |
| 612 | }), |
| 613 | }), |
| 614 | Effect.provide(TestProviderHost.layer({ cwd: workspace })), |
| 615 | ); |
| 616 | const runtime = yield* adapter.openSession({ |
| 617 | threadId, |
| 618 | providerSessionId: ProviderSessionId.make("cursor-search-session"), |
| 619 | modelSelection, |
| 620 | runtimePolicy, |
| 621 | }); |
| 622 | const providerThread = yield* runtime.ensureThread({ |
| 623 | threadId, |
| 624 | modelSelection, |
| 625 | runtimePolicy, |
| 626 | }); |
| 627 | const now = yield* DateTime.now; |
| 628 | yield* runtime.startTurn({ |
| 629 | threadId, |
| 630 | providerThread, |
| 631 | modelSelection, |
| 632 | runtimePolicy, |
| 633 | runId: RunId.make("cursor-search-run"), |
| 634 | runOrdinal: 1, |
| 635 | providerTurnOrdinal: 1, |
| 636 | attemptId: RunAttemptId.make("cursor-search-attempt"), |
| 637 | rootNodeId: NodeId.make("cursor-search-root"), |
| 638 | appThread: { |
| 639 | id: threadId, |
| 640 | projectId: ProjectId.make("cursor-search-project"), |
| 641 | createdBy: "user", |
| 642 | creationSource: "web", |
| 643 | title: "Cursor search results", |
| 644 | providerInstanceId: instanceId, |
| 645 | modelSelection, |
| 646 | runtimeMode: "full-access", |
| 647 | interactionMode: "default", |
| 648 | branch: null, |
| 649 | worktreePath: null, |
| 650 | activeProviderThreadId: providerThread.id, |
| 651 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, |
| 652 | forkedFrom: null, |
| 653 | createdAt: now, |
| 654 | updatedAt: now, |
| 655 | archivedAt: null, |
| 656 | settledOverride: null, |
| 657 | settledAt: null, |
| 658 | lastVisitedAt: null, |
| 659 | deletedAt: null, |
| 660 | }, |
| 661 | message: { |
| 662 | messageId: MessageId.make("cursor-search-message"), |
| 663 | createdBy: "user", |
| 664 | creationSource: "web", |
| 665 | text: "inspect the workspace", |
| 666 | attachments: [], |
| 667 | }, |
| 668 | }); |
| 669 | const events = yield* runtime.events.pipe( |
| 670 | Stream.takeUntil((event) => event.type === "turn.terminal"), |
| 671 | Stream.runCollect, |
| 672 | ); |
| 673 | const mcpItems = events.flatMap((event) => |
| 674 | event.type === "turn_item.updated" && |
| 675 | event.turnItem.type === "dynamic_tool" && |
| 676 | event.turnItem.toolName === "mcp__weather__get_weather" |
| 677 | ? [event.turnItem] |
| 678 | : [], |
| 679 | ); |
| 680 | assert.deepEqual( |
| 681 | mcpItems.map((item) => item.status), |
| 682 | ["running", "completed"], |
| 683 | ); |
| 684 | for (const item of mcpItems) { |
| 685 | assert.equal(item.title, "get weather"); |
| 686 | assert.deepEqual(item.toolSource, { |
| 687 | key: "mcp:weather", |
| 688 | name: "weather", |
| 689 | kind: "integration", |
| 690 | }); |
| 691 | assert.deepEqual(item.input, { |
| 692 | providerIdentifier: "weather", |
| 693 | toolName: "get_weather", |
| 694 | args: { city: "Berlin" }, |
| 695 | }); |
| 696 | } |
| 697 | const fileSearchItems = events.flatMap((event) => |
| 698 | event.type === "turn_item.updated" && |
| 699 | event.turnItem.type === "file_search" && |
| 700 | event.turnItem.status !== "running" |
| 701 | ? [event.turnItem] |
| 702 | : [], |
| 703 | ); |
| 704 | const readItems = events.flatMap((event) => |
| 705 | event.type === "turn_item.updated" && |
| 706 | event.turnItem.type === "dynamic_tool" && |
| 707 | event.turnItem.toolName === "Read" && |
| 708 | event.turnItem.status === "completed" |
| 709 | ? [event.turnItem] |
| 710 | : [], |
| 711 | ); |
| 712 | assert.deepEqual( |
| 713 | readItems.map((item) => ({ title: item.title, input: item.input })), |
| 714 | [{ title: "Read src/env.ts", input: { path: "src/env.ts" } }], |
| 715 | ); |
| 716 | assert.deepEqual( |
| 717 | fileSearchItems.map((item) => ({ |
| 718 | pattern: item.pattern, |
| 719 | status: item.status, |
| 720 | results: item.results, |
| 721 | })), |
| 722 | [ |
| 723 | { |
| 724 | pattern: workspace, |
| 725 | status: "completed", |
| 726 | results: [ |
| 727 | { fileName: path.join(workspace, "README.md") }, |
| 728 | { fileName: path.join(workspace, "src", "index.ts") }, |
| 729 | { fileName: path.join(workspace, "src", "nested", "util.ts") }, |
| 730 | ], |
| 731 | }, |
| 732 | { |
| 733 | pattern: path.join(workspace, "empty"), |
| 734 | status: "completed", |
| 735 | results: undefined, |
| 736 | }, |
| 737 | { |
| 738 | pattern: path.join(workspace, "missing"), |
| 739 | status: "failed", |
| 740 | results: [{ fileName: path.join(workspace, "missing"), preview: "ENOENT" }], |
| 741 | }, |
| 742 | { |
| 743 | pattern: "TODO", |
| 744 | status: "failed", |
| 745 | results: [{ fileName: "src", preview: "search failed" }], |
| 746 | }, |
| 747 | { |
| 748 | pattern: "*.ts", |
| 749 | status: "failed", |
| 750 | results: [{ fileName: ".", preview: "search failed" }], |
| 751 | }, |
| 752 | { |
| 753 | pattern: "src/a.ts, src/b.ts", |
| 754 | status: "completed", |
| 755 | results: [ |
| 756 | { fileName: "src/a.ts", line: 1, column: 5, preview: "Unused variable" }, |
| 757 | { fileName: "src/a.ts", line: 12, preview: "Cannot find name" }, |
| 758 | { fileName: "src/b.ts", preview: "File-level diagnostic" }, |
| 759 | ], |
| 760 | }, |
| 761 | { |
| 762 | pattern: "src/clean.ts", |
| 763 | status: "completed", |
| 764 | results: undefined, |
| 765 | }, |
| 766 | { |
| 767 | pattern: "src/a.ts", |
| 768 | status: "failed", |
| 769 | results: [{ fileName: "src/a.ts", preview: "lint failed" }], |
| 770 | }, |
| 771 | ], |
| 772 | ); |
| 773 | }).pipe( |
| 774 | Effect.scoped, |
| 775 | Effect.provide( |
| 776 | Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), |
| 777 | ), |
| 778 | ), |
| 779 | ); |
| 780 | |
| 781 | it("maps Cursor auto and model parameters to SDK selections", () => { |
| 782 | assert.deepEqual( |
| 783 | cursorSdkModelSelection({ |
| 784 | instanceId: ProviderInstanceId.make("cursor"), |
| 785 | model: "auto", |
| 786 | options: [ |
| 787 | { id: "thinking", value: "high" }, |
| 788 | { id: "contextWindow", value: "1m" }, |
| 789 | { id: "fastMode", value: true }, |
| 790 | ], |
| 791 | }), |
| 792 | { |
| 793 | id: "default", |
| 794 | params: [ |
| 795 | { id: "thinking", value: "high" }, |
| 796 | { id: "context", value: "1m" }, |
| 797 | { id: "fast", value: "true" }, |
| 798 | ], |
| 799 | }, |
| 800 | ); |
| 801 | }); |
| 802 | |
| 803 | it("maps runtime modes to the SDK sandbox and auto-review controls", () => { |
| 804 | const base = { |
| 805 | interactionMode: "default" as const, |
| 806 | cwd: "/tmp/cursor-adapter", |
| 807 | }; |
| 808 | assert.deepEqual( |
| 809 | cursorRuntimeAgentPolicy({ |
| 810 | ...base, |
| 811 | runtimeMode: "full-access", |
| 812 | }), |
| 813 | { |
| 814 | autoReview: false, |
| 815 | sandboxEnabled: false, |
| 816 | }, |
| 817 | ); |
| 818 | assert.deepEqual( |
| 819 | cursorRuntimeAgentPolicy({ |
| 820 | ...base, |
| 821 | runtimeMode: "auto-accept-edits", |
| 822 | }), |
| 823 | { |
| 824 | autoReview: false, |
| 825 | sandboxEnabled: true, |
| 826 | }, |
| 827 | ); |
| 828 | assert.deepEqual( |
| 829 | cursorRuntimeAgentPolicy({ |
| 830 | ...base, |
| 831 | runtimeMode: "approval-required", |
| 832 | }), |
| 833 | { |
| 834 | autoReview: true, |
| 835 | sandboxEnabled: true, |
| 836 | }, |
| 837 | ); |
| 838 | assert.deepEqual( |
| 839 | cursorRuntimeAgentPolicy({ |
| 840 | ...base, |
| 841 | runtimeMode: "full-access", |
| 842 | approvalPolicy: "never", |
| 843 | sandboxPolicy: { type: "readOnly" }, |
| 844 | }), |
| 845 | { |
| 846 | autoReview: false, |
| 847 | sandboxEnabled: true, |
| 848 | }, |
| 849 | ); |
| 850 | assert.deepEqual( |
| 851 | cursorRuntimeAgentPolicy({ |
| 852 | ...base, |
| 853 | runtimeMode: "approval-required", |
| 854 | approvalPolicy: "never", |
| 855 | sandboxPolicy: { type: "dangerFullAccess" }, |
| 856 | }), |
| 857 | { |
| 858 | autoReview: false, |
| 859 | sandboxEnabled: false, |
| 860 | }, |
| 861 | ); |
| 862 | }); |
| 863 | |
| 864 | it("loads the user's Cursor settings layers in every runtime mode", () => { |
| 865 | // The SDK loads no rules, skills, hooks, or MCP config from disk unless |
| 866 | // settingSources names them, so an omitted list silently drops AGENTS.md. |
| 867 | for (const runtimeMode of ["full-access", "auto-accept-edits", "approval-required"] as const) { |
| 868 | const options = makeCursorAgentOptions({ |
| 869 | modelSelection: { |
| 870 | instanceId: ProviderInstanceId.make("cursor"), |
| 871 | model: "composer-2.5", |
| 872 | }, |
| 873 | runtimePolicy: { runtimeMode, interactionMode: "default", cwd: "/workspace" }, |
| 874 | threadId: ThreadId.make("thread-cursor-setting-sources"), |
| 875 | mcpSession: undefined, |
| 876 | }); |
| 877 | assert.deepEqual(options.local?.settingSources, [ |
| 878 | "project", |
| 879 | "user", |
| 880 | "team", |
| 881 | "mdm", |
| 882 | "plugins", |
| 883 | ]); |
| 884 | } |
| 885 | }); |
| 886 | |
| 887 | it("injects thread-scoped MCP credentials without logging them", () => { |
| 888 | const threadId = ThreadId.make("thread-cursor-mcp"); |
| 889 | const mcpSession = { |
| 890 | environmentId: EnvironmentId.make("environment-cursor-mcp"), |
| 891 | threadId, |
| 892 | providerSessionId: "mcp-session-cursor", |
| 893 | providerInstanceId: ProviderInstanceId.make("cursor"), |
| 894 | endpoint: "http://127.0.0.1:43123/mcp", |
| 895 | authorizationHeader: "Bearer secret-cursor-mcp-token", |
| 896 | browserToolsAvailable: true, |
| 897 | }; |
| 898 | |
| 899 | assert.deepEqual(cursorMcpServers(mcpSession), { |
| 900 | "t3-code": { |
| 901 | type: "http", |
| 902 | url: "http://127.0.0.1:43123/mcp", |
| 903 | headers: { |
| 904 | Authorization: "Bearer secret-cursor-mcp-token", |
| 905 | }, |
| 906 | }, |
| 907 | }); |
| 908 | |
| 909 | const options = makeCursorAgentOptions({ |
| 910 | apiKey: "secret-cursor-api-key", |
| 911 | modelSelection: { |
| 912 | instanceId: ProviderInstanceId.make("cursor"), |
| 913 | model: "composer-2.5", |
| 914 | }, |
| 915 | runtimePolicy: { |
| 916 | runtimeMode: "full-access", |
| 917 | interactionMode: "default", |
| 918 | cwd: "/workspace", |
| 919 | }, |
| 920 | threadId, |
| 921 | mcpSession, |
| 922 | }); |
| 923 | assert.deepEqual(options.mcpServers, cursorMcpServers(mcpSession)); |
| 924 | |
| 925 | const logged = JSON.stringify(CursorAgentSdk.loggedCursorAgentOptions(options)); |
| 926 | assert.notInclude(logged, "secret-cursor-api-key"); |
| 927 | assert.notInclude(logged, "secret-cursor-mcp-token"); |
| 928 | }); |
| 929 | |
| 930 | it("recognizes direct and SDK-wrapped abort failures as cancellation", () => { |
| 931 | assert.isTrue(CursorAgentSdk.isCursorCancellationError({ name: "AbortError" })); |
| 932 | assert.isTrue( |
| 933 | CursorAgentSdk.isCursorCancellationError({ |
| 934 | name: "ConnectError", |
| 935 | cause: { |
| 936 | name: "ConnectError", |
| 937 | cause: { name: "AbortError" }, |
| 938 | }, |
| 939 | }), |
| 940 | ); |
| 941 | assert.isFalse(CursorAgentSdk.isCursorCancellationError(new Error("request failed"))); |
| 942 | assert.isFalse(CursorAgentSdk.isCursorCancellationError(null)); |
| 943 | }); |
| 944 | |
| 945 | it("preserves failed nested read calls when Cursor omits their path", () => { |
| 946 | assert.deepEqual( |
| 947 | nestedToolCallFromEnvelope({ |
| 948 | toolCallId: "tool:failed-read", |
| 949 | readToolCall: { |
| 950 | args: {}, |
| 951 | result: { error: "File path was not provided." }, |
| 952 | }, |
| 953 | }), |
| 954 | { |
| 955 | callId: "tool:failed-read", |
| 956 | toolCall: { |
| 957 | type: "read", |
| 958 | args: { path: "<unknown path>" }, |
| 959 | result: { |
| 960 | status: "error", |
| 961 | error: "File path was not provided.", |
| 962 | }, |
| 963 | }, |
| 964 | }, |
| 965 | ); |
| 966 | }); |
| 967 | }); |