1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import type { InteractionUpdate } from "@cursor/sdk";
4import {
5 EnvironmentId,
6 MessageId,
7 NodeId,
8 ProjectId,
9 ProviderInstanceId,
10 ProviderSessionId,
11 RunAttemptId,
12 RunId,
13 ThreadId,
14} from "@t3tools/contracts";
15import { CursorSettings } from "../settings.ts";
16import * as DateTime from "effect/DateTime";
17import * as Effect from "effect/Effect";
18import * as FileSystem from "effect/FileSystem";
19import * as Layer from "effect/Layer";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import * as Stream from "effect/Stream";
24
25import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
26import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
27import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
28import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
29import {
30 cursorMcpServers,
31 cursorRuntimeAgentPolicy,
32 cursorSdkModelSelection,
33 makeCursorAgentOptions,
34 makeCursorAdapterV2,
35 nestedToolCallFromEnvelope,
36} from "./adapter.ts";
37import * as CursorAgentSdk from "./CursorAgentSdk.ts";
38
39const decodeCursorSettings = Schema.decodeEffect(CursorSettings);
40
41describe("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});