| author | |
| committer | |
| log | 33806e73555107b8ac8fb18fd15b9dfb87ec6557 |
| tree | 54901ad6c38609e39e20bcc9448e6e7b4186fae4 |
| parent | f853d514cab475e73468f7cb7bab1aae48dbe549 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>18 files changed, 378 insertions(+), 360 deletions(-)
apps/server/scripts/record-cursor-agent-sdk-replay-fixture.ts+3-1| ... | @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Config from "effect/Config"; | 3 | import * as Config from "effect/Config"; |
| 4 | import * as Console from "effect/Console"; | 4 | import * as Console from "effect/Console"; |
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | 7 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Option from "effect/Option"; | 8 | import * as Option from "effect/Option"; |
| 8 | import * as Path from "effect/Path"; | 9 | import * as Path from "effect/Path"; |
| ... | @@ -10,6 +11,7 @@ import * as Redacted from "effect/Redacted"; | ... | @@ -10,6 +11,7 @@ import * as Redacted from "effect/Redacted"; |
| 10 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 11 | import * as Struct from "effect/Struct"; | 12 | import * as Struct from "effect/Struct"; |
| 12 | import { Command, Flag } from "effect/cli"; | 13 | import { Command, Flag } from "effect/cli"; |
| 14 | import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk"; | ||
| 13 | 15 | ||
| 14 | import { | 16 | import { |
| 15 | recordCursorAgentSdkReplayTranscript, | 17 | recordCursorAgentSdkReplayTranscript, |
| ... | @@ -219,6 +221,6 @@ const recordCursorReplayCommand = Command.make( | ... | @@ -219,6 +221,6 @@ const recordCursorReplayCommand = Command.make( |
| 219 | ); | 221 | ); |
| 220 | 222 | ||
| 221 | Command.run(recordCursorReplayCommand, { version: "0.0.0" }).pipe( | 223 | Command.run(recordCursorReplayCommand, { version: "0.0.0" }).pipe( |
| 222 | Effect.provide(NodeServices.layer), | 224 | Effect.provide(Layer.mergeAll(CursorSdk.layer, NodeServices.layer)), |
| 223 | NodeRuntime.runMain, | 225 | NodeRuntime.runMain, |
| 224 | ); | 226 | ); |
apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.testkit.ts+4-4| ... | @@ -213,7 +213,7 @@ function replayRunnerError( | ... | @@ -213,7 +213,7 @@ function replayRunnerError( |
| 213 | 213 | ||
| 214 | export function makeCursorAgentSdkReplayRunner( | 214 | export function makeCursorAgentSdkReplayRunner( |
| 215 | transcript: CursorAgentSdkReplayTranscript, | 215 | transcript: CursorAgentSdkReplayTranscript, |
| 216 | ): CursorAgentSdk.CursorAgentSdkRunnerShape { | 216 | ): CursorAgentSdk.CursorAgentSdkRunner["Service"] { |
| 217 | let cursor = 0; | 217 | let cursor = 0; |
| 218 | let failure: CursorAgentSdkReplayError | null = null; | 218 | let failure: CursorAgentSdkReplayError | null = null; |
| 219 | let cursorAdvanced = makeSignal(); | 219 | let cursorAdvanced = makeSignal(); |
| ... | @@ -491,7 +491,7 @@ export function makeCursorAgentSdkReplayRunner( | ... | @@ -491,7 +491,7 @@ export function makeCursorAgentSdkReplayRunner( |
| 491 | function layerCursorAgentSdkReplay( | 491 | function layerCursorAgentSdkReplay( |
| 492 | transcript: CursorAgentSdkReplayTranscript, | 492 | transcript: CursorAgentSdkReplayTranscript, |
| 493 | options?: { | 493 | options?: { |
| 494 | readonly runner?: CursorAgentSdk.CursorAgentSdkRunnerShape; | 494 | readonly runner?: CursorAgentSdk.CursorAgentSdkRunner["Service"]; |
| 495 | readonly assertCompleteOnFinalize?: boolean; | 495 | readonly assertCompleteOnFinalize?: boolean; |
| 496 | }, | 496 | }, |
| 497 | ): Layer.Layer<CursorAgentSdk.CursorAgentSdkRunner> { | 497 | ): Layer.Layer<CursorAgentSdk.CursorAgentSdkRunner> { |
| ... | @@ -512,7 +512,7 @@ function layerCursorAgentSdkReplay( | ... | @@ -512,7 +512,7 @@ function layerCursorAgentSdkReplay( |
| 512 | export function layer( | 512 | export function layer( |
| 513 | transcript: CursorAgentSdkReplayTranscript, | 513 | transcript: CursorAgentSdkReplayTranscript, |
| 514 | options?: { | 514 | options?: { |
| 515 | readonly runner?: CursorAgentSdk.CursorAgentSdkRunnerShape; | 515 | readonly runner?: CursorAgentSdk.CursorAgentSdkRunner["Service"]; |
| 516 | readonly assertCompleteOnFinalize?: boolean; | 516 | readonly assertCompleteOnFinalize?: boolean; |
| 517 | }, | 517 | }, |
| 518 | ) { | 518 | ) { |
| ... | @@ -807,7 +807,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn( | ... | @@ -807,7 +807,7 @@ export const recordCursorAgentSdkReplayTranscript = Effect.fn( |
| 807 | heldUntilCancel = []; | 807 | heldUntilCancel = []; |
| 808 | } | 808 | } |
| 809 | }); | 809 | }); |
| 810 | const runner = CursorAgentSdk.makeCursorAgentSdkRunner(() => recordFrame); | 810 | const runner = yield* CursorAgentSdk.makeCursorAgentSdkRunner(() => recordFrame); |
| 811 | 811 | ||
| 812 | const awaitSignal = (signal: Deferred.Deferred<void>, description: string) => | 812 | const awaitSignal = (signal: Deferred.Deferred<void>, description: string) => |
| 813 | Deferred.await(signal).pipe( | 813 | Deferred.await(signal).pipe( |
apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts+3-1| ... | @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -3,6 +3,7 @@ import * as Layer from "effect/Layer"; |
| 3 | import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; | 3 | import * as ClaudeAdapterV2 from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; |
| 4 | import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; | 4 | import * as CodexAdapterV2 from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; |
| 5 | import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; | 5 | import * as CursorAgentSdk from "@t3tools/provider-cursor/server/CursorAgentSdk"; |
| 6 | import * as CursorSdk from "@t3tools/provider-cursor/server/CursorSdk"; | ||
| 6 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 7 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 7 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; | 8 | import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; |
| 8 | 9 | ||
| ... | @@ -10,6 +11,7 @@ export type ProviderOrchestrationAdapterInfrastructure = | ... | @@ -10,6 +11,7 @@ export type ProviderOrchestrationAdapterInfrastructure = |
| 10 | | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner | 11 | | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner |
| 11 | | CodexAdapterV2.CodexAppServerClientFactory | 12 | | CodexAdapterV2.CodexAppServerClientFactory |
| 12 | | CursorAgentSdk.CursorAgentSdkRunner | 13 | | CursorAgentSdk.CursorAgentSdkRunner |
| 14 | | CursorSdk.CursorSdk | ||
| 13 | | IdAllocator.IdAllocatorV2; | 15 | | IdAllocator.IdAllocatorV2; |
| 14 | 16 | ||
| 15 | /** | 17 | /** |
| ... | @@ -21,7 +23,7 @@ export type ProviderOrchestrationAdapterInfrastructure = | ... | @@ -21,7 +23,7 @@ export type ProviderOrchestrationAdapterInfrastructure = |
| 21 | export const layer = Layer.mergeAll( | 23 | export const layer = Layer.mergeAll( |
| 22 | ClaudeAdapterV2.layerQueryRunner, | 24 | ClaudeAdapterV2.layerQueryRunner, |
| 23 | CodexAdapterV2.layerAppServerClientFactory, | 25 | CodexAdapterV2.layerAppServerClientFactory, |
| 24 | CursorAgentSdk.layer, | 26 | CursorAgentSdk.layer.pipe(Layer.provideMerge(CursorSdk.layer)), |
| 25 | IdAllocator.layer, | 27 | IdAllocator.layer, |
| 26 | ProviderContinuationRequests.layer, | 28 | ProviderContinuationRequests.layer, |
| 27 | ); | 29 | ); |
packages/provider-cursor/package.json+4| ... | @@ -22,6 +22,10 @@ | ... | @@ -22,6 +22,10 @@ |
| 22 | "./server/CursorAgentSdk": { | 22 | "./server/CursorAgentSdk": { |
| 23 | "types": "./src/server/CursorAgentSdk.ts", | 23 | "types": "./src/server/CursorAgentSdk.ts", |
| 24 | "import": "./src/server/CursorAgentSdk.ts" | 24 | "import": "./src/server/CursorAgentSdk.ts" |
| 25 | }, | ||
| 26 | "./server/CursorSdk": { | ||
| 27 | "types": "./src/server/CursorSdk.ts", | ||
| 28 | "import": "./src/server/CursorSdk.ts" | ||
| 25 | } | 29 | } |
| 26 | }, | 30 | }, |
| 27 | "scripts": { | 31 | "scripts": { |
packages/provider-cursor/src/server/CursorAgentSdk.test.ts+3| ... | @@ -7,6 +7,7 @@ import { vi } from "vite-plus/test"; | ... | @@ -7,6 +7,7 @@ import { vi } from "vite-plus/test"; |
| 7 | 7 | ||
| 8 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | 8 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 9 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | 9 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; |
| 10 | import * as CursorSdk from "./CursorSdk.ts"; | ||
| 10 | 11 | ||
| 11 | const cursorSdkMock = vi.hoisted(() => { | 12 | const cursorSdkMock = vi.hoisted(() => { |
| 12 | const closeExecutionOrder: Array<string> = []; | 13 | const closeExecutionOrder: Array<string> = []; |
| ... | @@ -102,6 +103,7 @@ vi.mock("./sdk.ts", () => ({ | ... | @@ -102,6 +103,7 @@ vi.mock("./sdk.ts", () => ({ |
| 102 | list: vi.fn(async () => []), | 103 | list: vi.fn(async () => []), |
| 103 | }, | 104 | }, |
| 104 | }, | 105 | }, |
| 106 | createAgentPlatform: vi.fn(), | ||
| 105 | })); | 107 | })); |
| 106 | 108 | ||
| 107 | const layerTest = CursorAgentSdk.layer.pipe( | 109 | const layerTest = CursorAgentSdk.layer.pipe( |
| ... | @@ -124,6 +126,7 @@ const layerTest = CursorAgentSdk.layer.pipe( | ... | @@ -124,6 +126,7 @@ const layerTest = CursorAgentSdk.layer.pipe( |
| 124 | canonical: undefined, | 126 | canonical: undefined, |
| 125 | }), | 127 | }), |
| 126 | ), | 128 | ), |
| 129 | Layer.provide(CursorSdk.layer), | ||
| 127 | Layer.provide(NodeServices.layer), | 130 | Layer.provide(NodeServices.layer), |
| 128 | ); | 131 | ); |
| 129 | 132 |
packages/provider-cursor/src/server/CursorAgentSdk.ts+51-49| ... | @@ -12,11 +12,13 @@ import { | ... | @@ -12,11 +12,13 @@ import { |
| 12 | type ThreadId, | 12 | type ThreadId, |
| 13 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 14 | import * as Context from "effect/Context"; | 14 | import * as Context from "effect/Context"; |
| 15 | import * as Deferred from "effect/Deferred"; | ||
| 15 | import * as Effect from "effect/Effect"; | 16 | import * as Effect from "effect/Effect"; |
| 16 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 18 | import * as Ref from "effect/Ref"; | ||
| 17 | import * as Schema from "effect/Schema"; | 19 | import * as Schema from "effect/Schema"; |
| 18 | 20 | ||
| 19 | import { Agent, createAgentPlatform } from "./sdk.ts"; | 21 | import * as CursorSdk from "./CursorSdk.ts"; |
| 20 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | 22 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 21 | 23 | ||
| 22 | export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; | 24 | export const CURSOR_AGENT_SDK_PROTOCOL = "cursor-agent-sdk.local" as const; |
| ... | @@ -66,16 +68,14 @@ export interface CursorAgentSdkSession { | ... | @@ -66,16 +68,14 @@ export interface CursorAgentSdkSession { |
| 66 | readonly close: Effect.Effect<void, CursorAgentSdkRunnerError>; | 68 | readonly close: Effect.Effect<void, CursorAgentSdkRunnerError>; |
| 67 | } | 69 | } |
| 68 | 70 | ||
| 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< | 71 | export class CursorAgentSdkRunner extends Context.Service< |
| 77 | CursorAgentSdkRunner, | 72 | CursorAgentSdkRunner, |
| 78 | CursorAgentSdkRunnerShape | 73 | { |
| 74 | readonly open: ( | ||
| 75 | input: CursorAgentSdkOpenInput, | ||
| 76 | ) => Effect.Effect<CursorAgentSdkSession, CursorAgentSdkRunnerError>; | ||
| 77 | readonly assertComplete: Effect.Effect<void, CursorAgentSdkRunnerError>; | ||
| 78 | } | ||
| 79 | >()("@t3tools/provider-cursor/server/CursorAgentSdk/CursorAgentSdkRunner") {} | 79 | >()("@t3tools/provider-cursor/server/CursorAgentSdk/CursorAgentSdkRunner") {} |
| 80 | 80 | ||
| 81 | export interface CursorAgentSdkLoggedAgentOptions { | 81 | export interface CursorAgentSdkLoggedAgentOptions { |
| ... | @@ -298,49 +298,55 @@ function makeCursorAgentSdkProtocolLogger(input: { | ... | @@ -298,49 +298,55 @@ function makeCursorAgentSdkProtocolLogger(input: { |
| 298 | .pipe(Effect.ignore); | 298 | .pipe(Effect.ignore); |
| 299 | } | 299 | } |
| 300 | 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 | /** | 301 | /** |
| 332 | * Runs agents through the Cursor SDK, logging every frame to the protocol | 302 | * 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 | 303 | * 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 | 304 | * provider event log; the replay recorder turns the same frames into a |
| 335 | * transcript. | 305 | * transcript. |
| 336 | */ | 306 | */ |
| 337 | export function makeCursorAgentSdkRunner( | 307 | export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(function* ( |
| 338 | protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined, | 308 | protocolLoggerFor: (input: CursorAgentSdkOpenInput) => CursorAgentSdkProtocolLogger | undefined, |
| 339 | ): CursorAgentSdkRunnerShape { | 309 | ) { |
| 310 | const { Agent, createAgentPlatform } = yield* CursorSdk.CursorSdk; | ||
| 311 | |||
| 312 | /** | ||
| 313 | * The Cursor SDK decides once per process whether local sandboxing works, and | ||
| 314 | * caches the answer the first time any run starts. Only sandboxed runs point | ||
| 315 | * it at its `cursorsandbox` helper first, so after an unsandboxed (Full access) | ||
| 316 | * run it caches "unsupported" and rejects every later sandboxed run until the | ||
| 317 | * server restarts. Warming a bare sandboxed executor before the first | ||
| 318 | * unsandboxed agent opens lets the SDK find the helper and cache the real | ||
| 319 | * answer. Warming is best effort: on a machine without sandbox support it | ||
| 320 | * fails, the SDK caches "unsupported", and sandboxed runs report that as | ||
| 321 | * before. | ||
| 322 | */ | ||
| 323 | const sandboxSupportPrimeStarted = yield* Ref.make(false); | ||
| 324 | const sandboxSupportPrimed = yield* Deferred.make<void>(); | ||
| 325 | const primeCursorSandboxSupport = Effect.fn("CursorAgentSdkRunner.primeSandboxSupport")( | ||
| 326 | function* (options: AgentOptions) { | ||
| 327 | if (yield* Ref.getAndSet(sandboxSupportPrimeStarted, true)) { | ||
| 328 | return yield* Deferred.await(sandboxSupportPrimed); | ||
| 329 | } | ||
| 330 | yield* Effect.tryPromise(async () => { | ||
| 331 | const cwd = typeof options.local?.cwd === "string" ? options.local.cwd : undefined; | ||
| 332 | const platform = await createAgentPlatform(cwd === undefined ? {} : { workspaceRef: cwd }); | ||
| 333 | const release = await platform.prewarmLocalWorkspace({ | ||
| 334 | ...(options.apiKey === undefined ? {} : { apiKey: options.apiKey }), | ||
| 335 | local: { | ||
| 336 | ...(cwd === undefined ? {} : { cwd }), | ||
| 337 | settingSources: [], | ||
| 338 | sandboxOptions: { enabled: true }, | ||
| 339 | }, | ||
| 340 | }); | ||
| 341 | await release(); | ||
| 342 | }).pipe(Effect.ignore, Effect.ensuring(Deferred.succeed(sandboxSupportPrimed, undefined))); | ||
| 343 | }, | ||
| 344 | ); | ||
| 345 | |||
| 340 | return CursorAgentSdkRunner.of({ | 346 | return CursorAgentSdkRunner.of({ |
| 341 | open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) { | 347 | open: Effect.fn("CursorAgentSdkRunner.open")(function* (input) { |
| 342 | if (input.options.local?.sandboxOptions?.enabled === false) { | 348 | if (input.options.local?.sandboxOptions?.enabled === false) { |
| 343 | yield* Effect.promise(() => primeCursorSandboxSupport(input.options)); | 349 | yield* primeCursorSandboxSupport(input.options); |
| 344 | } | 350 | } |
| 345 | const protocolLogger = protocolLoggerFor(input); | 351 | const protocolLogger = protocolLoggerFor(input); |
| 346 | const log = (event: CursorAgentSdkProtocolLogEvent) => | 352 | const log = (event: CursorAgentSdkProtocolLogEvent) => |
| ... | @@ -590,17 +596,13 @@ export function makeCursorAgentSdkRunner( | ... | @@ -590,17 +596,13 @@ export function makeCursorAgentSdkRunner( |
| 590 | }), | 596 | }), |
| 591 | assertComplete: Effect.void, | 597 | assertComplete: Effect.void, |
| 592 | }); | 598 | }); |
| 593 | } | 599 | }); |
| 594 | 600 | ||
| 595 | export const layer: Layer.Layer< | 601 | export const layer = Layer.effect( |
| 596 | CursorAgentSdkRunner, | ||
| 597 | never, | ||
| 598 | ProviderEventLoggers.ProviderEventLoggers | ||
| 599 | > = Layer.effect( | ||
| 600 | CursorAgentSdkRunner, | 602 | CursorAgentSdkRunner, |
| 601 | Effect.gen(function* () { | 603 | Effect.gen(function* () { |
| 602 | const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; | 604 | const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; |
| 603 | return makeCursorAgentSdkRunner((input) => | 605 | return yield* makeCursorAgentSdkRunner((input) => |
| 604 | makeCursorAgentSdkProtocolLogger({ | 606 | makeCursorAgentSdkProtocolLogger({ |
| 605 | nativeEventLogger, | 607 | nativeEventLogger, |
| 606 | threadId: input.threadId, | 608 | threadId: input.threadId, |
packages/provider-cursor/src/server/CursorSdk.test.ts created+183| ... | @@ -0,0 +1,183 @@ | ||
| 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 | import { isCursorShellSpawnFailure } from "./CursorSdk.ts"; | ||
| 6 | |||
| 7 | const cursorSdkUrl = new URL("./CursorSdk.ts", import.meta.url).href; | ||
| 8 | |||
| 9 | // Builds the CursorSdk layer in a fresh process. @cursor/sdk is stubbed so the guard | ||
| 10 | // can be tested without the real package. The vitest worker already has its | ||
| 11 | // own unhandledRejection listener, which would hide the process-exit behavior. | ||
| 12 | const probeProgram = ` | ||
| 13 | import { mkdtempSync, writeFileSync } from "node:fs"; | ||
| 14 | import { tmpdir } from "node:os"; | ||
| 15 | import { join } from "node:path"; | ||
| 16 | import { registerHooks } from "node:module"; | ||
| 17 | import { pathToFileURL } from "node:url"; | ||
| 18 | import { spawn } from "node:child_process"; | ||
| 19 | |||
| 20 | const dir = mkdtempSync(join(tmpdir(), "cursor-sdk-stub-")); | ||
| 21 | const stub = join(dir, "stub.cjs"); | ||
| 22 | writeFileSync( | ||
| 23 | stub, | ||
| 24 | "module.exports = { Agent: {}, AuthenticationError: class {}, createAgentPlatform: () => ({}), Cursor: {}, CursorSdkError: class {}, InMemoryCredentialStore: class {} };", | ||
| 25 | ); | ||
| 26 | registerHooks({ | ||
| 27 | resolve(specifier, context, next) { | ||
| 28 | if (specifier === "@cursor/sdk") { | ||
| 29 | return { url: pathToFileURL(stub).href, shortCircuit: true }; | ||
| 30 | } | ||
| 31 | return next(specifier, context); | ||
| 32 | }, | ||
| 33 | }); | ||
| 34 | |||
| 35 | const { layer } = await import(${JSON.stringify(cursorSdkUrl)}); | ||
| 36 | const Effect = await import("effect/Effect"); | ||
| 37 | const Layer = await import("effect/Layer"); | ||
| 38 | const Exit = await import("effect/Exit"); | ||
| 39 | const Scope = await import("effect/Scope"); | ||
| 40 | const scope = Scope.makeUnsafe(); | ||
| 41 | await Effect.runPromise(Layer.buildWithScope(layer, scope)); | ||
| 42 | const mode = process.argv[1]; | ||
| 43 | const missingCwd = join(tmpdir(), "t3-missing-cwd-" + process.pid); | ||
| 44 | |||
| 45 | if (mode === "cursor-shell") { | ||
| 46 | const child = spawn("/bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"], { | ||
| 47 | cwd: missingCwd, | ||
| 48 | }); | ||
| 49 | child.on("error", (error) => { | ||
| 50 | Promise.reject(error); | ||
| 51 | }); | ||
| 52 | setTimeout(() => process.exit(0), 500); | ||
| 53 | } else if (mode === "released") { | ||
| 54 | await Effect.runPromise(Scope.close(scope, Exit.void)); | ||
| 55 | Promise.reject( | ||
| 56 | Object.assign(new Error("spawn /bin/zsh ENOENT"), { | ||
| 57 | code: "ENOENT", | ||
| 58 | syscall: "spawn /bin/zsh", | ||
| 59 | spawnargs: ["-c", "dump_zsh_state >&4"], | ||
| 60 | }), | ||
| 61 | ); | ||
| 62 | setTimeout(() => process.exit(0), 500); | ||
| 63 | } else if (mode === "other-spawn") { | ||
| 64 | Promise.reject( | ||
| 65 | Object.assign(new Error("spawn git ENOENT"), { | ||
| 66 | code: "ENOENT", | ||
| 67 | syscall: "spawn git", | ||
| 68 | path: "git", | ||
| 69 | spawnargs: ["status"], | ||
| 70 | }), | ||
| 71 | ); | ||
| 72 | setTimeout(() => process.exit(0), 500); | ||
| 73 | } else if (mode === "other-rejection") { | ||
| 74 | Promise.reject(new Error("boom")); | ||
| 75 | setTimeout(() => process.exit(0), 500); | ||
| 76 | } else if (mode === "spawn-without-listener") { | ||
| 77 | spawn(process.execPath, ["-e", "process.exit(0)"], { cwd: missingCwd }); | ||
| 78 | setTimeout(() => process.exit(0), 500); | ||
| 79 | } else { | ||
| 80 | console.error("Unknown cursor shell spawn guard probe: " + mode); | ||
| 81 | process.exit(2); | ||
| 82 | } | ||
| 83 | `; | ||
| 84 | |||
| 85 | function runProbe(mode: string): Promise<{ code: number | null; stdout: string; stderr: string }> { | ||
| 86 | return new Promise((resolve, reject) => { | ||
| 87 | const child = NodeChildProcess.spawn( | ||
| 88 | process.execPath, | ||
| 89 | [ | ||
| 90 | "--experimental-strip-types", | ||
| 91 | "--disable-warning=ExperimentalWarning", | ||
| 92 | "--input-type=module", | ||
| 93 | "-e", | ||
| 94 | probeProgram, | ||
| 95 | mode, | ||
| 96 | ], | ||
| 97 | { stdio: ["ignore", "pipe", "pipe"] }, | ||
| 98 | ); | ||
| 99 | let stdout = ""; | ||
| 100 | let stderr = ""; | ||
| 101 | child.stdout.setEncoding("utf8"); | ||
| 102 | child.stderr.setEncoding("utf8"); | ||
| 103 | child.stdout.on("data", (chunk: string) => { | ||
| 104 | stdout += chunk; | ||
| 105 | }); | ||
| 106 | child.stderr.on("data", (chunk: string) => { | ||
| 107 | stderr += chunk; | ||
| 108 | }); | ||
| 109 | const timer = setTimeout(() => { | ||
| 110 | child.kill(); | ||
| 111 | reject(new Error(`probe ${mode} timed out`)); | ||
| 112 | }, 5_000); | ||
| 113 | child.on("error", reject); | ||
| 114 | child.on("exit", (code) => { | ||
| 115 | clearTimeout(timer); | ||
| 116 | resolve({ code, stdout, stderr }); | ||
| 117 | }); | ||
| 118 | }); | ||
| 119 | } | ||
| 120 | |||
| 121 | describe("isCursorShellSpawnFailure", () => { | ||
| 122 | it("matches only Cursor's shell wrapper", () => { | ||
| 123 | const spawnFailure = (syscall: string, spawnargs: ReadonlyArray<string>) => | ||
| 124 | Object.assign(new Error(`${syscall} ENOENT`), { code: "ENOENT", syscall, spawnargs }); | ||
| 125 | expect( | ||
| 126 | isCursorShellSpawnFailure( | ||
| 127 | spawnFailure("spawn /bin/zsh", ["-c", "dump_zsh_state >&4", "--", "true"]), | ||
| 128 | ), | ||
| 129 | ).toBe(true); | ||
| 130 | expect( | ||
| 131 | isCursorShellSpawnFailure(spawnFailure("spawn bash", ["-c", "dump_bash_state >&4"])), | ||
| 132 | ).toBe(true); | ||
| 133 | expect( | ||
| 134 | isCursorShellSpawnFailure( | ||
| 135 | spawnFailure("spawn /bin/zsh", ["-c", 'builtin eval "${__CURSOR_SANDBOX_ENV_RESTORE:-}"']), | ||
| 136 | ), | ||
| 137 | ).toBe(true); | ||
| 138 | expect(isCursorShellSpawnFailure(spawnFailure("spawn git", ["status"]))).toBe(false); | ||
| 139 | expect(isCursorShellSpawnFailure(spawnFailure("spawn /bin/zsh", ["-lc", "true"]))).toBe(false); | ||
| 140 | expect( | ||
| 141 | isCursorShellSpawnFailure( | ||
| 142 | Object.assign(new Error("open failed"), { code: "ENOENT", syscall: "open" }), | ||
| 143 | ), | ||
| 144 | ).toBe(false); | ||
| 145 | expect(isCursorShellSpawnFailure(new Error("boom"))).toBe(false); | ||
| 146 | expect(isCursorShellSpawnFailure("spawn ENOENT")).toBe(false); | ||
| 147 | }); | ||
| 148 | }); | ||
| 149 | |||
| 150 | describe("Cursor shell spawn guard", () => { | ||
| 151 | it("keeps the process alive when Cursor's shell spawn rejects", async () => { | ||
| 152 | const result = await runProbe("cursor-shell"); | ||
| 153 | expect(result.code).toBe(0); | ||
| 154 | expect(result.stderr).toContain("The server will keep running."); | ||
| 155 | expect(result.stderr).toContain("ENOENT"); | ||
| 156 | }); | ||
| 157 | |||
| 158 | it("still exits when a different spawn failure is unhandled", async () => { | ||
| 159 | const result = await runProbe("other-spawn"); | ||
| 160 | expect(result.code).toBe(1); | ||
| 161 | expect(result.stderr).toContain("spawn git ENOENT"); | ||
| 162 | expect(result.stderr).not.toContain("The server will keep running."); | ||
| 163 | }); | ||
| 164 | |||
| 165 | it("still exits on unrelated unhandled rejections", async () => { | ||
| 166 | const result = await runProbe("other-rejection"); | ||
| 167 | expect(result.code).toBe(1); | ||
| 168 | expect(result.stderr).toContain("boom"); | ||
| 169 | }); | ||
| 170 | |||
| 171 | it("removes the guard when the layer is released", async () => { | ||
| 172 | const result = await runProbe("released"); | ||
| 173 | expect(result.code).toBe(1); | ||
| 174 | expect(result.stderr).not.toContain("The server will keep running."); | ||
| 175 | }); | ||
| 176 | |||
| 177 | it("leaves a spawn with no error listener fatal", async () => { | ||
| 178 | const result = await runProbe("spawn-without-listener"); | ||
| 179 | expect(result.code).toBe(1); | ||
| 180 | expect(result.stderr).toContain("Unhandled"); | ||
| 181 | expect(result.stderr).not.toContain("The server will keep running."); | ||
| 182 | }); | ||
| 183 | }); | ||
packages/provider-cursor/src/server/CursorSdk.ts created+83| ... | @@ -0,0 +1,83 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- The guard runs in Node's unhandled-rejection callback, outside an Effect runtime. stderr must match Node's default unhandled-rejection print. | ||
| 2 | import * as Context from "effect/Context"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | |||
| 6 | import { Agent, createAgentPlatform } from "./sdk.ts"; | ||
| 7 | |||
| 8 | /** | ||
| 9 | * The Cursor Agent SDK runs in this process and spawns a shell for tool | ||
| 10 | * calls. The command is Cursor's sandbox wrapper (`dump_zsh_state`, | ||
| 11 | * `dump_bash_state`, `__CURSOR_SANDBOX_ENV_RESTORE`). A bad working directory | ||
| 12 | * makes Node report `spawn /bin/zsh ENOENT`. The SDK listens for that error, | ||
| 13 | * then rejects an internal promise nothing awaits. Node exits on that | ||
| 14 | * unhandled rejection and takes the server with it. | ||
| 15 | * | ||
| 16 | * The guard ignores only that rejection. Every other unhandled rejection | ||
| 17 | * still prints and exits when this is the process's only listener, matching | ||
| 18 | * Node's default. `child_process.spawn` itself is left alone, so git, | ||
| 19 | * terminals, and other providers keep their own error handling. | ||
| 20 | */ | ||
| 21 | const CURSOR_SHELL_SPAWN_MARKERS = [ | ||
| 22 | "dump_zsh_state", | ||
| 23 | "dump_bash_state", | ||
| 24 | "__CURSOR_SANDBOX_ENV_RESTORE", | ||
| 25 | ] as const; | ||
| 26 | |||
| 27 | export function isCursorShellSpawnFailure(reason: unknown): boolean { | ||
| 28 | if (typeof reason !== "object" || reason === null) { | ||
| 29 | return false; | ||
| 30 | } | ||
| 31 | const syscall = Reflect.get(reason, "syscall"); | ||
| 32 | const code = Reflect.get(reason, "code"); | ||
| 33 | const spawnargs = Reflect.get(reason, "spawnargs"); | ||
| 34 | if (typeof syscall !== "string" || !syscall.startsWith("spawn") || typeof code !== "string") { | ||
| 35 | return false; | ||
| 36 | } | ||
| 37 | if (!Array.isArray(spawnargs)) { | ||
| 38 | return false; | ||
| 39 | } | ||
| 40 | return spawnargs.some( | ||
| 41 | (arg) => | ||
| 42 | typeof arg === "string" && CURSOR_SHELL_SPAWN_MARKERS.some((marker) => arg.includes(marker)), | ||
| 43 | ); | ||
| 44 | } | ||
| 45 | |||
| 46 | function onUnhandledRejection(reason: unknown): void { | ||
| 47 | if (isCursorShellSpawnFailure(reason)) { | ||
| 48 | console.error("Cursor shell spawn failed. The server will keep running.", reason); | ||
| 49 | return; | ||
| 50 | } | ||
| 51 | // Another handler registered besides this one owns the decision. | ||
| 52 | if (process.listenerCount("unhandledRejection") > 1) { | ||
| 53 | return; | ||
| 54 | } | ||
| 55 | console.error(reason); | ||
| 56 | process.exit(1); | ||
| 57 | } | ||
| 58 | |||
| 59 | /** | ||
| 60 | * The SDK entry points that run agents in this process. Reaching them through | ||
| 61 | * this service keeps the shell spawn guard armed while they are in use. | ||
| 62 | */ | ||
| 63 | export class CursorSdk extends Context.Service< | ||
| 64 | CursorSdk, | ||
| 65 | { | ||
| 66 | readonly Agent: typeof Agent; | ||
| 67 | readonly createAgentPlatform: typeof createAgentPlatform; | ||
| 68 | } | ||
| 69 | >()("@t3tools/provider-cursor/server/CursorSdk") {} | ||
| 70 | |||
| 71 | /** Provide once per process: a second guard would defer to the first and swallow real rejections. */ | ||
| 72 | export const layer = Layer.effect( | ||
| 73 | CursorSdk, | ||
| 74 | Effect.acquireRelease( | ||
| 75 | Effect.sync(() => { | ||
| 76 | process.on("unhandledRejection", onUnhandledRejection); | ||
| 77 | }), | ||
| 78 | () => | ||
| 79 | Effect.sync(() => { | ||
| 80 | process.off("unhandledRejection", onUnhandledRejection); | ||
| 81 | }), | ||
| 82 | ).pipe(Effect.as(CursorSdk.of({ Agent, createAgentPlatform }))), | ||
| 83 | ); | ||
packages/provider-cursor/src/server/CursorSdkCatalog.ts+11-8| ... | @@ -28,13 +28,14 @@ export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogEr | ... | @@ -28,13 +28,14 @@ export class CursorSdkCatalogError extends Schema.TaggedError<CursorSdkCatalogEr |
| 28 | } | 28 | } |
| 29 | } | 29 | } |
| 30 | 30 | ||
| 31 | export interface CursorSdkCatalogShape { | 31 | export class CursorSdkCatalog extends Context.Service< |
| 32 | readonly read: (apiKey: string) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>; | 32 | CursorSdkCatalog, |
| 33 | } | 33 | { |
| 34 | 34 | readonly read: ( | |
| 35 | export class CursorSdkCatalog extends Context.Service<CursorSdkCatalog, CursorSdkCatalogShape>()( | 35 | apiKey: string, |
| 36 | "@t3tools/provider-cursor/server/CursorSdkCatalog", | 36 | ) => Effect.Effect<CursorSdkCatalogSnapshot, CursorSdkCatalogError>; |
| 37 | ) {} | 37 | } |
| 38 | >()("@t3tools/provider-cursor/server/CursorSdkCatalog") {} | ||
| 38 | 39 | ||
| 39 | function isAuthenticationFailure(cause: unknown): boolean { | 40 | function isAuthenticationFailure(cause: unknown): boolean { |
| 40 | return ( | 41 | return ( |
| ... | @@ -87,6 +88,8 @@ export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* | ... | @@ -87,6 +88,8 @@ export const makeCursorSdkCatalog = Effect.fn("CursorSdkCatalog.make")(function* |
| 87 | 88 | ||
| 88 | export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); | 89 | export const layer = Layer.effect(CursorSdkCatalog, makeCursorSdkCatalog()); |
| 89 | 90 | ||
| 90 | export function layerTest(read: CursorSdkCatalogShape["read"]): Layer.Layer<CursorSdkCatalog> { | 91 | export function layerTest( |
| 92 | read: CursorSdkCatalog["Service"]["read"], | ||
| 93 | ): Layer.Layer<CursorSdkCatalog> { | ||
| 91 | return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read })); | 94 | return Layer.succeed(CursorSdkCatalog, CursorSdkCatalog.of({ read })); |
| 92 | } | 95 | } |
packages/provider-cursor/src/server/adapter.ts-22| ... | @@ -34,7 +34,6 @@ import * as DateTime from "effect/DateTime"; | ... | @@ -34,7 +34,6 @@ import * as DateTime from "effect/DateTime"; |
| 34 | import * as Deferred from "effect/Deferred"; | 34 | import * as Deferred from "effect/Deferred"; |
| 35 | import * as Effect from "effect/Effect"; | 35 | import * as Effect from "effect/Effect"; |
| 36 | import * as FileSystem from "effect/FileSystem"; | 36 | import * as FileSystem from "effect/FileSystem"; |
| 37 | import * as Layer from "effect/Layer"; | ||
| 38 | import * as Option from "effect/Option"; | 37 | import * as Option from "effect/Option"; |
| 39 | import * as Path from "effect/Path"; | 38 | import * as Path from "effect/Path"; |
| 40 | import * as Queue from "effect/Queue"; | 39 | import * as Queue from "effect/Queue"; |
| ... | @@ -2645,24 +2644,3 @@ export const CursorAdapterV2Driver: ProviderAdapterDriver< | ... | @@ -2645,24 +2644,3 @@ export const CursorAdapterV2Driver: ProviderAdapterDriver< |
| 2645 | ), | 2644 | ), |
| 2646 | ), | 2645 | ), |
| 2647 | }; | 2646 | }; |
| 2648 | |||
| 2649 | const layer: Layer.Layer< | ||
| 2650 | ProviderAdapter.ProviderAdapterV2, | ||
| 2651 | never, | ||
| 2652 | | CursorAgentSdk.CursorAgentSdkRunner | ||
| 2653 | | FileSystem.FileSystem | ||
| 2654 | | Path.Path | ||
| 2655 | | IdAllocator.IdAllocatorV2 | ||
| 2656 | | McpProviderSessions.McpProviderSessions | ||
| 2657 | | ProviderHost.ProviderHost | ||
| 2658 | > = Layer.effect( | ||
| 2659 | ProviderAdapter.ProviderAdapterV2, | ||
| 2660 | Effect.gen(function* () { | ||
| 2661 | const hostEnvironment = yield* HostProcessEnvironment; | ||
| 2662 | return yield* makeCursorAdapterV2({ | ||
| 2663 | instanceId: CURSOR_DEFAULT_INSTANCE_ID, | ||
| 2664 | settings: DEFAULT_CURSOR_SETTINGS, | ||
| 2665 | environment: hostEnvironment, | ||
| 2666 | }); | ||
| 2667 | }), | ||
| 2668 | ); |
packages/provider-cursor/src/server/driver.test.ts+3-1| ... | @@ -7,13 +7,14 @@ import * as Layer from "effect/Layer"; | ... | @@ -7,13 +7,14 @@ import * as Layer from "effect/Layer"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Stream from "effect/Stream"; | 8 | import * as Stream from "effect/Stream"; |
| 9 | import { vi } from "vite-plus/test"; | 9 | import { vi } from "vite-plus/test"; |
| 10 | import { HttpClient } from "effect/http"; | 10 | import * as HttpClient from "effect/http/HttpClient"; |
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 12 | 12 | ||
| 13 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; | 13 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 14 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; | 14 | import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; |
| 15 | import { CursorDriver } from "./driver.ts"; | 15 | import { CursorDriver } from "./driver.ts"; |
| 16 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | 16 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; |
| 17 | import * as CursorSdk from "./CursorSdk.ts"; | ||
| 17 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; | 18 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 18 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; | 19 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 19 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; | 20 | import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; |
| ... | @@ -23,6 +24,7 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( | ... | @@ -23,6 +24,7 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( |
| 23 | Layer.provideMerge(NodeServices.layer), | 24 | Layer.provideMerge(NodeServices.layer), |
| 24 | Layer.provideMerge(IdAllocator.layer), | 25 | Layer.provideMerge(IdAllocator.layer), |
| 25 | Layer.provideMerge(McpProviderSessions.layer), | 26 | Layer.provideMerge(McpProviderSessions.layer), |
| 27 | Layer.provideMerge(CursorSdk.layer), | ||
| 26 | Layer.provideMerge( | 28 | Layer.provideMerge( |
| 27 | Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({ | 29 | Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({ |
| 28 | open: () => Effect.die("Maintenance resolution must not open a Cursor session"), | 30 | open: () => Effect.die("Maintenance resolution must not open a Cursor session"), |
packages/provider-cursor/src/server/driver.ts+4-2| ... | @@ -13,7 +13,7 @@ import * as Crypto from "effect/Crypto"; | ... | @@ -13,7 +13,7 @@ import * as Crypto from "effect/Crypto"; |
| 13 | import * as FileSystem from "effect/FileSystem"; | 13 | import * as FileSystem from "effect/FileSystem"; |
| 14 | import * as Path from "effect/Path"; | 14 | import * as Path from "effect/Path"; |
| 15 | import * as Schema from "effect/Schema"; | 15 | import * as Schema from "effect/Schema"; |
| 16 | import { HttpClient } from "effect/http"; | 16 | import * as HttpClient from "effect/http/HttpClient"; |
| 17 | import { readCursorUsageLimits } from "./usageLimits.ts"; | 17 | import { readCursorUsageLimits } from "./usageLimits.ts"; |
| 18 | 18 | ||
| 19 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | 19 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| ... | @@ -40,6 +40,7 @@ import { probeCursorSkills } from "./skills.ts"; | ... | @@ -40,6 +40,7 @@ import { probeCursorSkills } from "./skills.ts"; |
| 40 | import { makeCursorAuth } from "./auth.ts"; | 40 | import { makeCursorAuth } from "./auth.ts"; |
| 41 | import * as CursorCredentialStore from "./credentialStore.ts"; | 41 | import * as CursorCredentialStore from "./credentialStore.ts"; |
| 42 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; | 42 | import * as CursorAgentSdk from "./CursorAgentSdk.ts"; |
| 43 | import * as CursorSdk from "./CursorSdk.ts"; | ||
| 43 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); | 44 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 44 | const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError); | 45 | const isSdkRunnerError = Schema.is(CursorAgentSdk.CursorAgentSdkRunnerError); |
| 45 | 46 | ||
| ... | @@ -55,6 +56,7 @@ export type CursorDriverEnv = | ... | @@ -55,6 +56,7 @@ export type CursorDriverEnv = |
| 55 | | FileSystem.FileSystem | 56 | | FileSystem.FileSystem |
| 56 | | Path.Path | 57 | | Path.Path |
| 57 | | HttpClient.HttpClient | 58 | | HttpClient.HttpClient |
| 59 | | CursorSdk.CursorSdk | ||
| 58 | | ProviderHost.ProviderHost; | 60 | | ProviderHost.ProviderHost; |
| 59 | 61 | ||
| 60 | export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | 62 | export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| ... | @@ -238,7 +240,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { | ... | @@ -238,7 +240,7 @@ export const CursorDriver: ProviderDriver<CursorSettings, CursorDriverEnv> = { |
| 238 | new ProviderDriverError({ | 240 | new ProviderDriverError({ |
| 239 | driver: DRIVER_KIND, | 241 | driver: DRIVER_KIND, |
| 240 | instanceId, | 242 | instanceId, |
| 241 | detail: `Failed to build Cursor snapshot: ${cause.message ?? String(cause)}`, | 243 | detail: "Failed to build Cursor snapshot.", |
| 242 | cause, | 244 | cause, |
| 243 | }), | 245 | }), |
| 244 | ), | 246 | ), |
packages/provider-cursor/src/server/sdk.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("./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+1-64| ... | @@ -1,69 +1,6 @@ | ... | @@ -1,69 +1,6 @@ |
| 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. | 1 | // @effect-diagnostics nodeBuiltinImport:off -- The SDK must load from disk beside its Webpack chunks. |
| 2 | import * as NodeModule from "node:module"; | 2 | import * as NodeModule from "node:module"; |
| 3 | 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. | 4 | // 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. | 5 | // createRequire also loads that disk-backed package from a Node SEA executable. |
| 69 | const requireCursorSdk = NodeModule.createRequire(import.meta.url); | 6 | const requireCursorSdk = NodeModule.createRequire(import.meta.url); |
packages/provider-cursor/src/server/textGeneration.test.ts+18-12| ... | @@ -5,12 +5,14 @@ import { describe, expect, it } from "@effect/vitest"; | ... | @@ -5,12 +5,14 @@ import { describe, expect, it } from "@effect/vitest"; |
| 5 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; | 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Fiber from "effect/Fiber"; | 7 | import * as Fiber from "effect/Fiber"; |
| 8 | import * as Layer from "effect/Layer"; | ||
| 8 | import * as TestClock from "effect/testing/TestClock"; | 9 | import * as TestClock from "effect/testing/TestClock"; |
| 9 | import * as Schema from "effect/Schema"; | 10 | import * as Schema from "effect/Schema"; |
| 10 | import { createModelSelection } from "@t3tools/shared/model"; | 11 | import { createModelSelection } from "@t3tools/shared/model"; |
| 11 | import { beforeEach, vi } from "vite-plus/test"; | 12 | import { beforeEach, vi } from "vite-plus/test"; |
| 12 | 13 | ||
| 13 | import { makeCursorTextGeneration } from "./textGeneration.ts"; | 14 | import { makeCursorTextGeneration } from "./textGeneration.ts"; |
| 15 | import * as CursorSdk from "./CursorSdk.ts"; | ||
| 14 | 16 | ||
| 15 | const cursorSdkMock = vi.hoisted(() => ({ | 17 | const cursorSdkMock = vi.hoisted(() => ({ |
| 16 | create: vi.fn<(options: unknown) => Promise<unknown>>(), | 18 | create: vi.fn<(options: unknown) => Promise<unknown>>(), |
| ... | @@ -25,13 +27,17 @@ const cursorSdkMock = vi.hoisted(() => ({ | ... | @@ -25,13 +27,17 @@ const cursorSdkMock = vi.hoisted(() => ({ |
| 25 | })), | 27 | })), |
| 26 | })); | 28 | })); |
| 27 | 29 | ||
| 28 | vi.mock("./sdk.ts", () => ({ Agent: { create: cursorSdkMock.create } })); | 30 | vi.mock("./sdk.ts", () => ({ |
| 31 | Agent: { create: cursorSdkMock.create }, | ||
| 32 | createAgentPlatform: vi.fn(), | ||
| 33 | })); | ||
| 29 | 34 | ||
| 30 | let hasCustomPolicy = false; | 35 | let hasCustomPolicy = false; |
| 31 | const layerFs = FileSystem.layerNoop({ | 36 | const layerFs = FileSystem.layerNoop({ |
| 32 | exists: () => Effect.succeed(hasCustomPolicy), | 37 | exists: () => Effect.succeed(hasCustomPolicy), |
| 33 | makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"), | 38 | makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"), |
| 34 | }); | 39 | }); |
| 40 | const layerTest = Layer.merge(layerFs, CursorSdk.layer); | ||
| 35 | 41 | ||
| 36 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); | 42 | const decodeCursorSettings = Schema.decodeSync(CursorSettings); |
| 37 | const cursorSettings = decodeCursorSettings({ enabled: true }); | 43 | const cursorSettings = decodeCursorSettings({ enabled: true }); |
| ... | @@ -90,7 +96,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -90,7 +96,7 @@ describe("CursorTextGeneration", () => { |
| 90 | expect(cursorSdkMock.create).toHaveBeenLastCalledWith( | 96 | expect(cursorSdkMock.create).toHaveBeenLastCalledWith( |
| 91 | expect.objectContaining({ apiKey: "second-browser-key" }), | 97 | expect.objectContaining({ apiKey: "second-browser-key" }), |
| 92 | ); | 98 | ); |
| 93 | }).pipe(Effect.provide(layerFs)), | 99 | }).pipe(Effect.provide(layerTest)), |
| 94 | ); | 100 | ); |
| 95 | 101 | ||
| 96 | it.effect("uses the Cursor SDK prompt API with model parameters and API key", () => | 102 | it.effect("uses the Cursor SDK prompt API with model parameters and API key", () => |
| ... | @@ -139,7 +145,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -139,7 +145,7 @@ describe("CursorTextGeneration", () => { |
| 139 | enableAgentRetries: true, | 145 | enableAgentRetries: true, |
| 140 | }, | 146 | }, |
| 141 | }); | 147 | }); |
| 142 | }).pipe(Effect.provide(layerFs)), | 148 | }).pipe(Effect.provide(layerTest)), |
| 143 | ); | 149 | ); |
| 144 | 150 | ||
| 145 | it.effect("continues in the temp directory when the SDK cannot sandbox", () => | 151 | it.effect("continues in the temp directory when the SDK cannot sandbox", () => |
| ... | @@ -175,7 +181,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -175,7 +181,7 @@ describe("CursorTextGeneration", () => { |
| 175 | enableAgentRetries: true, | 181 | enableAgentRetries: true, |
| 176 | }, | 182 | }, |
| 177 | }); | 183 | }); |
| 178 | }).pipe(Effect.provide(layerFs)), | 184 | }).pipe(Effect.provide(layerTest)), |
| 179 | ); | 185 | ); |
| 180 | 186 | ||
| 181 | it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () => | 187 | it.effect("does not retry Agent.create for errors other than an unsupported sandbox", () => |
| ... | @@ -199,7 +205,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -199,7 +205,7 @@ describe("CursorTextGeneration", () => { |
| 199 | 205 | ||
| 200 | expect(error.detail).toBe("Cursor SDK text generation failed."); | 206 | expect(error.detail).toBe("Cursor SDK text generation failed."); |
| 201 | expect(cursorSdkMock.create).toHaveBeenCalledTimes(1); | 207 | expect(cursorSdkMock.create).toHaveBeenCalledTimes(1); |
| 202 | }).pipe(Effect.provide(layerFs)), | 208 | }).pipe(Effect.provide(layerTest)), |
| 203 | ); | 209 | ); |
| 204 | 210 | ||
| 205 | it.effect("accepts json objects with extra assistant text around them", () => | 211 | it.effect("accepts json objects with extra assistant text around them", () => |
| ... | @@ -227,7 +233,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -227,7 +233,7 @@ describe("CursorTextGeneration", () => { |
| 227 | 233 | ||
| 228 | expect(generated.subject).toBe("Update README dummy comment with attribution and date"); | 234 | expect(generated.subject).toBe("Update README dummy comment with attribution and date"); |
| 229 | expect(generated.body).toBe(""); | 235 | expect(generated.body).toBe(""); |
| 230 | }).pipe(Effect.provide(layerFs)), | 236 | }).pipe(Effect.provide(layerTest)), |
| 231 | ); | 237 | ); |
| 232 | 238 | ||
| 233 | it.effect("generates thread titles through Cursor SDK text generation", () => | 239 | it.effect("generates thread titles through Cursor SDK text generation", () => |
| ... | @@ -251,7 +257,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -251,7 +257,7 @@ describe("CursorTextGeneration", () => { |
| 251 | }); | 257 | }); |
| 252 | 258 | ||
| 253 | expect(generated.title).toBe("Trim reconnect spinner status after resume."); | 259 | expect(generated.title).toBe("Trim reconnect spinner status after resume."); |
| 254 | }).pipe(Effect.provide(layerFs)), | 260 | }).pipe(Effect.provide(layerTest)), |
| 255 | ); | 261 | ); |
| 256 | 262 | ||
| 257 | it.effect.each(["error", "cancelled"] as const)( | 263 | it.effect.each(["error", "cancelled"] as const)( |
| ... | @@ -285,7 +291,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -285,7 +291,7 @@ describe("CursorTextGeneration", () => { |
| 285 | : "Cursor SDK request finished with an error.", | 291 | : "Cursor SDK request finished with an error.", |
| 286 | ); | 292 | ); |
| 287 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); | 293 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); |
| 288 | }).pipe(Effect.provide(layerFs)), | 294 | }).pipe(Effect.provide(layerTest)), |
| 289 | ); | 295 | ); |
| 290 | 296 | ||
| 291 | it.effect("fails closed when ambient sandbox policy can expand write access", () => | 297 | it.effect("fails closed when ambient sandbox policy can expand write access", () => |
| ... | @@ -301,7 +307,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -301,7 +307,7 @@ describe("CursorTextGeneration", () => { |
| 301 | ); | 307 | ); |
| 302 | expect(failure.detail).toContain("custom ~/.cursor/sandbox.json"); | 308 | expect(failure.detail).toContain("custom ~/.cursor/sandbox.json"); |
| 303 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); | 309 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); |
| 304 | }).pipe(Effect.provide(layerFs)), | 310 | }).pipe(Effect.provide(layerTest)), |
| 305 | ); | 311 | ); |
| 306 | 312 | ||
| 307 | it.effect("cancels the native run when text generation times out", () => | 313 | it.effect("cancels the native run when text generation times out", () => |
| ... | @@ -327,7 +333,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -327,7 +333,7 @@ describe("CursorTextGeneration", () => { |
| 327 | expect((yield* Fiber.join(result)).detail).toContain("timed out"); | 333 | expect((yield* Fiber.join(result)).detail).toContain("timed out"); |
| 328 | expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); | 334 | expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); |
| 329 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); | 335 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); |
| 330 | }).pipe(Effect.provide(layerFs), Effect.scoped), | 336 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 331 | ); | 337 | ); |
| 332 | 338 | ||
| 333 | it.effect.each(["create", "send"] as const)( | 339 | it.effect.each(["create", "send"] as const)( |
| ... | @@ -389,7 +395,7 @@ describe("CursorTextGeneration", () => { | ... | @@ -389,7 +395,7 @@ describe("CursorTextGeneration", () => { |
| 389 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); | 395 | expect(cursorSdkMock.close).toHaveBeenCalledOnce(); |
| 390 | expect(wait).not.toHaveBeenCalled(); | 396 | expect(wait).not.toHaveBeenCalled(); |
| 391 | if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); | 397 | if (phase === "send") expect(cursorSdkMock.cancel).toHaveBeenCalledOnce(); |
| 392 | }).pipe(Effect.provide(layerFs), Effect.scoped), | 398 | }).pipe(Effect.provide(layerTest), Effect.scoped), |
| 393 | ); | 399 | ); |
| 394 | 400 | ||
| 395 | it.effect("requires CURSOR_API_KEY before calling the SDK", () => | 401 | it.effect("requires CURSOR_API_KEY before calling the SDK", () => |
| ... | @@ -411,6 +417,6 @@ describe("CursorTextGeneration", () => { | ... | @@ -411,6 +417,6 @@ describe("CursorTextGeneration", () => { |
| 411 | 417 | ||
| 412 | expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings."); | 418 | expect(error.detail).toBe("Sign in with Cursor or add CURSOR_API_KEY in provider settings."); |
| 413 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); | 419 | expect(cursorSdkMock.prompt).not.toHaveBeenCalled(); |
| 414 | }).pipe(Effect.provide(layerFs)), | 420 | }).pipe(Effect.provide(layerTest)), |
| 415 | ); | 421 | ); |
| 416 | }); | 422 | }); |
packages/provider-cursor/src/server/textGeneration.ts+2-1| ... | @@ -2,7 +2,6 @@ import * as NodeOS from "node:os"; | ... | @@ -2,7 +2,6 @@ import * as NodeOS from "node:os"; |
| 2 | import * as FileSystem from "effect/FileSystem"; | 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | 3 | ||
| 4 | import type { AgentOptions, RunResult } from "@cursor/sdk"; | 4 | import type { AgentOptions, RunResult } from "@cursor/sdk"; |
| 5 | import { Agent } from "./sdk.ts"; | ||
| 6 | import * as Effect from "effect/Effect"; | 5 | import * as Effect from "effect/Effect"; |
| 7 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 8 | import * as Schema from "effect/Schema"; | 7 | import * as Schema from "effect/Schema"; |
| ... | @@ -13,6 +12,7 @@ import type { CursorSettings } from "../settings.ts"; | ... | @@ -13,6 +12,7 @@ import type { CursorSettings } from "../settings.ts"; |
| 13 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; | 12 | import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; |
| 14 | import { cursorSdkModelSelection } from "./sdkModel.ts"; | 13 | import { cursorSdkModelSelection } from "./sdkModel.ts"; |
| 15 | import type { CursorAuth } from "./auth.ts"; | 14 | import type { CursorAuth } from "./auth.ts"; |
| 15 | import * as CursorSdk from "./CursorSdk.ts"; | ||
| 16 | 16 | ||
| 17 | const CURSOR_TIMEOUT_MS = 180_000; | 17 | const CURSOR_TIMEOUT_MS = 180_000; |
| 18 | 18 | ||
| ... | @@ -58,6 +58,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu | ... | @@ -58,6 +58,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu |
| 58 | withAccess?: CursorAuth["withAccess"], | 58 | withAccess?: CursorAuth["withAccess"], |
| 59 | ) { | 59 | ) { |
| 60 | const fs = yield* FileSystem.FileSystem; | 60 | const fs = yield* FileSystem.FileSystem; |
| 61 | const { Agent } = yield* CursorSdk.CursorSdk; | ||
| 61 | const resolvedEnvironment = environment ?? process.env; | 62 | const resolvedEnvironment = environment ?? process.env; |
| 62 | 63 | ||
| 63 | const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) => | 64 | const resolveCursorApiKey = (operation: TextGenerationOperations.Operation) => |
packages/provider-cursor/src/server/usageLimits.test.ts+2-1| ... | @@ -4,7 +4,8 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ... | @@ -4,7 +4,8 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; | 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; | 6 | import * as Path from "effect/Path"; |
| 7 | import { HttpClient, HttpClientResponse } from "effect/http"; | 7 | import * as HttpClient from "effect/http/HttpClient"; |
| 8 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | ||
| 8 | import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; | 9 | import { cursorUsageResponseToLimits, readCursorUsageLimits } from "./usageLimits.ts"; |
| 9 | 10 | ||
| 10 | const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) => | 11 | const withNodeServices = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path>) => |
packages/provider-cursor/src/server/usageLimits.ts+3-1| ... | @@ -9,7 +9,9 @@ import * as FileSystem from "effect/FileSystem"; | ... | @@ -9,7 +9,9 @@ import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Option from "effect/Option"; | 9 | import * as Option from "effect/Option"; |
| 10 | import * as Path from "effect/Path"; | 10 | import * as Path from "effect/Path"; |
| 11 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| 12 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; | 12 | import * as HttpClient from "effect/http/HttpClient"; |
| 13 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; | ||
| 14 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; | ||
| 13 | import { | 15 | import { |
| 14 | clampPercent, | 16 | clampPercent, |
| 15 | makeUnavailableUsageLimits, | 17 | makeUnavailableUsageLimits, |