diff --git a/.gitignore b/.gitignore index 6e5f8cc59c9..e137de24a61 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,4 @@ apps/web/playwright-report apps/web/src/components/__screenshots__ .vitest-* __screenshots__/ -.tanstack +.tanstack \ No newline at end of file diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 87c81f08c8d..9ac0fae7c0c 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -31,6 +31,7 @@ import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/La import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts"; import { ProjectionCheckpointRepositoryLive } from "../src/persistence/Layers/ProjectionCheckpoints.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../src/persistence/Layers/ProjectionPendingApprovals.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../src/persistence/Layers/ProjectionThreadMessages.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; import { makeSqlitePersistenceLive } from "../src/persistence/Layers/Sqlite.ts"; import { ProjectionCheckpointRepository } from "../src/persistence/Services/ProjectionCheckpoints.ts"; @@ -279,11 +280,13 @@ export const makeOrchestrationIntegrationHarness = ( const providerLayer = useRealCodex ? makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), + Layer.provide(ProjectionThreadMessageRepositoryLive), Layer.provide(realCodexRegistry), Layer.provide(AnalyticsService.layerTest), ) : makeProviderServiceLive().pipe( Layer.provide(providerSessionDirectoryLayer), + Layer.provide(ProjectionThreadMessageRepositoryLive), Layer.provide(fakeRegistry!), Layer.provide(AnalyticsService.layerTest), ); diff --git a/apps/server/integration/providerService.integration.test.ts b/apps/server/integration/providerService.integration.test.ts index 1ca6fa1b836..ce6ffea8cda 100644 --- a/apps/server/integration/providerService.integration.test.ts +++ b/apps/server/integration/providerService.integration.test.ts @@ -15,8 +15,10 @@ import { } from "../src/provider/Services/ProviderService.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../src/persistence/Layers/ProjectionThreadMessages.ts"; import { SqlitePersistenceMemory } from "../src/persistence/Layers/Sqlite.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../src/persistence/Layers/ProviderSessionRuntime.ts"; +import { ProjectionThreadMessageRepository } from "../src/persistence/Services/ProjectionThreadMessages.ts"; import { makeTestProviderAdapterHarness, @@ -40,7 +42,7 @@ const makeWorkspaceDirectory = Effect.gen(function* () { interface IntegrationFixture { readonly cwd: string; readonly harness: TestProviderAdapterHarness; - readonly layer: Layer.Layer; + readonly layer: Layer.Layer; } const makeIntegrationFixture = Effect.gen(function* () { @@ -58,15 +60,17 @@ const makeIntegrationFixture = Effect.gen(function* () { const directoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(ProviderSessionRuntimeRepositoryLive), ); + const projectionMessageRepositoryLayer = ProjectionThreadMessageRepositoryLive; const shared = Layer.mergeAll( directoryLayer, + projectionMessageRepositoryLayer, Layer.succeed(ProviderAdapterRegistry, registry), ServerSettingsService.layerTest(DEFAULT_SERVER_SETTINGS), AnalyticsService.layerTest, ).pipe(Layer.provide(SqlitePersistenceMemory)); - const layer = makeProviderServiceLive().pipe(Layer.provide(shared)); + const layer = Layer.mergeAll(shared, makeProviderServiceLive().pipe(Layer.provide(shared))); return { cwd, diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts new file mode 100644 index 00000000000..ed84b060b81 --- /dev/null +++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts @@ -0,0 +1,207 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, FileSystem, Layer, Result } from "effect"; +import { expect } from "vitest"; + +import { TextGenerationError } from "@t3tools/contracts"; + +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { TextGeneration } from "../Services/TextGeneration.ts"; +import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; + +const DEFAULT_TEST_MODEL_SELECTION = { + provider: "cursor" as const, + model: "auto", +}; + +const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { + prefix: "t3-cursor-text-generation-test-", + }), + ), + Layer.provideMerge(NodeServices.layer), +); + +function makeFakeCursorBinary( + dir: string, + input: { + output: string; + exitCode?: number; + stderr?: string; + argsMustContain?: string; + stdinMustContain?: string; + }, +) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cursorPath = `${dir}/cursor-agent`; + yield* fs.writeFileString( + cursorPath, + [ + "#!/bin/sh", + 'args="$*"', + 'stdin_content="$(cat)"', + ...(input.argsMustContain !== undefined + ? [ + `if ! printf "%s" "$args" | grep -F -- ${JSON.stringify(input.argsMustContain)} >/dev/null; then`, + ' printf "%s\\n" "args missing expected content" >&2', + " exit 2", + "fi", + ] + : []), + ...(input.stdinMustContain !== undefined + ? [ + `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, + ' printf "%s\\n" "stdin missing expected content" >&2', + " exit 3", + "fi", + ] + : []), + ...(input.stderr !== undefined + ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] + : []), + "cat <<'__T3_FAKE_CURSOR_OUTPUT__'", + input.output, + "__T3_FAKE_CURSOR_OUTPUT__", + `exit ${input.exitCode ?? 0}`, + "", + ].join("\n"), + ); + yield* fs.chmod(cursorPath, 0o755); + return cursorPath; + }); +} + +function withFakeCursorBinary( + input: { + output: string; + exitCode?: number; + stderr?: string; + argsMustContain?: string; + stdinMustContain?: string; + }, + effect: Effect.Effect, +) { + return Effect.acquireUseRelease( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cursor-text-" }); + const cursorPath = yield* makeFakeCursorBinary(tempDir, input); + const serverSettings = yield* ServerSettingsService; + const previousSettings = yield* serverSettings.getSettings; + yield* serverSettings.updateSettings({ + providers: { + cursor: { + binaryPath: cursorPath, + }, + }, + }); + return { + serverSettings, + previousBinaryPath: previousSettings.providers.cursor.binaryPath, + }; + }), + () => effect, + ({ serverSettings, previousBinaryPath }) => + serverSettings + .updateSettings({ + providers: { + cursor: { + binaryPath: previousBinaryPath, + }, + }, + }) + .pipe(Effect.asVoid), + ); +} + +it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { + it.effect("uses Cursor Agent print mode with structured JSON output for thread titles", () => + withFakeCursorBinary( + { + output: JSON.stringify({ + result: '\n```json\n{"title":"Fix Cursor text generation"}\n```', + }), + argsMustContain: "--model auto", + stdinMustContain: "Return a JSON object with key: title.", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "fix the Cursor text generation backend", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + + expect(generated.title).toBe("Fix Cursor text generation"); + }), + ), + ); + + it.effect("resolves Cursor CLI model ids before spawning the agent", () => + withFakeCursorBinary( + { + output: JSON.stringify({ + result: '{"subject":"add cursor support","body":"details"}', + }), + argsMustContain: "--model claude-4.6-opus-high-fast", + stdinMustContain: "Return a JSON object with keys: subject, body", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-model", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: { + provider: "cursor", + model: "claude-4.6-opus", + options: { + reasoningEffort: "high", + fastMode: true, + }, + }, + }); + + expect(generated.subject).toBe("add cursor support"); + }), + ), + ); + + it.effect("returns typed TextGenerationError when Cursor exits non-zero", () => + withFakeCursorBinary( + { + output: "", + exitCode: 1, + stderr: "cursor execution failed", + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const result = yield* textGeneration + .generateCommitMessage({ + cwd: process.cwd(), + branch: "feature/cursor-error", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }) + .pipe(Effect.result); + + expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(result.failure).toBeInstanceOf(TextGenerationError); + expect(result.failure.message).toContain( + "Cursor Agent command failed: cursor execution failed", + ); + } + }), + ), + ); +}); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts new file mode 100644 index 00000000000..47a724e054a --- /dev/null +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -0,0 +1,331 @@ +import { Effect, Layer, Option, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { CursorModelSelection, TextGenerationError } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; + +import { resolveCursorCliModelId } from "../../provider/Layers/CursorProvider.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "../Prompts.ts"; +import { + normalizeCliError, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "../Utils.ts"; + +const CURSOR_TIMEOUT_MS = 180_000; + +const CursorPrintEnvelope = Schema.Struct({ + result: Schema.String, +}); + +function extractJsonText(raw: string): string { + const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i); + if (fenced?.[1]) { + return fenced[1].trim(); + } + return raw.trim(); +} + +const makeCursorTextGeneration = Effect.gen(function* () { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverSettingsService = yield* ServerSettingsService; + + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("cursor-agent", operation, cause, "Failed to collect process output"), + ), + ); + + const runCursorJson = Effect.fn("runCursorJson")(function* ({ + operation, + cwd, + prompt, + outputSchema, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchema: S; + modelSelection: CursorModelSelection; + }): Effect.fn.Return { + const cursorSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.cursor), + Effect.catch(() => Effect.undefined), + ); + + const cursorCliModel = + modelSelection.model === "auto" + ? "auto" + : resolveCursorCliModelId({ + model: modelSelection.model, + options: modelSelection.options, + }); + + const runCursorCommand = Effect.fn("runCursorJson.runCursorCommand")(function* () { + const command = ChildProcess.make( + cursorSettings?.binaryPath || "cursor-agent", + [ + "--print", + "--output-format", + "json", + "--mode", + "ask", + "--trust", + "--approve-mcps", + "--workspace", + cwd, + "--model", + cursorCliModel, + ], + { + cwd, + shell: process.platform === "win32", + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }, + ); + + const child = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError( + "cursor-agent", + operation, + cause, + "Failed to spawn Cursor Agent process", + ), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, child.stdout), + readStreamAsString(operation, child.stderr), + child.exitCode.pipe( + Effect.mapError((cause) => + normalizeCliError( + "cursor-agent", + operation, + cause, + "Failed to read Cursor Agent exit code", + ), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + if (exitCode !== 0) { + const stderrDetail = stderr.trim(); + const stdoutDetail = stdout.trim(); + const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + return yield* new TextGenerationError({ + operation, + detail: + detail.length > 0 + ? `Cursor Agent command failed: ${detail}` + : `Cursor Agent command failed with code ${exitCode}.`, + }); + } + + return stdout; + }); + + const rawStdout = yield* runCursorCommand().pipe( + Effect.scoped, + Effect.timeoutOption(CURSOR_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ operation, detail: "Cursor Agent request timed out." }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + ); + + const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(CursorPrintEnvelope))( + rawStdout, + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent returned unexpected output format.", + cause, + }), + ), + ), + ); + + return yield* Schema.decodeEffect(Schema.fromJsonString(outputSchema))( + extractJsonText(envelope.result), + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent returned invalid structured output.", + cause, + }), + ), + ), + ); + }); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "CursorTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + if (input.modelSelection.provider !== "cursor") { + return yield* new TextGenerationError({ + operation: "generateCommitMessage", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCursorJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "CursorTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + if (input.modelSelection.provider !== "cursor") { + return yield* new TextGenerationError({ + operation: "generatePrContent", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCursorJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "CursorTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + if (input.modelSelection.provider !== "cursor") { + return yield* new TextGenerationError({ + operation: "generateBranchName", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCursorJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "CursorTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + if (input.modelSelection.provider !== "cursor") { + return yield* new TextGenerationError({ + operation: "generateThreadTitle", + detail: "Invalid model selection.", + }); + } + + const generated = yield* runCursorJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + }; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); + +export const CursorTextGenerationLive = Layer.effect(TextGeneration, makeCursorTextGeneration); diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index dee12a3e0e7..d10d584cdf6 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -18,6 +18,7 @@ import { } from "../Services/TextGeneration.ts"; import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; +import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; // --------------------------------------------------------------------------- // Internal service tags so both concrete layers can coexist. @@ -31,6 +32,10 @@ class ClaudeTextGen extends ServiceMap.Service()( + "t3/git/Layers/RoutingTextGeneration/CursorTextGen", +) {} + // --------------------------------------------------------------------------- // Routing implementation // --------------------------------------------------------------------------- @@ -38,9 +43,10 @@ class ClaudeTextGen extends ServiceMap.Service - provider === "claudeAgent" ? claude : codex; + provider === "claudeAgent" ? claude : provider === "cursor" ? cursor : codex; return { generateCommitMessage: (input) => @@ -67,7 +73,19 @@ const InternalClaudeLayer = Layer.effect( }), ).pipe(Layer.provide(ClaudeTextGenerationLive)); +const InternalCursorLayer = Layer.effect( + CursorTextGen, + Effect.gen(function* () { + const svc = yield* TextGeneration; + return svc; + }), +).pipe(Layer.provide(CursorTextGenerationLive)); + export const RoutingTextGenerationLive = Layer.effect( TextGeneration, makeRoutingTextGeneration, -).pipe(Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer)); +).pipe( + Layer.provide(InternalCodexLayer), + Layer.provide(InternalClaudeLayer), + Layer.provide(InternalCursorLayer), +); diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index f4354c7a994..30d596e2fb4 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "@t3tools/contracts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "claudeAgent"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index ca4a2c22ef9..958c6743a31 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -99,7 +99,15 @@ export const attachmentsRouteLayer = HttpRouter.add( const config = yield* ServerConfig; const rawRelativePath = url.value.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); - const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); + const decodedRelativePath = yield* Effect.try({ + try: () => decodeURIComponent(rawRelativePath), + catch: () => null, + }); + if (decodedRelativePath === null) { + return HttpServerResponse.text("Invalid attachment path", { status: 400 }); + } + + const normalizedRelativePath = normalizeAttachmentRelativePath(decodedRelativePath); if (!normalizedRelativePath) { return HttpServerResponse.text("Invalid attachment path", { status: 400 }); } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 85f4d966e32..de24e93187f 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -330,6 +330,73 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); + it("maps aborted turns into interrupted session updates and finalizes partial assistant output", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-aborted"), + provider: "cursor", + threadId: asThreadId("thread-1"), + createdAt: now, + turnId: asTurnId("turn-aborted"), + }); + + await waitForThread( + harness.engine, + (thread) => + thread.session?.status === "running" && thread.session?.activeTurnId === "turn-aborted", + ); + + harness.emit({ + type: "content.delta", + eventId: asEventId("evt-assistant-delta-aborted"), + provider: "cursor", + threadId: asThreadId("thread-1"), + createdAt: now, + turnId: asTurnId("turn-aborted"), + itemId: asItemId("item-aborted"), + payload: { + streamKind: "assistant_text", + delta: "partial output", + }, + }); + + harness.emit({ + type: "turn.aborted", + eventId: asEventId("evt-turn-aborted"), + provider: "cursor", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-aborted"), + payload: { + reason: "Turn cancelled", + }, + }); + + const thread = await waitForThread( + harness.engine, + (entry) => + entry.session?.status === "interrupted" && + entry.session?.activeTurnId === null && + entry.messages.some( + (message: ProviderRuntimeTestMessage) => + message.id === "assistant:item-aborted" && + message.text === "partial output" && + !message.streaming, + ), + ); + + expect(thread.session?.status).toBe("interrupted"); + expect(thread.session?.activeTurnId).toBeNull(); + const message = thread.messages.find( + (entry: ProviderRuntimeTestMessage) => entry.id === "assistant:item-aborted", + ); + expect(message?.text).toBe("partial output"); + expect(message?.streaming).toBe(false); + }); + it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 8d3fb5d7520..aefd3c6b244 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -141,6 +141,43 @@ function orchestrationSessionStatusFromRuntimeState( } } +function isTerminalTurnLifecycleEvent( + event: ProviderRuntimeEvent, +): event is Extract { + return event.type === "turn.completed" || event.type === "turn.aborted"; +} + +function orchestrationSessionStatusFromTerminalTurnEvent( + event: Extract, +): "ready" | "interrupted" | "error" { + if (event.type === "turn.aborted") { + return "interrupted"; + } + + switch (normalizeRuntimeTurnState(event.payload.state)) { + case "failed": + return "error"; + case "interrupted": + case "cancelled": + return "interrupted"; + case "completed": + return "ready"; + } +} + +function lastErrorFromTerminalTurnEvent( + event: Extract, + previousLastError: string | null, +): string | null { + if (event.type === "turn.aborted") { + return null; + } + + return normalizeRuntimeTurnState(event.payload.state) === "failed" + ? (event.payload.errorMessage ?? previousLastError ?? "Turn failed") + : null; +} + function requestKindFromCanonicalRequestType( requestType: string | undefined, ): "command" | "file-read" | "file-change" | undefined { @@ -900,6 +937,7 @@ const make = Effect.fn("make")(function* () { case "turn.started": return !conflictsWithActiveTurn; case "turn.completed": + case "turn.aborted": if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } @@ -924,12 +962,13 @@ const make = Effect.fn("make")(function* () { event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" + event.type === "turn.completed" || + event.type === "turn.aborted" ) { const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" + : isTerminalTurnLifecycleEvent(event) || event.type === "session.exited" ? null : activeTurnId; const status = (() => { @@ -941,7 +980,8 @@ const make = Effect.fn("make")(function* () { case "session.exited": return "stopped"; case "turn.completed": - return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" : "ready"; + case "turn.aborted": + return orchestrationSessionStatusFromTerminalTurnEvent(event); case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an @@ -952,10 +992,9 @@ const make = Effect.fn("make")(function* () { const lastError = event.type === "session.state.changed" && event.payload.state === "error" ? (event.payload.reason ?? thread.session?.lastError ?? "Provider session error") - : event.type === "turn.completed" && - normalizeRuntimeTurnState(event.payload.state) === "failed" - ? (event.payload.errorMessage ?? thread.session?.lastError ?? "Turn failed") - : status === "ready" + : isTerminalTurnLifecycleEvent(event) + ? lastErrorFromTerminalTurnEvent(event, thread.session?.lastError ?? null) + : status === "ready" || status === "interrupted" ? null : (thread.session?.lastError ?? null); @@ -1106,7 +1145,7 @@ const make = Effect.fn("make")(function* () { }); } - if (event.type === "turn.completed") { + if (isTerminalTurnLifecycleEvent(event)) { const turnId = toTurnId(event.turnId); if (turnId) { const assistantMessageIds = yield* getAssistantMessageIdsForTurn(thread.id, turnId); @@ -1126,14 +1165,18 @@ const make = Effect.fn("make")(function* () { ).pipe(Effect.asVoid); yield* clearAssistantMessageIdsForTurn(thread.id, turnId); - yield* finalizeBufferedProposedPlan({ - event, - threadId: thread.id, - threadProposedPlans: thread.proposedPlans, - planId: proposedPlanIdForTurn(thread.id, turnId), - turnId, - updatedAt: now, - }); + if (event.type === "turn.completed") { + yield* finalizeBufferedProposedPlan({ + event, + threadId: thread.id, + threadProposedPlans: thread.proposedPlans, + planId: proposedPlanIdForTurn(thread.id, turnId), + turnId, + updatedAt: now, + }); + } else { + yield* clearBufferedProposedPlan(proposedPlanIdForTurn(thread.id, turnId)); + } } } diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 9f2eeb014e0..76518762633 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -39,6 +39,7 @@ import { TurnId, type UserInputQuestion, ClaudeCodeEffort, + type ClaudeThinkingBudget, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -77,6 +78,14 @@ import { ClaudeAdapter, type ClaudeAdapterShape } from "../Services/ClaudeAdapte import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "claudeAgent" as const; + +/** Thinking budget → max token count mapping. */ +const THINKING_BUDGET_TOKENS: Record = { + low: 5_000, + medium: 20_000, + high: 80_000, +}; + type ClaudeTextStreamKind = Extract; type ClaudeToolResultStreamKind = Extract< RuntimeContentStreamKind, @@ -2692,6 +2701,12 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle ? modelSelection.options.thinking : undefined; + const thinkingBudget = + thinking === true && modelSelection?.options?.thinkingBudget + ? (THINKING_BUDGET_TOKENS[ + modelSelection.options.thinkingBudget as ClaudeThinkingBudget + ] ?? null) + : null; const effectiveEffort = getEffectiveClaudeCodeEffort(effort); const permissionMode = input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; const settings = { @@ -2733,6 +2748,10 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }), }); + if (thinkingBudget !== null) { + yield* Effect.promise(() => queryRuntime.setMaxThinkingTokens(thinkingBudget)); + } + const session: ProviderSession = { threadId, provider: PROVIDER, @@ -2868,6 +2887,22 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...context.session, model: modelSelection.model, }; + + // Apply thinking budget when options change on subsequent turns. + const caps = getClaudeModelCapabilities(modelSelection.model); + const turnThinking = + typeof modelSelection.options?.thinking === "boolean" && caps.supportsThinkingToggle + ? modelSelection.options.thinking + : undefined; + const turnThinkingBudget = + turnThinking === true && modelSelection.options?.thinkingBudget + ? (THINKING_BUDGET_TOKENS[ + modelSelection.options.thinkingBudget as ClaudeThinkingBudget + ] ?? null) + : null; + if (turnThinkingBudget !== null) { + yield* Effect.promise(() => context.query.setMaxThinkingTokens(turnThinkingBudget)); + } } // Apply interaction mode by switching the SDK's permission mode. diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 761b795fe55..a6933d6225f 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -78,6 +78,11 @@ const BUILT_IN_MODELS: ReadonlyArray = [ reasoningEffortLevels: [], supportsFastMode: false, supportsThinkingToggle: true, + thinkingBudgetOptions: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + ], contextWindowOptions: [], promptInjectedEffortLevels: [], } satisfies ModelCapabilities, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts new file mode 100644 index 00000000000..7d8fdfb67fd --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -0,0 +1,1662 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; +import { Effect, Layer, Stream } from "effect"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../cursorAcp.ts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startCursorAcpClient: vi.fn(), + }; +}); + +import { + CursorAdapterLive, + classifyCursorToolItemType, + describePermissionRequest, + extractCursorStreamText, + permissionOptionKindForRuntimeMode, + requestTypeForCursorTool, + runtimeItemStatusFromCursorStatus, + streamKindFromUpdateKind, +} from "./CursorAdapter.ts"; +import { type ServerConfigShape, ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { startCursorAcpClient, type CursorAcpClient } from "../cursorAcp.ts"; +import { type CursorAdapterShape, CursorAdapter } from "../Services/CursorAdapter.ts"; + +const mockedStartCursorAcpClient = vi.mocked(startCursorAcpClient); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const tinyPngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0ioAAAAASUVORK5CYII="; + +const cursorInitializeResult = (input?: { + readonly loadSession?: boolean; + readonly image?: boolean; +}) => ({ + protocolVersion: 1, + authMethods: [{ id: "cursor_login", name: "Cursor Login" }], + agentCapabilities: { + loadSession: input?.loadSession ?? true, + promptCapabilities: { + image: input?.image ?? true, + }, + }, +}); + +const cursorSessionConfigOptions = (input?: { + readonly mode?: string; + readonly model?: string; + readonly modelOptions?: ReadonlyArray<{ + readonly value: string; + readonly name: string; + readonly description?: string; + }>; +}) => [ + { + id: "mode", + name: "Mode", + category: "mode", + currentValue: input?.mode ?? "agent", + options: [ + { value: "agent", name: "Agent" }, + { value: "plan", name: "Plan" }, + ], + }, + { + id: "model", + name: "Model", + category: "model", + currentValue: input?.model ?? "gpt-5-mini[]", + options: input?.modelOptions ?? [ + { value: "gpt-5-mini[]", name: "GPT-5 mini" }, + { value: "claude-3.7-sonnet[]", name: "Claude Sonnet" }, + ], + }, +]; + +const cursorSessionResult = ( + sessionId: string, + input?: { + readonly mode?: string; + readonly model?: string; + readonly modelOptions?: ReadonlyArray<{ + readonly value: string; + readonly name: string; + readonly description?: string; + }>; + }, +) => ({ + sessionId, + configOptions: cursorSessionConfigOptions(input), + modes: { + currentModeId: input?.mode ?? "agent", + availableModes: [ + { id: "agent", name: "Agent" }, + { id: "plan", name: "Plan" }, + ], + }, + models: { + currentModelId: input?.model ?? "gpt-5-mini[]", + availableModels: [{ modelId: input?.model ?? "gpt-5-mini[]", name: "GPT-5 mini" }], + }, +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function waitForCondition(predicate: () => boolean, timeoutMs = 1000): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt >= timeoutMs) { + throw new Error("Timed out waiting for condition."); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function makeFakeCursorClient(options: { + readonly requestImpl: ( + method: string, + params?: unknown, + requestOptions?: { readonly timeoutMs?: number }, + ) => Promise; +}): CursorAcpClient & { + readonly request: ReturnType; + readonly getNotificationHandler: () => + | ((notification: { readonly method: string; readonly params?: unknown }) => void) + | undefined; + readonly getRequestHandler: () => + | ((request: { + readonly id: string | number; + readonly method: string; + readonly params?: unknown; + }) => void) + | undefined; +} { + let closeHandler: + | ((input: { readonly code: number | null; readonly signal: NodeJS.Signals | null }) => void) + | undefined; + let notificationHandler: + | ((notification: { readonly method: string; readonly params?: unknown }) => void) + | undefined; + let requestHandler: + | ((request: { + readonly id: string | number; + readonly method: string; + readonly params?: unknown; + }) => void) + | undefined; + + const request = vi.fn(options.requestImpl); + + return { + child: { + kill: vi.fn(() => true), + } as unknown as CursorAcpClient["child"], + request, + notify: vi.fn(), + respond: vi.fn(), + respondError: vi.fn(), + setNotificationHandler: vi.fn((handler) => { + notificationHandler = handler; + }), + setRequestHandler: vi.fn((handler) => { + requestHandler = handler; + }), + setCloseHandler: vi.fn((handler) => { + closeHandler = handler; + }), + setProtocolErrorHandler: vi.fn(), + getNotificationHandler: () => notificationHandler, + getRequestHandler: () => requestHandler, + close: vi.fn(async () => { + closeHandler?.({ code: 0, signal: null }); + }), + }; +} + +const adapterLayer = CursorAdapterLive.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "cursor-adapter-test-" })), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(NodeServices.layer), +); + +async function withAdapter( + run: (adapter: CursorAdapterShape, config: ServerConfigShape) => Promise, +): Promise { + return Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const config = yield* ServerConfig; + return yield* Effect.promise(() => run(adapter, config)); + }), + ).pipe(Effect.provide(adapterLayer)), + ); +} + +afterEach(() => { + mockedStartCursorAcpClient.mockReset(); +}); + +describe("permissionOptionKindForRuntimeMode", () => { + it("auto-approves Cursor ACP tool permissions for full-access sessions", () => { + expect(permissionOptionKindForRuntimeMode("full-access")).toEqual({ + primary: "allow_always", + fallback: "allow_once", + decision: "acceptForSession", + }); + }); + + it("keeps manual approval flow for approval-required sessions", () => { + expect(permissionOptionKindForRuntimeMode("approval-required")).toEqual({ + primary: "allow_once", + fallback: "allow_always", + decision: "accept", + }); + }); +}); + +describe("streamKindFromUpdateKind", () => { + it("maps Cursor thought chunks to reasoning text", () => { + expect(streamKindFromUpdateKind("agent_thought_chunk")).toBe("reasoning_text"); + }); + + it("keeps normal assistant chunks as assistant text", () => { + expect(streamKindFromUpdateKind("agent_message_chunk")).toBe("assistant_text"); + }); +}); + +describe("extractCursorStreamText", () => { + it("preserves leading and trailing whitespace for streamed chunks", () => { + expect(extractCursorStreamText({ content: { text: " hello world \n" } })).toBe( + " hello world \n", + ); + }); + + it("keeps whitespace-only streamed chunks instead of trimming them away", () => { + expect(extractCursorStreamText({ text: " " })).toBe(" "); + }); +}); + +describe("classifyCursorToolItemType", () => { + it("classifies execute/terminal tool calls as command execution", () => { + expect( + classifyCursorToolItemType({ + kind: "execute", + title: "Terminal", + }), + ).toBe("command_execution"); + }); + + it("classifies explore subagent tasks as collab agent tool calls", () => { + expect( + classifyCursorToolItemType({ + title: "Explore codebase", + subagentType: "explore", + }), + ).toBe("collab_agent_tool_call"); + }); +}); + +describe("requestTypeForCursorTool", () => { + it("classifies read-style tools as file-read approvals", () => { + expect( + requestTypeForCursorTool({ + kind: "read", + title: "Read file", + }), + ).toBe("file_read_approval"); + }); +}); + +describe("runtimeItemStatusFromCursorStatus", () => { + it("normalizes Cursor in-progress and completed statuses", () => { + expect(runtimeItemStatusFromCursorStatus("in_progress")).toBe("inProgress"); + expect(runtimeItemStatusFromCursorStatus("completed")).toBe("completed"); + expect(runtimeItemStatusFromCursorStatus("failed")).toBe("failed"); + }); +}); + +describe("describePermissionRequest", () => { + it("extracts the command text from Cursor permission requests", () => { + expect( + describePermissionRequest({ + toolCall: { + toolCallId: "tool_123", + title: "`pwd && ls -la /tmp/repo`", + kind: "execute", + status: "pending", + }, + }), + ).toBe("pwd && ls -la /tmp/repo"); + }); +}); + +describe("CursorAdapterLive", () => { + it("uses the current ACP session params for new and resumed sessions", async () => { + const newClient = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-new"); + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + const resumedClient = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/load": + return cursorSessionResult("cursor-session-existing"); + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValueOnce(newClient).mockReturnValueOnce(resumedClient); + + await withAdapter(async (adapter) => { + try { + const started = await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-new"), + cwd: "/repo/new", + runtimeMode: "full-access", + }), + ); + expect(started.resumeCursor).toEqual({ sessionId: "cursor-session-new" }); + expect(newClient.request).toHaveBeenNthCalledWith( + 3, + "session/new", + { + cwd: "/repo/new", + mcpServers: [], + }, + { timeoutMs: 15000 }, + ); + + const resumed = await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-existing"), + cwd: "/repo/existing", + resumeCursor: { sessionId: "cursor-session-existing" }, + runtimeMode: "full-access", + }), + ); + expect(resumed.resumeCursor).toEqual({ sessionId: "cursor-session-existing" }); + expect(resumedClient.request).toHaveBeenNthCalledWith( + 3, + "session/load", + { + cwd: "/repo/existing", + mcpServers: [], + sessionId: "cursor-session-existing", + }, + { timeoutMs: 15000 }, + ); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("falls back to a fresh Cursor session when the persisted resume cursor no longer exists remotely", async () => { + const client = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/load": + throw new Error( + "Request session/load failed: Session not found: cursor-session-missing", + ); + case "session/new": + return cursorSessionResult("cursor-session-recreated"); + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + const session = await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-cursor-stale-resume"), + cwd: "/repo/cursor-stale-resume", + resumeCursor: { sessionId: "cursor-session-missing" }, + runtimeMode: "full-access", + }), + ); + + expect(session.resumeCursor).toEqual({ sessionId: "cursor-session-recreated" }); + expect(client.request).toHaveBeenNthCalledWith( + 3, + "session/load", + { + cwd: "/repo/cursor-stale-resume", + mcpServers: [], + sessionId: "cursor-session-missing", + }, + { timeoutMs: 15000 }, + ); + expect(client.request).toHaveBeenNthCalledWith( + 4, + "session/new", + { + cwd: "/repo/cursor-stale-resume", + mcpServers: [], + }, + { timeoutMs: 15000 }, + ); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("returns a typed missing-session failure from readThread", async () => { + await withAdapter(async (adapter) => { + const result = await Effect.runPromise( + adapter.readThread(asThreadId("thread-missing-read")).pipe(Effect.result), + ); + + expect(result._tag).toBe("Failure"); + if (result._tag !== "Failure") { + return; + } + + expect(result.failure._tag).toBe("ProviderAdapterSessionNotFoundError"); + if (result.failure._tag !== "ProviderAdapterSessionNotFoundError") { + return; + } + + expect(result.failure.provider).toBe("cursor"); + expect(result.failure.threadId).toBe("thread-missing-read"); + }); + }); + + it("waits for an in-flight startup instead of returning a connecting session", async () => { + const sessionNew = deferred>(); + const client = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return sessionNew.promise; + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + const input = { + provider: "cursor" as const, + threadId: asThreadId("thread-race"), + cwd: "/repo/race", + runtimeMode: "full-access" as const, + }; + let secondResolved = false; + + const firstStart = Effect.runPromise(adapter.startSession(input)); + await waitForCondition(() => + client.request.mock.calls.some(([method]) => method === "session/new"), + ); + + const secondStart = Effect.runPromise( + adapter.startSession(input).pipe( + Effect.tap(() => + Effect.sync(() => { + secondResolved = true; + }), + ), + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(secondResolved).toBe(false); + + sessionNew.resolve(cursorSessionResult("cursor-session-race")); + + const [firstSession, secondSession] = await Promise.all([firstStart, secondStart]); + expect(firstSession.status).toBe("ready"); + expect(firstSession.resumeCursor).toEqual({ sessionId: "cursor-session-race" }); + expect(secondSession.status).toBe("ready"); + expect(secondSession.resumeCursor).toEqual({ sessionId: "cursor-session-race" }); + expect( + client.request.mock.calls.filter(([method]) => method === "initialize"), + ).toHaveLength(1); + expect( + client.request.mock.calls.filter(([method]) => method === "session/new"), + ).toHaveLength(1); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("preserves active-turn send failures without wrapping them in Effect.tryPromise", async () => { + const promptResult = deferred<{ readonly stopReason: string }>(); + const client = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-send-turn"); + case "session/prompt": + return promptResult.promise; + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-send-turn"), + cwd: "/repo/send-turn", + runtimeMode: "full-access", + }), + ); + + await Effect.runPromise( + adapter.sendTurn({ + threadId: asThreadId("thread-send-turn"), + input: "first prompt", + }), + ); + + await expect( + Effect.runPromise( + adapter.sendTurn({ + threadId: asThreadId("thread-send-turn"), + input: "second prompt", + }), + ), + ).rejects.toMatchObject({ + _tag: "ProviderAdapterRequestError", + provider: "cursor", + method: "session/prompt", + detail: "Cursor session already has an active turn.", + }); + + promptResult.resolve({ stopReason: "end_turn" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("rehydrates image attachments and syncs plan mode before prompting", async () => { + const firstPromptResult = deferred<{ readonly stopReason: string }>(); + const secondPromptResult = deferred<{ readonly stopReason: string }>(); + const promptResults = [firstPromptResult.promise, secondPromptResult.promise] as const; + let promptIndex = 0; + const client = makeFakeCursorClient({ + requestImpl: async (method, params) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-attachments"); + case "session/set_config_option": { + const record = params as { readonly value?: string }; + return { + configOptions: cursorSessionConfigOptions({ + mode: record.value === "plan" ? "plan" : "agent", + }), + }; + } + case "session/prompt": + return ( + promptResults[promptIndex++] ?? Promise.reject(new Error("Unexpected prompt call")) + ); + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter, config) => { + try { + const threadId = asThreadId("thread-attachments"); + const attachment = { + type: "image" as const, + id: "thread-attachments-123e4567-e89b-12d3-a456-426614174000", + name: "screenshot.png", + mimeType: "image/png", + sizeBytes: Buffer.from(tinyPngBase64, "base64").length, + }; + + await writeFile( + join(config.attachmentsDir, `${attachment.id}.png`), + Buffer.from(tinyPngBase64, "base64"), + ); + + await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId, + cwd: "/repo/attachments", + runtimeMode: "full-access", + }), + ); + + await Effect.runPromise( + adapter.sendTurn({ + threadId, + input: "Describe this screenshot", + interactionMode: "plan", + attachments: [attachment], + }), + ); + + expect(client.request).toHaveBeenCalledWith( + "session/set_config_option", + { + sessionId: "cursor-session-attachments", + configId: "mode", + value: "plan", + }, + { timeoutMs: 15000 }, + ); + expect(client.request).toHaveBeenCalledWith("session/prompt", { + sessionId: "cursor-session-attachments", + prompt: [ + { type: "text", text: "Describe this screenshot" }, + { + type: "image", + mimeType: "image/png", + data: tinyPngBase64, + }, + ], + }); + + const firstCompletedEventPromise = Effect.runPromise(Stream.runHead(adapter.streamEvents)); + firstPromptResult.resolve({ stopReason: "end_turn" }); + const firstCompletedEvent = await firstCompletedEventPromise; + expect(firstCompletedEvent._tag).toBe("Some"); + if (firstCompletedEvent._tag !== "Some") { + return; + } + expect(firstCompletedEvent.value.type).toBe("turn.completed"); + + await Effect.runPromise( + adapter.sendTurn({ + threadId, + input: "Continue normally", + interactionMode: "default", + }), + ); + + const modeRequests = client.request.mock.calls.filter( + ([method, requestParams]) => + method === "session/set_config_option" && + (requestParams as { readonly configId?: string } | undefined)?.configId === "mode", + ); + expect(modeRequests).toEqual([ + [ + "session/set_config_option", + { + sessionId: "cursor-session-attachments", + configId: "mode", + value: "plan", + }, + { timeoutMs: 15000 }, + ], + [ + "session/set_config_option", + { + sessionId: "cursor-session-attachments", + configId: "mode", + value: "agent", + }, + { timeoutMs: 15000 }, + ], + ]); + + secondPromptResult.resolve({ stopReason: "end_turn" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("prefers the original Cursor model slug when ACP exposes that exact model option", async () => { + const client = makeFakeCursorClient({ + requestImpl: async (method, params) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-model-selection", { + model: "gpt-5.2-low[]", + modelOptions: [ + { value: "gpt-5.2-low[]", name: "GPT-5.2 Low" }, + { value: "gpt-5.2-max[]", name: "GPT-5.2 Max" }, + ], + }); + case "session/set_config_option": { + const record = params as { readonly configId?: string; readonly value?: string }; + return { + configOptions: cursorSessionConfigOptions({ + modelOptions: [ + { value: "gpt-5.2-low[]", name: "GPT-5.2 Low" }, + { value: "gpt-5.2-max[]", name: "GPT-5.2 Max" }, + ], + ...(record.value ? { model: record.value } : {}), + }), + }; + } + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-cursor-model-selection"), + cwd: "/repo/cursor-model-selection", + modelSelection: { + provider: "cursor", + model: "gpt-5.2-max", + options: { + fastMode: true, + }, + }, + runtimeMode: "full-access", + }), + ); + + expect(client.request).toHaveBeenCalledWith( + "session/set_config_option", + { + sessionId: "cursor-session-model-selection", + configId: "model", + value: "gpt-5.2-max[]", + }, + { timeoutMs: 15000 }, + ); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("matches Cursor model options using ACP descriptions for Claude thinking variants", async () => { + const client = makeFakeCursorClient({ + requestImpl: async (method, params) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-claude-thinking", { + model: "claude-4.6-opus-fast[]", + modelOptions: [ + { + value: "claude-4.6-opus[]", + name: "Claude 4.6 Opus", + description: "Max Thinking", + }, + { + value: "claude-4.6-opus-fast[]", + name: "Claude 4.6 Opus Fast", + }, + ], + }); + case "session/set_config_option": { + const record = params as { readonly value?: string }; + return { + configOptions: cursorSessionConfigOptions({ + modelOptions: [ + { + value: "claude-4.6-opus[]", + name: "Claude 4.6 Opus", + description: "Max Thinking", + }, + { + value: "claude-4.6-opus-fast[]", + name: "Claude 4.6 Opus Fast", + }, + ], + ...(record.value ? { model: record.value } : {}), + }), + }; + } + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-cursor-claude-thinking"), + cwd: "/repo/cursor-claude-thinking", + modelSelection: { + provider: "cursor", + model: "claude-4.6-opus-max-thinking", + }, + runtimeMode: "full-access", + }), + ); + + expect(client.request).toHaveBeenCalledWith( + "session/set_config_option", + { + sessionId: "cursor-session-claude-thinking", + configId: "model", + value: "claude-4.6-opus[]", + }, + { timeoutMs: 15000 }, + ); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("matches Cursor Spark Preview variants when ACP omits the preview token", async () => { + const modelOptions = [ + { + value: "gpt-5.3-codex-spark[]", + name: "GPT-5.3 Codex Spark", + }, + { + value: "gpt-5.3-codex-spark-xhigh[]", + name: "GPT-5.3 Codex Spark Extra High", + }, + ] as const; + const client = makeFakeCursorClient({ + requestImpl: async (method, params) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-spark-preview", { + model: "gpt-5.3-codex-spark[]", + modelOptions, + }); + case "session/set_config_option": { + const record = params as { readonly value?: string }; + return { + configOptions: cursorSessionConfigOptions({ + modelOptions, + ...(record.value ? { model: record.value } : {}), + }), + }; + } + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-cursor-spark-preview"), + cwd: "/repo/cursor-spark-preview", + modelSelection: { + provider: "cursor", + model: "gpt-5.3-codex-spark-preview-xhigh", + }, + runtimeMode: "full-access", + }), + ); + + expect(client.request).toHaveBeenCalledWith( + "session/set_config_option", + { + sessionId: "cursor-session-spark-preview", + configId: "model", + value: "gpt-5.3-codex-spark-xhigh[]", + }, + { timeoutMs: 15000 }, + ); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("matches Cursor Sonnet 1M thinking variants when ACP exposes 1M in descriptions", async () => { + const modelOptions = [ + { + value: "claude-4-sonnet[]", + name: "Sonnet 4", + }, + { + value: "claude-4-sonnet-thinking[]", + name: "Sonnet 4 Thinking", + description: "1M context", + }, + ] as const; + const client = makeFakeCursorClient({ + requestImpl: async (method, params) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-sonnet-1m-thinking", { + model: "claude-4-sonnet[]", + modelOptions, + }); + case "session/set_config_option": { + const record = params as { readonly value?: string }; + return { + configOptions: cursorSessionConfigOptions({ + modelOptions, + ...(record.value ? { model: record.value } : {}), + }), + }; + } + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-cursor-sonnet-1m-thinking"), + cwd: "/repo/cursor-sonnet-1m-thinking", + modelSelection: { + provider: "cursor", + model: "claude-4-sonnet-1m-thinking", + }, + runtimeMode: "full-access", + }), + ); + + expect(client.request).toHaveBeenCalledWith( + "session/set_config_option", + { + sessionId: "cursor-session-sonnet-1m-thinking", + configId: "model", + value: "claude-4-sonnet-thinking[]", + }, + { timeoutMs: 15000 }, + ); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("maps approval decisions to ACP-provided option ids", async () => { + const client = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-approval"); + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + const session = await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-approval"), + cwd: "/repo/approval", + runtimeMode: "approval-required", + }), + ); + + const requestHandler = client.getRequestHandler(); + expect(requestHandler).toBeTypeOf("function"); + if (!requestHandler) { + return; + } + + const openedEventPromise = Effect.runPromise(Stream.runHead(adapter.streamEvents)); + requestHandler({ + id: 51, + method: "session/request_permission", + params: { + toolCall: { + toolCallId: "tool-approval", + title: "`npm run build`", + kind: "execute", + status: "pending", + }, + options: [ + { optionId: "approve-per-run", kind: "allow_once", name: "Allow once" }, + { + optionId: "approve-this-session", + kind: "allow_always", + name: "Allow for session", + }, + { optionId: "deny-per-run", kind: "reject_once", name: "Reject" }, + ], + }, + }); + + const openedEvent = await openedEventPromise; + expect(openedEvent._tag).toBe("Some"); + if (openedEvent._tag !== "Some") { + return; + } + expect(openedEvent.value.type).toBe("request.opened"); + if (openedEvent.value.type !== "request.opened") { + return; + } + const requestId = openedEvent.value.requestId; + expect(typeof requestId).toBe("string"); + if (!requestId) { + return; + } + + const resolvedEventPromise = Effect.runPromise(Stream.runHead(adapter.streamEvents)); + await Effect.runPromise( + adapter.respondToRequest( + session.threadId, + ApprovalRequestId.makeUnsafe(requestId), + "acceptForSession", + ), + ); + + const resolvedEvent = await resolvedEventPromise; + expect(resolvedEvent._tag).toBe("Some"); + if (resolvedEvent._tag !== "Some") { + return; + } + expect(resolvedEvent.value.type).toBe("request.resolved"); + if (resolvedEvent.value.type !== "request.resolved") { + return; + } + expect(resolvedEvent.value.payload).toEqual({ + requestType: "command_execution_approval", + decision: "acceptForSession", + resolution: { + optionId: "approve-this-session", + kind: "allow_always", + }, + }); + expect(client.respond).toHaveBeenCalledWith(51, { + outcome: { + outcome: "selected", + optionId: "approve-this-session", + }, + }); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("cancels declined permission requests when Cursor exposes no reject option", async () => { + const client = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-decline-fallback"); + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + const session = await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-decline-fallback"), + cwd: "/repo/decline-fallback", + runtimeMode: "approval-required", + }), + ); + + const requestHandler = client.getRequestHandler(); + expect(requestHandler).toBeTypeOf("function"); + if (!requestHandler) { + return; + } + + const openedEventPromise = Effect.runPromise(Stream.runHead(adapter.streamEvents)); + requestHandler({ + id: 61, + method: "session/request_permission", + params: { + toolCall: { + toolCallId: "tool-decline-fallback", + title: "`npm run build`", + kind: "execute", + status: "pending", + }, + options: [ + { optionId: "approve-per-run", kind: "allow_once", name: "Allow once" }, + { + optionId: "approve-this-session", + kind: "allow_always", + name: "Allow for session", + }, + ], + }, + }); + + const openedEvent = await openedEventPromise; + expect(openedEvent._tag).toBe("Some"); + if (openedEvent._tag !== "Some") { + return; + } + expect(openedEvent.value.type).toBe("request.opened"); + if (openedEvent.value.type !== "request.opened") { + return; + } + const requestId = openedEvent.value.requestId; + expect(typeof requestId).toBe("string"); + if (!requestId) { + return; + } + + const resolvedEventPromise = Effect.runPromise(Stream.runHead(adapter.streamEvents)); + await Effect.runPromise( + adapter.respondToRequest( + session.threadId, + ApprovalRequestId.makeUnsafe(requestId), + "decline", + ), + ); + + const resolvedEvent = await resolvedEventPromise; + expect(resolvedEvent._tag).toBe("Some"); + if (resolvedEvent._tag !== "Some") { + return; + } + expect(resolvedEvent.value.type).toBe("request.resolved"); + if (resolvedEvent.value.type !== "request.resolved") { + return; + } + expect(resolvedEvent.value.payload).toEqual({ + requestType: "command_execution_approval", + decision: "decline", + resolution: { + outcome: "cancelled", + }, + }); + expect(client.respond).toHaveBeenCalledWith(61, { + outcome: { + outcome: "cancelled", + }, + }); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("cancels active turns with ACP notifications and cancels pending approvals", async () => { + const firstPromptResult = deferred<{ readonly stopReason: string }>(); + const secondPromptResult = deferred<{ readonly stopReason: string }>(); + let promptCount = 0; + const client = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-cancel"); + case "session/prompt": + promptCount += 1; + return promptCount === 1 ? firstPromptResult.promise : secondPromptResult.promise; + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + const threadId = asThreadId("thread-cancel"); + await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId, + cwd: "/repo/cancel", + runtimeMode: "approval-required", + }), + ); + const startedTurn = await Effect.runPromise( + adapter.sendTurn({ + threadId, + input: "Run something risky", + }), + ); + + const requestHandler = client.getRequestHandler(); + expect(requestHandler).toBeTypeOf("function"); + if (!requestHandler) { + return; + } + + requestHandler({ + id: 77, + method: "session/request_permission", + params: { + toolCall: { + toolCallId: "tool-cancel", + title: "`npm run lint`", + kind: "execute", + status: "pending", + }, + options: [ + { optionId: "approve-per-run", kind: "allow_once", name: "Allow once" }, + { optionId: "deny-per-run", kind: "reject_once", name: "Reject" }, + ], + }, + }); + + const postInterruptEventsPromise = Effect.runPromise( + Stream.runCollect(Stream.take(adapter.streamEvents, 2)), + ); + await Effect.runPromise(adapter.interruptTurn(threadId, startedTurn.turnId)); + + expect(client.notify).toHaveBeenCalledWith("session/cancel", { + sessionId: "cursor-session-cancel", + }); + expect(client.respond).toHaveBeenCalledWith(77, { + outcome: { + outcome: "cancelled", + }, + }); + + const postInterruptEvents = Array.from(await postInterruptEventsPromise); + expect(postInterruptEvents).toHaveLength(2); + + const resolvedEvent = postInterruptEvents[0]; + const abortedEvent = postInterruptEvents[1]; + expect(resolvedEvent?.type).toBe("request.resolved"); + if (resolvedEvent?.type !== "request.resolved") { + return; + } + expect(abortedEvent?.type).toBe("turn.aborted"); + if (abortedEvent?.type !== "turn.aborted") { + return; + } + expect(resolvedEvent.payload).toEqual({ + requestType: "command_execution_approval", + decision: "cancel", + resolution: { + outcome: "cancelled", + }, + }); + + const restartedTurn = await Effect.runPromise( + adapter.sendTurn({ + threadId, + input: "Retry after cancel", + }), + ); + expect(restartedTurn.threadId).toBe(threadId); + expect(restartedTurn.turnId).not.toBe(startedTurn.turnId); + } finally { + secondPromptResult.resolve({ stopReason: "end_turn" }); + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("reuses one fallback id for cursor task item and task completion events", async () => { + const client = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-task-fallback"); + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-cursor-task-fallback"), + cwd: "/repo/cursor-task-fallback", + runtimeMode: "full-access", + }), + ); + + const notificationHandler = client.getNotificationHandler(); + expect(notificationHandler).toBeTypeOf("function"); + if (!notificationHandler) { + return; + } + + const completedEventsPromise = Effect.runPromise( + Stream.runCollect(Stream.take(adapter.streamEvents, 2)), + ); + + notificationHandler({ + method: "cursor/task", + params: { + description: "Run a subagent task", + prompt: "Investigate the failure", + subagentType: "explore", + durationMs: 123, + }, + }); + + const completedEvents = Array.from(await completedEventsPromise); + expect(completedEvents).toHaveLength(2); + + const itemCompletedEvent = completedEvents[0]; + const taskCompletedEvent = completedEvents[1]; + expect(itemCompletedEvent?.type).toBe("item.completed"); + expect(taskCompletedEvent?.type).toBe("task.completed"); + if (itemCompletedEvent?.type !== "item.completed") { + return; + } + if (taskCompletedEvent?.type !== "task.completed") { + return; + } + + expect(itemCompletedEvent.itemId).toBe(taskCompletedEvent.payload.taskId); + expect(itemCompletedEvent.itemId).toMatch(/^cursor-task:/); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("restarts Cursor sessions on rollback and bootstraps the next prompt from preserved transcript", async () => { + const firstPromptResult = deferred<{ readonly stopReason: string }>(); + const secondPromptResult = deferred<{ readonly stopReason: string }>(); + const clients: Array> = []; + + mockedStartCursorAcpClient.mockImplementation(() => { + const sessionIndex = clients.length + 1; + let promptCount = 0; + const client = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult(`cursor-session-rollback-${sessionIndex}`); + case "session/prompt": + if (sessionIndex !== 1) { + return { stopReason: "end_turn" }; + } + promptCount += 1; + return promptCount === 1 ? firstPromptResult.promise : secondPromptResult.promise; + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + clients.push(client); + return client; + }); + + await withAdapter(async (adapter) => { + try { + const threadId = asThreadId("thread-cursor-rollback"); + await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId, + cwd: "/repo/cursor-rollback", + modelSelection: { + provider: "cursor", + model: "gpt-5-mini", + }, + runtimeMode: "full-access", + }), + ); + + const firstTurnPromise = Effect.runPromise( + adapter.sendTurn({ + threadId, + input: "Original prompt", + }), + ); + + const notificationHandler = clients[0]?.getNotificationHandler(); + expect(notificationHandler).toBeTypeOf("function"); + if (!notificationHandler) { + return; + } + + notificationHandler({ + method: "session/update", + params: { + sessionId: "cursor-session-rollback-1", + update: { + sessionUpdate: "agent_message_chunk", + content: { + text: "Original answer", + }, + }, + }, + }); + firstPromptResult.resolve({ stopReason: "end_turn" }); + await firstTurnPromise; + + const secondTurnPromise = Effect.runPromise( + adapter.sendTurn({ + threadId, + input: "Reverted prompt", + modelSelection: { + provider: "cursor", + model: "gpt-5-mini", + }, + }), + ); + secondPromptResult.resolve({ stopReason: "end_turn" }); + await secondTurnPromise; + + const rolledBack = await Effect.runPromise(adapter.rollbackThread(threadId, 1)); + expect(rolledBack.turns).toHaveLength(1); + expect(clients).toHaveLength(2); + + await Effect.runPromise( + adapter.sendTurn({ + threadId, + input: "New prompt", + modelSelection: { + provider: "cursor", + model: "gpt-5-mini", + }, + }), + ); + + const secondPromptCall = clients[1]?.request.mock.calls.find( + ([method]) => method === "session/prompt", + ); + const promptPayload = secondPromptCall?.[1] as + | { + readonly prompt?: ReadonlyArray<{ readonly type?: string; readonly text?: string }>; + } + | undefined; + const bootstrapText = promptPayload?.prompt?.find((part) => part.type === "text")?.text; + + expect(bootstrapText).toContain( + "Continue this conversation using the transcript context below.", + ); + expect(bootstrapText).toContain("Original prompt"); + expect(bootstrapText).not.toContain("Reverted prompt"); + expect(bootstrapText).toContain("Latest user request (answer this now):\nNew prompt"); + } finally { + firstPromptResult.resolve({ stopReason: "end_turn" }); + secondPromptResult.resolve({ stopReason: "end_turn" }); + await Effect.runPromise(adapter.stopAll()); + } + }); + }); + + it("round-trips multi-select ask_question answers back to Cursor ACP option ids", async () => { + const client = makeFakeCursorClient({ + requestImpl: async (method) => { + switch (method) { + case "initialize": + return cursorInitializeResult(); + case "authenticate": + return {}; + case "session/new": + return cursorSessionResult("cursor-session-user-input"); + default: + throw new Error(`Unexpected Cursor ACP request: ${method}`); + } + }, + }); + mockedStartCursorAcpClient.mockReturnValue(client); + + await withAdapter(async (adapter) => { + try { + const session = await Effect.runPromise( + adapter.startSession({ + provider: "cursor", + threadId: asThreadId("thread-user-input"), + cwd: "/repo/user-input", + runtimeMode: "full-access", + }), + ); + + const requestHandler = client.getRequestHandler(); + expect(requestHandler).toBeTypeOf("function"); + if (!requestHandler) { + return; + } + + const requestedEventPromise = Effect.runPromise(Stream.runHead(adapter.streamEvents)); + requestHandler({ + id: 44, + method: "cursor/ask_question", + params: { + title: "Tool selection", + questions: [ + { + id: "tools", + prompt: "Which tools should run?", + allowMultiple: true, + options: [ + { id: "search", label: "Search" }, + { id: "edit", label: "Edit" }, + ], + }, + ], + }, + }); + + const requestedEvent = await requestedEventPromise; + expect(requestedEvent._tag).toBe("Some"); + if (requestedEvent._tag !== "Some") { + return; + } + expect(requestedEvent.value.type).toBe("user-input.requested"); + if (requestedEvent.value.type !== "user-input.requested") { + return; + } + const requestId = requestedEvent.value.requestId; + expect(typeof requestId).toBe("string"); + if (!requestId) { + return; + } + expect(requestedEvent.value.payload.questions).toEqual([ + { + id: "tools", + header: "Tool selection", + question: "Which tools should run?", + multiSelect: true, + options: [ + { label: "Search", description: "Search" }, + { label: "Edit", description: "Edit" }, + ], + }, + ]); + + const resolvedEventPromise = Effect.runPromise(Stream.runHead(adapter.streamEvents)); + await Effect.runPromise( + adapter.respondToUserInput(session.threadId, ApprovalRequestId.makeUnsafe(requestId), { + tools: ["Search", "Edit"], + }), + ); + + const resolvedEvent = await resolvedEventPromise; + expect(resolvedEvent._tag).toBe("Some"); + if (resolvedEvent._tag !== "Some") { + return; + } + expect(resolvedEvent.value.type).toBe("user-input.resolved"); + if (resolvedEvent.value.type !== "user-input.resolved") { + return; + } + expect(resolvedEvent.value.payload.answers).toEqual({ + tools: ["Search", "Edit"], + }); + + expect(client.respond).toHaveBeenCalledWith(44, { + outcome: { + outcome: "answered", + answers: [ + { + questionId: "tools", + selectedOptionIds: ["search", "edit"], + }, + ], + }, + }); + } finally { + await Effect.runPromise(adapter.stopAll()); + } + }); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts new file mode 100644 index 00000000000..06808bd230b --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -0,0 +1,2333 @@ +import { randomUUID } from "node:crypto"; + +import { + DEFAULT_MODEL_BY_PROVIDER, + ApprovalRequestId, + type CanonicalItemType, + type CanonicalRequestType, + type CursorModelOptions, + EventId, + type ProviderRuntimeEvent, + type ProviderSendTurnInput, + RuntimeItemId, + RuntimeRequestId, + RuntimeTaskId, + ThreadId, + TurnId, + type ProviderSession, + type ProviderTurnStartResult, + type RuntimeItemStatus, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { Effect, FileSystem, Layer, PubSub, Stream } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { startCursorAcpClient, type CursorAcpClient, type CursorAcpJsonRpcId } from "../cursorAcp"; +import { type CursorAdapterShape, CursorAdapter } from "../Services/CursorAdapter.ts"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + buildBootstrapPromptFromReplayTurns, + cloneReplayTurns, + type TranscriptReplayTurn, +} from "../providerTranscriptBootstrap.ts"; +import { resolveCursorCliModelId } from "./CursorProvider.ts"; +import { + describeCursorAdapterCause, + findKnownCursorAdapterError, + isMissingCursorSessionError, + isProviderAdapterProcessError, + isProviderAdapterRequestError, + isProviderAdapterSessionNotFoundError, + isProviderAdapterValidationError, +} from "./CursorAdapterErrors.ts"; +import { + type CursorPermissionOption, + type CursorSessionConfigOption, + type CursorSessionConfigOptionValue, + type CursorSessionMetadata, + EMPTY_CURSOR_SESSION_METADATA, + buildCursorSessionMetadata, + cursorSessionMetadataSnapshot, + findCursorConfigOption, + parseCursorAvailableCommands, + parseCursorConfigOptions, + parseCursorInitializeState, + parseCursorSessionModeState, + parseCursorSessionModelState, +} from "./CursorAdapterSessionMetadata.ts"; +import { + buildCursorToolData, + classifyCursorToolItemType, + cursorToolLookupInput, + cursorPermissionKindsForDecision, + cursorPermissionKindsForRuntimeMode, + defaultCursorToolTitle, + describePermissionRequest, + extractCursorStreamText, + extractCursorToolCommand, + extractCursorToolContentText, + extractCursorToolPath, + isFinalCursorToolStatus, + parseCursorPermissionOptions, + permissionOptionKindForRuntimeMode, + requestTypeForCursorTool, + resolveCursorToolTitle, + runtimeItemStatusFromCursorStatus, + selectCursorPermissionOption, + streamKindFromUpdateKind, +} from "./CursorAdapterToolHelpers.ts"; +import { asObject, asTrimmedNonEmptyString as asString } from "../unknown.ts"; + +const PROVIDER = "cursor" as const; +const ACP_CONTROL_TIMEOUT_MS = 15_000; +const ROLLBACK_BOOTSTRAP_MAX_CHARS = 24_000; + +type CursorResumeCursor = { + readonly sessionId: string; +}; + +type PendingApproval = { + readonly requestId: ApprovalRequestId; + readonly jsonRpcId: CursorAcpJsonRpcId; + readonly requestType: CanonicalRequestType; + readonly options: ReadonlyArray; + readonly turnId?: TurnId; +}; + +type PendingUserInput = + | { + readonly requestId: ApprovalRequestId; + readonly jsonRpcId: CursorAcpJsonRpcId; + readonly turnId?: TurnId; + readonly kind: "ask-question"; + readonly optionIdsByQuestionAndLabel: ReadonlyMap>; + readonly questions: ReadonlyArray; + } + | { + readonly requestId: ApprovalRequestId; + readonly jsonRpcId: CursorAcpJsonRpcId; + readonly turnId?: TurnId; + readonly kind: "create-plan"; + readonly questions: ReadonlyArray; + }; + +type CursorContentItemState = { + readonly itemId: RuntimeItemId; + text: string; +}; + +type TurnSnapshot = { + readonly id: TurnId; + readonly startedAtMs: number; + readonly inputText: string; + readonly attachmentNames: ReadonlyArray; + readonly items: Array; + assistantText: string; + interruptRequested: boolean; + reasoningText: string; + assistantItem: CursorContentItemState | undefined; + reasoningItem: CursorContentItemState | undefined; + readonly toolCalls: Map; +}; + +type CursorSessionContext = { + session: ProviderSession; + readonly client: CursorAcpClient; + metadata: CursorSessionMetadata; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array; + readonly replayTurns: Array; + activeTurn: TurnSnapshot | undefined; + pendingBootstrapReset: boolean; + stopping: boolean; + startPromise: Promise | undefined; +}; + +type CursorToolState = { + readonly toolCallId: string; + readonly itemId: RuntimeItemId; + readonly itemType: CanonicalItemType; + readonly title: string; + readonly status: RuntimeItemStatus; + readonly detail?: string; + readonly data: Record; +}; + +function isoNow(): string { + return new Date().toISOString(); +} + +function readResumeSessionId(value: unknown): string | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + const sessionId = (value as { readonly sessionId?: unknown }).sessionId; + return typeof sessionId === "string" && sessionId.trim().length > 0 + ? sessionId.trim() + : undefined; +} + +function contentItemType(kind: "assistant" | "reasoning"): CanonicalItemType { + return kind === "assistant" ? "assistant_message" : "reasoning"; +} + +function contentItemTitle(kind: "assistant" | "reasoning") { + return kind === "assistant" ? "Assistant response" : "Reasoning"; +} + +function getContentItemState( + turn: TurnSnapshot, + kind: "assistant" | "reasoning", +): CursorContentItemState | undefined { + return kind === "assistant" ? turn.assistantItem : turn.reasoningItem; +} + +function setContentItemState( + turn: TurnSnapshot, + kind: "assistant" | "reasoning", + state: CursorContentItemState | undefined, +) { + if (kind === "assistant") { + turn.assistantItem = state; + return; + } + turn.reasoningItem = state; +} + +function requestIdFromApprovalRequest(requestId: ApprovalRequestId) { + return RuntimeRequestId.makeUnsafe(requestId); +} + +function cursorTaskSubagentType(value: unknown): string | undefined { + const direct = asString(value); + if (direct) { + return direct; + } + const record = asObject(value); + return asString(record?.custom); +} + +function cursorModelTokens(value: string): ReadonlySet { + const normalized = value + .trim() + .toLowerCase() + .replace(/\[|\]|=|,/g, "-") + .split(/[^a-z0-9]+/g) + .filter((entry): entry is string => entry.length > 0); + const tokens = new Set(); + for (const entry of normalized) { + if (entry === "default" || entry === "auto") { + tokens.add("default"); + tokens.add("auto"); + continue; + } + if (entry === "false") { + continue; + } + if (entry === "reasoning" || entry === "effort" || entry === "context") { + continue; + } + tokens.add(entry); + } + return tokens; +} + +const CURSOR_MODEL_VARIANT_TOKENS = new Set([ + "auto", + "default", + "fast", + "low", + "medium", + "high", + "xhigh", + "max", + "thinking", + "think", + "none", +]); + +function stripCursorVariantTokens(tokens: ReadonlySet): ReadonlySet { + const filtered = new Set(); + for (const token of tokens) { + if (!CURSOR_MODEL_VARIANT_TOKENS.has(token)) { + filtered.add(token); + } + } + return filtered; +} + +function sameCursorTokenSet(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false; + } + for (const token of left) { + if (!right.has(token)) { + return false; + } + } + return true; +} + +function isCursorTokenSubset(subset: ReadonlySet, superset: ReadonlySet): boolean { + for (const token of subset) { + if (!superset.has(token)) { + return false; + } + } + return true; +} + +function compatibleCursorCoreTokenSets( + left: ReadonlySet, + right: ReadonlySet, +): boolean { + if (left.size === 0 || right.size === 0) { + return true; + } + return isCursorTokenSubset(left, right) || isCursorTokenSubset(right, left); +} + +type ParsedCursorModelConfigChoice = { + readonly choice: CursorSessionConfigOptionValue; + readonly valuePrefix: string; + readonly identityTokens: ReadonlySet; + readonly tokens: ReadonlySet; +}; + +function parseCursorModelConfigChoice( + choice: CursorSessionConfigOptionValue, +): ParsedCursorModelConfigChoice { + const bracketIndex = choice.value.indexOf("["); + const valuePrefix = bracketIndex === -1 ? choice.value : choice.value.slice(0, bracketIndex); + const identityTokens = cursorModelTokens(valuePrefix); + return { + choice, + valuePrefix, + identityTokens, + tokens: new Set([ + ...identityTokens, + ...cursorModelTokens(choice.value), + ...cursorModelTokens(choice.name), + ...cursorModelTokens(choice.description ?? ""), + ]), + }; +} + +function resolveCursorModelConfigValue(input: { + readonly model: string; + readonly options?: CursorModelOptions | null | undefined; + readonly modelOption: CursorSessionConfigOption; +}): string | undefined { + const requestedModelId = input.model.trim(); + const cliModelId = resolveCursorCliModelId({ + model: input.model, + options: input.options, + }); + const exactModelIds = [requestedModelId, cliModelId].filter( + (value, index, values): value is string => + value.length > 0 && + values.findIndex((entry) => entry.toLowerCase() === value.toLowerCase()) === index, + ); + const targetTokens = cursorModelTokens(cliModelId); + const targetIdentityTokens = cursorModelTokens(input.model); + const targetCoreTokens = stripCursorVariantTokens( + targetIdentityTokens.size > 0 ? targetIdentityTokens : targetTokens, + ); + let best: + | { + readonly value: string; + readonly score: number; + } + | undefined; + for (const choice of input.modelOption.options) { + const parsed = parseCursorModelConfigChoice(choice); + const valuePrefix = parsed.valuePrefix.toLowerCase(); + if (exactModelIds.some((modelId) => valuePrefix === modelId.toLowerCase())) { + return choice.value; + } + const choiceCoreTokens = stripCursorVariantTokens(parsed.identityTokens); + if ( + targetCoreTokens.size > 0 && + !compatibleCursorCoreTokenSets(choiceCoreTokens, targetCoreTokens) + ) { + continue; + } + let score = 0; + if (sameCursorTokenSet(choiceCoreTokens, targetCoreTokens)) { + score += 24; + } + for (const token of targetCoreTokens) { + if (parsed.tokens.has(token)) { + score += 12; + } + } + for (const token of targetTokens) { + if (parsed.tokens.has(token)) { + score += 6; + } + } + for (const token of targetIdentityTokens) { + if (parsed.tokens.has(token)) { + score += 3; + } + } + score -= Math.max(0, parsed.tokens.size - targetTokens.size); + if (!best || score > best.score) { + best = { + value: choice.value, + score, + }; + } + } + return best?.value; +} + +function planStepsFromTodos( + todos: unknown, +): Array<{ step: string; status: "pending" | "inProgress" | "completed" }> { + if (!Array.isArray(todos)) { + return []; + } + return todos + .map((todo) => asObject(todo)) + .filter((todo): todo is Record => todo !== undefined) + .map((todo) => ({ + step: asString(todo.content) ?? "Todo", + status: + todo.status === "completed" + ? "completed" + : todo.status === "in_progress" + ? "inProgress" + : "pending", + })); +} + +export const CursorAdapterLive = Layer.effect( + CursorAdapter, + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const settingsService = yield* ServerSettingsService; + const fileSystem = yield* FileSystem.FileSystem; + const services = yield* Effect.services(); + const runPromise = Effect.runPromiseWith(services); + const eventsPubSub = yield* Effect.acquireRelease( + PubSub.unbounded(), + PubSub.shutdown, + ); + const sessions = new Map(); + + const emit = (event: ProviderRuntimeEvent) => { + void runPromise(PubSub.publish(eventsPubSub, event).pipe(Effect.asVoid)); + }; + + const resolveSelectedModel = (modelSelection: { readonly model: string } | undefined) => + modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER.cursor; + + const baseEvent = ( + context: CursorSessionContext, + input: { + readonly turnId?: TurnId; + readonly itemId?: RuntimeItemId; + readonly requestId?: ApprovalRequestId; + readonly rawMethod?: string; + readonly rawPayload?: unknown; + readonly rawSource?: "cursor.acp.request" | "cursor.acp.notification" | undefined; + } = {}, + ) => ({ + eventId: EventId.makeUnsafe(randomUUID()), + provider: PROVIDER, + threadId: context.session.threadId, + createdAt: isoNow(), + ...(input.turnId ? { turnId: input.turnId } : {}), + ...(input.itemId ? { itemId: input.itemId } : {}), + ...(input.requestId ? { requestId: requestIdFromApprovalRequest(input.requestId) } : {}), + ...(input.rawMethod + ? { + raw: { + source: + input.rawSource ?? + (input.requestId + ? ("cursor.acp.request" as const) + : ("cursor.acp.notification" as const)), + method: input.rawMethod, + payload: input.rawPayload ?? {}, + }, + } + : {}), + }); + + const updateSession = (context: CursorSessionContext, patch: Partial) => { + context.session = { + ...context.session, + ...patch, + updatedAt: isoNow(), + }; + }; + + const updateMetadata = ( + context: CursorSessionContext, + patch: Parameters[0], + ) => { + context.metadata = buildCursorSessionMetadata({ + previous: context.metadata, + ...patch, + }); + }; + + const emitSessionConfigured = ( + context: CursorSessionContext, + input: { + readonly rawMethod: string; + readonly rawPayload?: unknown; + readonly rawSource?: "cursor.acp.request" | "cursor.acp.notification" | undefined; + }, + ) => { + emit({ + ...baseEvent(context, { + rawMethod: input.rawMethod, + ...(input.rawPayload !== undefined ? { rawPayload: input.rawPayload } : {}), + ...(input.rawSource ? { rawSource: input.rawSource } : {}), + }), + type: "session.configured", + payload: { + config: cursorSessionMetadataSnapshot(context.metadata), + }, + }); + }; + + const requireCursorSessionId = (context: CursorSessionContext, method: string) => { + const sessionId = readResumeSessionId(context.session.resumeCursor); + if (!sessionId) { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: "Cursor session is missing a resumable session id.", + }); + } + return sessionId; + }; + + const currentCursorModeId = (context: CursorSessionContext) => + findCursorConfigOption(context.metadata.configOptions, { category: "mode", id: "mode" }) + ?.currentValue ?? context.metadata.modes?.currentModeId; + + const currentCursorModelConfigValue = (context: CursorSessionContext) => + findCursorConfigOption(context.metadata.configOptions, { category: "model", id: "model" }) + ?.currentValue ?? context.metadata.models?.currentModelId; + + const availableCursorModeIds = (context: CursorSessionContext) => { + const modeOption = findCursorConfigOption(context.metadata.configOptions, { + category: "mode", + id: "mode", + }); + if (modeOption) { + return new Set(modeOption.options.map((option) => option.value)); + } + return new Set(context.metadata.modes?.availableModes.map((mode) => mode.id) ?? []); + }; + + const cursorControlRequest = async ( + context: CursorSessionContext, + method: string, + params: unknown, + ) => + context.client.request(method, params, { + timeoutMs: ACP_CONTROL_TIMEOUT_MS, + }); + + const buildCursorPromptContent = Effect.fn("buildCursorPromptContent")(function* ( + context: CursorSessionContext, + input: ProviderSendTurnInput, + ) { + const prompt: Array> = []; + if (input.input !== undefined) { + prompt.push({ type: "text", text: input.input }); + } + + const attachments = input.attachments ?? []; + if ( + attachments.length > 0 && + !context.metadata.initialize.agentCapabilities.promptCapabilities.image + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Cursor ACP session does not advertise image prompt support.", + }); + } + + for (const attachment of attachments) { + if (attachment.type !== "image") { + continue; + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: describeCursorAdapterCause(cause), + cause, + }), + ), + ); + prompt.push({ + type: "image", + mimeType: attachment.mimeType, + data: Buffer.from(bytes).toString("base64"), + }); + } + + if (prompt.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Cursor prompts require text or supported prompt attachments.", + }); + } + + return prompt; + }); + + const syncCursorInteractionMode = async ( + context: CursorSessionContext, + interactionMode: ProviderSendTurnInput["interactionMode"], + ) => { + if (interactionMode === undefined) { + return; + } + const sessionId = requireCursorSessionId(context, "session/set_mode"); + const currentModeId = currentCursorModeId(context); + const availableModeIds = availableCursorModeIds(context); + const desiredModeId = + interactionMode === "plan" + ? availableModeIds.has("plan") + ? "plan" + : undefined + : (context.metadata.defaultModeId ?? + currentModeId ?? + findCursorConfigOption(context.metadata.configOptions, { category: "mode", id: "mode" }) + ?.options[0]?.value ?? + context.metadata.modes?.availableModes[0]?.id); + if (!desiredModeId) { + if (interactionMode === "plan") { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/set_mode", + detail: "Cursor ACP session does not expose a plan mode.", + }); + } + return; + } + if (currentModeId === desiredModeId) { + return; + } + const modeOption = findCursorConfigOption(context.metadata.configOptions, { + category: "mode", + id: "mode", + }); + if (modeOption?.options.some((option) => option.value === desiredModeId)) { + const result = await cursorControlRequest(context, "session/set_config_option", { + sessionId, + configId: modeOption.id, + value: desiredModeId, + }); + const resultRecord = asObject(result); + updateMetadata(context, { + configOptions: + resultRecord && "configOptions" in resultRecord + ? parseCursorConfigOptions(resultRecord.configOptions) + : context.metadata.configOptions, + currentModeId: desiredModeId, + }); + emitSessionConfigured(context, { + rawMethod: "session/set_config_option", + rawPayload: result, + rawSource: "cursor.acp.request", + }); + return; + } + if (!availableModeIds.has(desiredModeId)) { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/set_mode", + detail: `Cursor ACP session does not support mode '${desiredModeId}'.`, + }); + } + await cursorControlRequest(context, "session/set_mode", { + sessionId, + modeId: desiredModeId, + }); + updateMetadata(context, { + currentModeId: desiredModeId, + }); + emitSessionConfigured(context, { + rawMethod: "session/set_mode", + rawPayload: { currentModeId: desiredModeId }, + rawSource: "cursor.acp.request", + }); + }; + + const syncCursorModelSelection = async ( + context: CursorSessionContext, + modelSelection: + | { + readonly provider: "cursor"; + readonly model: string; + readonly options?: CursorModelOptions | undefined; + } + | undefined, + ) => { + if (!modelSelection) { + return; + } + const modelOption = findCursorConfigOption(context.metadata.configOptions, { + category: "model", + id: "model", + }); + updateSession(context, { + model: modelSelection.model, + }); + if (!modelOption) { + return; + } + const desiredValue = resolveCursorModelConfigValue({ + model: modelSelection.model, + options: modelSelection.options, + modelOption, + }); + if (!desiredValue) { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/set_config_option", + detail: `Cursor ACP session does not expose model '${modelSelection.model}'.`, + }); + } + if (currentCursorModelConfigValue(context) === desiredValue) { + return; + } + const sessionId = requireCursorSessionId(context, "session/set_config_option"); + const result = await cursorControlRequest(context, "session/set_config_option", { + sessionId, + configId: modelOption.id, + value: desiredValue, + }); + const resultRecord = asObject(result); + updateMetadata(context, { + configOptions: + resultRecord && "configOptions" in resultRecord + ? parseCursorConfigOptions(resultRecord.configOptions) + : context.metadata.configOptions, + currentModelId: desiredValue, + }); + emitSessionConfigured(context, { + rawMethod: "session/set_config_option", + rawPayload: result, + rawSource: "cursor.acp.request", + }); + }; + + const ensureContentItem = ( + context: CursorSessionContext, + turnId: TurnId, + kind: "assistant" | "reasoning", + input: { + readonly rawMethod: string; + readonly rawPayload?: unknown; + readonly rawSource?: "cursor.acp.request" | "cursor.acp.notification" | undefined; + }, + ): CursorContentItemState | undefined => { + const activeTurn = context.activeTurn; + if (!activeTurn || activeTurn.id !== turnId) { + return undefined; + } + const existing = getContentItemState(activeTurn, kind); + if (existing) { + return existing; + } + const state: CursorContentItemState = { + itemId: RuntimeItemId.makeUnsafe(`cursor-${kind}:${randomUUID()}`), + text: "", + }; + setContentItemState(activeTurn, kind, state); + emit({ + ...baseEvent(context, { + turnId, + itemId: state.itemId, + rawMethod: input.rawMethod, + ...(input.rawPayload !== undefined ? { rawPayload: input.rawPayload } : {}), + ...(input.rawSource ? { rawSource: input.rawSource } : {}), + }), + type: "item.started", + payload: { + itemType: contentItemType(kind), + title: contentItemTitle(kind), + status: "inProgress", + }, + }); + return state; + }; + + const completeContentItem = ( + context: CursorSessionContext, + turnId: TurnId, + kind: "assistant" | "reasoning", + input?: { + readonly rawMethod?: string; + readonly rawPayload?: unknown; + readonly rawSource?: "cursor.acp.request" | "cursor.acp.notification" | undefined; + }, + ) => { + const activeTurn = context.activeTurn; + if (!activeTurn || activeTurn.id !== turnId) { + return; + } + const state = getContentItemState(activeTurn, kind); + if (!state) { + return; + } + setContentItemState(activeTurn, kind, undefined); + emit({ + ...baseEvent(context, { + turnId, + itemId: state.itemId, + ...(input?.rawMethod ? { rawMethod: input.rawMethod } : {}), + ...(input?.rawPayload !== undefined ? { rawPayload: input.rawPayload } : {}), + ...(input?.rawSource ? { rawSource: input.rawSource } : {}), + }), + type: "item.completed", + payload: { + itemType: contentItemType(kind), + title: contentItemTitle(kind), + status: "completed", + ...(state.text.length > 0 ? { detail: state.text } : {}), + }, + }); + }; + + const completeActiveContentItems = ( + context: CursorSessionContext, + turnId: TurnId, + input?: { + readonly rawMethod?: string; + readonly rawPayload?: unknown; + readonly rawSource?: "cursor.acp.request" | "cursor.acp.notification" | undefined; + }, + ) => { + completeContentItem(context, turnId, "assistant", input); + completeContentItem(context, turnId, "reasoning", input); + }; + + const emitToolLifecycleEvent = ( + context: CursorSessionContext, + input: { + readonly turnId: TurnId; + readonly tool: CursorToolState; + readonly type: "item.started" | "item.updated" | "item.completed"; + readonly rawMethod: string; + readonly rawPayload?: unknown; + readonly rawSource?: "cursor.acp.request" | "cursor.acp.notification" | undefined; + }, + ) => { + emit({ + ...baseEvent(context, { + turnId: input.turnId, + itemId: input.tool.itemId, + rawMethod: input.rawMethod, + ...(input.rawPayload !== undefined ? { rawPayload: input.rawPayload } : {}), + ...(input.rawSource ? { rawSource: input.rawSource } : {}), + }), + type: input.type, + payload: { + itemType: input.tool.itemType, + status: input.tool.status, + title: input.tool.title, + ...(input.tool.detail ? { detail: input.tool.detail } : {}), + ...(Object.keys(input.tool.data).length > 0 ? { data: input.tool.data } : {}), + }, + }); + }; + + const syncCursorToolCall = ( + context: CursorSessionContext, + turnId: TurnId, + record: Record, + input: { + readonly rawMethod: string; + readonly rawPayload?: unknown; + readonly rawSource?: "cursor.acp.request" | "cursor.acp.notification" | undefined; + }, + ) => { + const activeTurn = context.activeTurn; + if (!activeTurn) { + return undefined; + } + const toolCallId = asString(record.toolCallId); + if (!toolCallId) { + return undefined; + } + const existing = activeTurn.toolCalls.get(toolCallId); + const detectedItemType = classifyCursorToolItemType( + cursorToolLookupInput({ + kind: asString(record.kind), + title: asString(record.title), + }), + ); + const itemType = + existing && existing.itemType !== "dynamic_tool_call" + ? existing.itemType + : detectedItemType; + const status = asString(record.status) + ? runtimeItemStatusFromCursorStatus(asString(record.status)) + : (existing?.status ?? "inProgress"); + const title = resolveCursorToolTitle(itemType, asString(record.title), existing?.title); + const detail = + extractCursorToolCommand(record) ?? + extractCursorToolPath(record) ?? + extractCursorToolContentText(record) ?? + existing?.detail; + const tool: CursorToolState = { + toolCallId, + itemId: existing?.itemId ?? RuntimeItemId.makeUnsafe(`cursor-tool:${randomUUID()}`), + itemType, + title, + status, + ...(detail ? { detail } : {}), + data: buildCursorToolData(existing?.data, record), + }; + activeTurn.toolCalls.set(toolCallId, tool); + if (!existing) { + activeTurn.items.push({ + kind: "tool_call", + toolCallId, + itemType, + data: tool.data, + }); + } + emitToolLifecycleEvent(context, { + turnId, + tool, + type: + !existing && !isFinalCursorToolStatus(status) + ? "item.started" + : isFinalCursorToolStatus(status) + ? "item.completed" + : "item.updated", + rawMethod: input.rawMethod, + ...(input.rawPayload !== undefined ? { rawPayload: input.rawPayload } : {}), + ...(input.rawSource ? { rawSource: input.rawSource } : {}), + }); + return tool; + }; + + const settleTurn = ( + context: CursorSessionContext, + turnId: TurnId, + outcome: + | { + readonly type: "completed"; + readonly stopReason?: string | null; + readonly errorMessage?: string; + } + | { readonly type: "aborted"; readonly reason: string }, + ) => { + if (!context.activeTurn || context.activeTurn.id !== turnId) { + return; + } + + completeActiveContentItems(context, turnId); + context.turns.push(context.activeTurn); + context.replayTurns.push({ + prompt: context.activeTurn.inputText, + attachmentNames: [...context.activeTurn.attachmentNames], + ...(context.activeTurn.assistantText.trim().length > 0 + ? { assistantResponse: context.activeTurn.assistantText } + : {}), + }); + context.activeTurn = undefined; + updateSession(context, { + activeTurnId: undefined, + status: outcome.type === "completed" && outcome.errorMessage ? "error" : "ready", + ...(outcome.type === "completed" && outcome.errorMessage + ? { lastError: outcome.errorMessage } + : {}), + }); + + if (outcome.type === "completed") { + emit({ + ...baseEvent(context, { turnId }), + type: "turn.completed", + payload: { + state: outcome.errorMessage ? "failed" : "completed", + ...(outcome.stopReason !== undefined ? { stopReason: outcome.stopReason } : {}), + ...(outcome.errorMessage ? { errorMessage: outcome.errorMessage } : {}), + }, + }); + return; + } + + emit({ + ...baseEvent(context, { turnId }), + type: "turn.aborted", + payload: { + reason: outcome.reason, + }, + }); + }; + + const cancelPendingApprovalsForTurn = ( + context: CursorSessionContext, + turnId: TurnId, + input: { + readonly rawMethod: string; + readonly rawPayload?: unknown; + }, + ) => { + for (const [requestId, pending] of context.pendingApprovals.entries()) { + if (pending.turnId !== turnId) { + continue; + } + context.pendingApprovals.delete(requestId); + context.client.respond(pending.jsonRpcId, { + outcome: { + outcome: "cancelled", + }, + }); + emit({ + ...baseEvent(context, { + turnId, + requestId, + rawMethod: input.rawMethod, + ...(input.rawPayload !== undefined ? { rawPayload: input.rawPayload } : {}), + rawSource: "cursor.acp.notification", + }), + type: "request.resolved", + payload: { + requestType: pending.requestType, + decision: "cancel", + resolution: { + outcome: "cancelled", + }, + }, + }); + } + }; + + const cancelPendingUserInputsForTurn = ( + context: CursorSessionContext, + turnId: TurnId, + input: { + readonly rawMethod: string; + readonly rawPayload?: unknown; + }, + ) => { + for (const [requestId, pending] of context.pendingUserInputs.entries()) { + if (pending.turnId !== turnId) { + continue; + } + context.pendingUserInputs.delete(requestId); + context.client.respond(pending.jsonRpcId, { + outcome: { + outcome: "cancelled", + }, + }); + emit({ + ...baseEvent(context, { + turnId, + requestId, + rawMethod: input.rawMethod, + ...(input.rawPayload !== undefined ? { rawPayload: input.rawPayload } : {}), + rawSource: "cursor.acp.notification", + }), + type: "user-input.resolved", + payload: { + answers: {}, + }, + }); + } + }; + + const handleSessionUpdate = (context: CursorSessionContext, params: unknown) => { + const record = asObject(params); + const update = asObject(record?.update); + const updateKind = asString(update?.sessionUpdate); + if (!updateKind || !update) { + return; + } + + if (updateKind === "current_mode_update") { + updateMetadata(context, { + currentModeId: asString(update.currentModeId), + }); + emitSessionConfigured(context, { + rawMethod: "session/update", + rawPayload: params, + rawSource: "cursor.acp.notification", + }); + return; + } + + if (updateKind === "config_option_update") { + const configOptions = parseCursorConfigOptions(update.configOptions); + if (configOptions.length > 0) { + updateMetadata(context, { + configOptions, + }); + } + emitSessionConfigured(context, { + rawMethod: "session/update", + rawPayload: params, + rawSource: "cursor.acp.notification", + }); + return; + } + + if (updateKind === "available_commands_update") { + updateMetadata(context, { + availableCommands: parseCursorAvailableCommands(update.availableCommands), + }); + emitSessionConfigured(context, { + rawMethod: "session/update", + rawPayload: params, + rawSource: "cursor.acp.notification", + }); + return; + } + + const turnId = context.activeTurn?.id; + if (!turnId) { + return; + } + + if (updateKind === "tool_call" || updateKind === "tool_call_update") { + completeActiveContentItems(context, turnId, { + rawMethod: "session/update", + rawPayload: params, + }); + syncCursorToolCall(context, turnId, update, { + rawMethod: "session/update", + rawPayload: params, + }); + return; + } + + const text = extractCursorStreamText(update); + if (!text) { + return; + } + + if (updateKind.toLowerCase().includes("plan")) { + completeActiveContentItems(context, turnId, { + rawMethod: "session/update", + rawPayload: params, + }); + emit({ + ...baseEvent(context, { turnId, rawMethod: "session/update", rawPayload: params }), + type: "turn.proposed.delta", + payload: { delta: text }, + }); + return; + } + + const activeTurn = context.activeTurn; + if (!activeTurn) { + return; + } + + const streamKind = streamKindFromUpdateKind(updateKind); + const itemStateInput = { rawMethod: "session/update", rawPayload: params } as const; + let itemId: RuntimeItemId | undefined; + if (streamKind === "assistant_text") { + completeContentItem(context, turnId, "reasoning", itemStateInput); + const assistantItem = ensureContentItem(context, turnId, "assistant", itemStateInput); + if (!assistantItem) { + return; + } + assistantItem.text += text; + itemId = assistantItem.itemId; + activeTurn.assistantText += text; + } else if (streamKind === "reasoning_text" || streamKind === "reasoning_summary_text") { + completeContentItem(context, turnId, "assistant", itemStateInput); + const reasoningItem = ensureContentItem(context, turnId, "reasoning", itemStateInput); + if (!reasoningItem) { + return; + } + reasoningItem.text += text; + itemId = reasoningItem.itemId; + activeTurn.reasoningText += text; + } else { + completeActiveContentItems(context, turnId, itemStateInput); + } + activeTurn.items.push({ kind: streamKind, text, ...(itemId ? { itemId } : {}) }); + emit({ + ...baseEvent(context, { + turnId, + ...(itemId ? { itemId } : {}), + rawMethod: "session/update", + rawPayload: params, + }), + type: "content.delta", + payload: { + streamKind, + delta: text, + }, + }); + }; + + const handleRequest = ( + context: CursorSessionContext, + request: { + readonly id: CursorAcpJsonRpcId; + readonly method: string; + readonly params?: unknown; + }, + ) => { + const turnId = context.activeTurn?.id; + + if (request.method === "session/request_permission") { + const params = asObject(request.params); + const toolCall = asObject(params?.toolCall); + if (toolCall && turnId) { + completeActiveContentItems(context, turnId, { + rawMethod: request.method, + rawPayload: request.params, + rawSource: "cursor.acp.request", + }); + } + if (toolCall && turnId) { + syncCursorToolCall(context, turnId, toolCall, { + rawMethod: request.method, + rawPayload: request.params, + rawSource: "cursor.acp.request", + }); + } + const requestType = requestTypeForCursorTool( + cursorToolLookupInput({ + kind: asString(toolCall?.kind), + title: asString(toolCall?.title), + }), + ); + const permissionOptions = parseCursorPermissionOptions(params?.options); + if (context.session.runtimeMode === "full-access") { + const resolution = permissionOptionKindForRuntimeMode(context.session.runtimeMode); + const selectedOption = selectCursorPermissionOption( + permissionOptions, + cursorPermissionKindsForRuntimeMode(context.session.runtimeMode), + ); + if (selectedOption) { + context.client.respond(request.id, { + outcome: { + outcome: "selected", + optionId: selectedOption.optionId, + }, + }); + emit({ + ...baseEvent(context, { + ...(turnId ? { turnId } : {}), + rawMethod: request.method, + rawPayload: request.params, + rawSource: "cursor.acp.request", + }), + type: "request.resolved", + payload: { + requestType, + decision: resolution.decision, + resolution: { + optionId: selectedOption.optionId, + kind: selectedOption.kind ?? resolution.primary, + }, + }, + }); + } else { + context.client.respond(request.id, { + outcome: { + outcome: "cancelled", + }, + }); + } + return; + } + + const requestId = ApprovalRequestId.makeUnsafe(`cursor-permission:${randomUUID()}`); + context.pendingApprovals.set(requestId, { + requestId, + jsonRpcId: request.id, + requestType, + options: permissionOptions, + ...(turnId ? { turnId } : {}), + }); + emit({ + ...baseEvent(context, { + ...(turnId ? { turnId } : {}), + requestId, + rawMethod: request.method, + rawPayload: request.params, + }), + type: "request.opened", + payload: { + requestType, + ...(describePermissionRequest(request.params) + ? { detail: describePermissionRequest(request.params) } + : {}), + ...(request.params !== undefined ? { args: request.params } : {}), + }, + }); + return; + } + + if (request.method === "cursor/ask_question") { + const params = asObject(request.params); + if (turnId) { + completeActiveContentItems(context, turnId, { + rawMethod: request.method, + rawPayload: request.params, + rawSource: "cursor.acp.request", + }); + } + const questions = Array.isArray(params?.questions) ? params.questions : []; + const optionIdsByQuestionAndLabel = new Map>(); + const normalizedQuestions = questions + .map((entry) => asObject(entry)) + .filter((entry): entry is Record => entry !== undefined) + .map((entry) => { + const questionId = asString(entry.id) ?? `question-${randomUUID()}`; + const options = Array.isArray(entry.options) ? entry.options : []; + const labelMap = new Map(); + const normalizedOptions = options + .map((option) => asObject(option)) + .filter((option): option is Record => option !== undefined) + .map((option) => { + const optionId = asString(option.id) ?? randomUUID(); + const label = asString(option.label) ?? optionId; + labelMap.set(label, optionId); + return { + label, + description: label, + }; + }); + optionIdsByQuestionAndLabel.set(questionId, labelMap); + const normalizedQuestion: { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; + multiSelect?: true; + } = { + id: questionId, + header: asString(params?.title) ?? "Need input", + question: asString(entry.prompt) ?? "Choose an option", + options: normalizedOptions, + }; + if (entry.allowMultiple === true) { + normalizedQuestion.multiSelect = true; + } + return normalizedQuestion; + }); + const requestId = ApprovalRequestId.makeUnsafe(`cursor-question:${randomUUID()}`); + context.pendingUserInputs.set(requestId, { + requestId, + jsonRpcId: request.id, + ...(turnId ? { turnId } : {}), + kind: "ask-question", + optionIdsByQuestionAndLabel, + questions: normalizedQuestions, + }); + emit({ + ...baseEvent(context, { + ...(turnId ? { turnId } : {}), + requestId, + rawMethod: request.method, + rawPayload: request.params, + }), + type: "user-input.requested", + payload: { + questions: normalizedQuestions, + }, + }); + return; + } + + if (request.method === "cursor/create_plan") { + const params = asObject(request.params); + if (turnId) { + completeActiveContentItems(context, turnId, { + rawMethod: request.method, + rawPayload: request.params, + rawSource: "cursor.acp.request", + }); + } + const requestId = ApprovalRequestId.makeUnsafe(`cursor-plan:${randomUUID()}`); + const questionId = "plan_decision"; + const questions: ReadonlyArray = [ + { + id: questionId, + header: asString(params?.name) ?? "Plan approval", + question: asString(params?.overview) ?? "Approve the proposed plan?", + options: [ + { label: "Accept", description: "Approve the proposed plan" }, + { label: "Reject", description: "Reject the proposed plan" }, + { label: "Cancel", description: "Cancel plan approval" }, + ], + }, + ]; + context.pendingUserInputs.set(requestId, { + requestId, + jsonRpcId: request.id, + ...(turnId ? { turnId } : {}), + kind: "create-plan", + questions, + }); + emit({ + ...baseEvent(context, { + ...(turnId ? { turnId } : {}), + rawMethod: request.method, + rawPayload: request.params, + }), + type: "turn.plan.updated", + payload: { + ...(asString(params?.overview) ? { explanation: asString(params?.overview) } : {}), + plan: planStepsFromTodos(params?.todos), + }, + }); + if (asString(params?.plan)) { + emit({ + ...baseEvent(context, { + ...(turnId ? { turnId } : {}), + rawMethod: request.method, + rawPayload: request.params, + }), + type: "turn.proposed.completed", + payload: { + planMarkdown: asString(params?.plan) ?? "", + }, + }); + } + emit({ + ...baseEvent(context, { + ...(turnId ? { turnId } : {}), + requestId, + rawMethod: request.method, + rawPayload: request.params, + }), + type: "user-input.requested", + payload: { + questions, + }, + }); + return; + } + + context.client.respondError( + request.id, + -32601, + `Unsupported Cursor ACP request: ${request.method}`, + ); + }; + + const handleNotification = ( + context: CursorSessionContext, + notification: { readonly method: string; readonly params?: unknown }, + ) => { + if (notification.method === "session/update") { + handleSessionUpdate(context, notification.params); + return; + } + + if (notification.method === "cursor/update_todos") { + const params = asObject(notification.params); + if (context.activeTurn?.id) { + completeActiveContentItems(context, context.activeTurn.id, { + rawMethod: notification.method, + rawPayload: notification.params, + }); + } + emit({ + ...baseEvent(context, { + ...(context.activeTurn?.id ? { turnId: context.activeTurn.id } : {}), + rawMethod: notification.method, + rawPayload: notification.params, + }), + type: "turn.plan.updated", + payload: { + plan: planStepsFromTodos(params?.todos), + }, + }); + return; + } + + if (notification.method === "cursor/task") { + const params = asObject(notification.params); + const turnId = context.activeTurn?.id; + if (turnId) { + completeActiveContentItems(context, turnId, { + rawMethod: notification.method, + rawPayload: notification.params, + }); + } + const subagentType = cursorTaskSubagentType(params?.subagentType); + const itemType = classifyCursorToolItemType( + cursorToolLookupInput({ + title: asString(params?.description), + subagentType, + }), + ); + const prompt = asString(params?.prompt); + const agentId = asString(params?.agentId); + const taskIdentity = agentId ?? `cursor-task:${randomUUID()}`; + const itemId = RuntimeItemId.makeUnsafe(taskIdentity); + emit({ + ...baseEvent(context, { + ...(turnId ? { turnId } : {}), + itemId, + rawMethod: notification.method, + rawPayload: notification.params, + }), + type: "item.completed", + payload: { + itemType, + status: "completed", + title: asString(params?.description) ?? defaultCursorToolTitle(itemType), + ...(prompt ? { detail: prompt } : {}), + data: { + ...(subagentType ? { subagentType } : {}), + ...(prompt ? { prompt } : {}), + ...(asString(params?.model) ? { model: asString(params?.model) } : {}), + ...(agentId ? { agentId } : {}), + ...(typeof params?.durationMs === "number" ? { durationMs: params.durationMs } : {}), + }, + }, + }); + emit({ + ...baseEvent(context, { + ...(turnId ? { turnId } : {}), + rawMethod: notification.method, + rawPayload: notification.params, + }), + type: "task.completed", + payload: { + taskId: RuntimeTaskId.makeUnsafe(taskIdentity), + status: "completed", + ...(asString(params?.description) ? { summary: asString(params?.description) } : {}), + ...(params && "durationMs" in params + ? { usage: { durationMs: params.durationMs } } + : {}), + }, + }); + } + }; + + const startSession: CursorAdapterShape["startSession"] = (input) => + Effect.tryPromise({ + try: async () => { + if (input.modelSelection && input.modelSelection.provider !== PROVIDER) { + throw new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected Cursor model selection, received '${input.modelSelection.provider}'.`, + }); + } + + const existing = sessions.get(input.threadId); + if (existing) { + return existing.startPromise ? await existing.startPromise : existing.session; + } + const settings = await runPromise(settingsService.getSettings); + const selectedModel = resolveSelectedModel(input.modelSelection); + + const client = startCursorAcpClient({ + binaryPath: settings.providers.cursor.binaryPath, + }); + const createdAt = isoNow(); + const session: ProviderSession = { + provider: PROVIDER, + status: "connecting", + runtimeMode: input.runtimeMode, + ...(input.cwd ? { cwd: input.cwd } : {}), + model: selectedModel, + threadId: input.threadId, + createdAt, + updatedAt: createdAt, + }; + const context: CursorSessionContext = { + session, + client, + metadata: EMPTY_CURSOR_SESSION_METADATA, + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + turns: [], + replayTurns: cloneReplayTurns(input.replayTurns), + activeTurn: undefined, + pendingBootstrapReset: false, + stopping: false, + startPromise: undefined, + }; + const startPromise = (async () => { + try { + client.setProtocolErrorHandler((error) => { + emit({ + ...baseEvent(context), + type: "runtime.error", + payload: { + message: error.message, + class: "transport_error", + }, + }); + }); + client.setNotificationHandler((notification) => + handleNotification(context, notification), + ); + client.setRequestHandler((request) => handleRequest(context, request)); + client.setCloseHandler(({ code, signal }) => { + const activeContext = sessions.get(input.threadId); + if (!activeContext) { + return; + } + sessions.delete(input.threadId); + if (activeContext.activeTurn) { + settleTurn(activeContext, activeContext.activeTurn.id, { + type: "completed", + errorMessage: `Cursor ACP exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + }); + } + updateSession(activeContext, { status: "closed", activeTurnId: undefined }); + emit({ + ...baseEvent(activeContext), + type: "session.exited", + payload: { + reason: activeContext.stopping + ? "Cursor session stopped" + : `Cursor ACP exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"}).`, + exitKind: activeContext.stopping ? "graceful" : "error", + }, + }); + }); + + emit({ + ...baseEvent(context), + type: "session.state.changed", + payload: { + state: "starting", + reason: "Starting Cursor ACP session", + }, + }); + + const initialized = parseCursorInitializeState( + await client.request( + "initialize", + { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { + name: "t3code", + version: "0.0.0", + }, + }, + { timeoutMs: ACP_CONTROL_TIMEOUT_MS }, + ), + ); + updateMetadata(context, { + initialize: initialized, + }); + const authMethodId = + initialized.authMethods.find((method) => method.id === "cursor_login")?.id ?? + initialized.authMethods[0]?.id; + if (authMethodId) { + await client.request( + "authenticate", + { methodId: authMethodId }, + { timeoutMs: ACP_CONTROL_TIMEOUT_MS }, + ); + } + + const resumeSessionId = readResumeSessionId(input.resumeCursor); + const canLoadSession = + resumeSessionId !== undefined && + context.metadata.initialize.agentCapabilities.loadSession; + const newSessionParams = { + cwd: input.cwd ?? serverConfig.cwd, + mcpServers: [], + }; + let sessionMethod: "session/load" | "session/new" = canLoadSession + ? "session/load" + : "session/new"; + let sessionResult: Record | undefined; + + if (canLoadSession) { + try { + sessionResult = asObject( + await client.request( + "session/load", + { + ...newSessionParams, + sessionId: resumeSessionId, + }, + { timeoutMs: ACP_CONTROL_TIMEOUT_MS }, + ), + ); + } catch (cause) { + if (!isMissingCursorSessionError(cause)) { + throw cause; + } + sessionMethod = "session/new"; + sessionResult = asObject( + await client.request("session/new", newSessionParams, { + timeoutMs: ACP_CONTROL_TIMEOUT_MS, + }), + ); + } + } else { + sessionResult = asObject( + await client.request("session/new", newSessionParams, { + timeoutMs: ACP_CONTROL_TIMEOUT_MS, + }), + ); + } + const sessionId = + asString(sessionResult?.sessionId) ?? + (sessionMethod === "session/load" ? resumeSessionId : undefined); + if (!sessionId) { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method: sessionMethod, + detail: "Cursor ACP did not return a session id.", + }); + } + context.pendingBootstrapReset = + context.replayTurns.length > 0 && sessionMethod === "session/new"; + + updateSession(context, { + status: "ready", + ...((input.cwd ?? serverConfig.cwd) ? { cwd: input.cwd ?? serverConfig.cwd } : {}), + model: selectedModel, + resumeCursor: { + sessionId, + } satisfies CursorResumeCursor, + }); + updateMetadata(context, { + configOptions: parseCursorConfigOptions(sessionResult?.configOptions), + modes: parseCursorSessionModeState(sessionResult?.modes), + models: parseCursorSessionModelState(sessionResult?.models), + }); + if (input.modelSelection?.provider === PROVIDER) { + await syncCursorModelSelection(context, input.modelSelection); + } + emitSessionConfigured(context, { + rawMethod: sessionMethod, + rawPayload: sessionResult, + rawSource: "cursor.acp.request", + }); + + emit({ + ...baseEvent(context), + type: "session.started", + payload: { + resume: context.session.resumeCursor, + }, + }); + emit({ + ...baseEvent(context), + type: "session.state.changed", + payload: { + state: "ready", + }, + }); + emit({ + ...baseEvent(context), + type: "thread.started", + payload: { + providerThreadId: sessionId, + }, + }); + + return context.session; + } catch (cause) { + context.stopping = true; + sessions.delete(input.threadId); + context.client.child.kill("SIGTERM"); + throw cause; + } finally { + context.startPromise = undefined; + } + })(); + context.startPromise = startPromise; + sessions.set(input.threadId, context); + return await startPromise; + }, + catch: (cause) => { + const known = findKnownCursorAdapterError(cause); + return isProviderAdapterValidationError(known) || + isProviderAdapterProcessError(known) || + isProviderAdapterRequestError(known) + ? known + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "startSession", + detail: describeCursorAdapterCause(cause), + }); + }, + }); + + const sendTurn: CursorAdapterShape["sendTurn"] = (input) => + Effect.tryPromise({ + try: async () => { + const context = sessions.get(input.threadId); + if (!context) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId: input.threadId, + }); + } + if (context.startPromise) { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Cursor session is still starting.", + }); + } + if (input.modelSelection && input.modelSelection.provider !== PROVIDER) { + throw new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Expected Cursor model selection, received '${input.modelSelection.provider}'.`, + }); + } + if (context.activeTurn) { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: "Cursor session already has an active turn.", + }); + } + + const sessionId = requireCursorSessionId(context, "session/prompt"); + const promptInput = context.pendingBootstrapReset + ? { + ...input, + input: buildBootstrapPromptFromReplayTurns( + context.replayTurns, + input.input ?? "Please analyze the attached files.", + ROLLBACK_BOOTSTRAP_MAX_CHARS, + ).text, + } + : input; + const prompt = await runPromise(buildCursorPromptContent(context, promptInput)); + if (input.modelSelection?.provider === PROVIDER) { + await syncCursorModelSelection(context, input.modelSelection); + } + await syncCursorInteractionMode(context, input.interactionMode); + + const turnId = TurnId.makeUnsafe(`cursor-turn:${randomUUID()}`); + const selectedModel = resolveSelectedModel(input.modelSelection); + const activeTurn: TurnSnapshot = { + id: turnId, + startedAtMs: Date.now(), + inputText: input.input ?? "", + attachmentNames: (input.attachments ?? []).map((attachment) => attachment.name), + items: [], + assistantText: "", + interruptRequested: false, + reasoningText: "", + assistantItem: undefined, + reasoningItem: undefined, + toolCalls: new Map(), + }; + context.activeTurn = activeTurn; + updateSession(context, { + status: "running", + activeTurnId: turnId, + model: selectedModel, + }); + emit({ + ...baseEvent(context, { turnId }), + type: "turn.started", + payload: { + model: selectedModel, + ...(input.interactionMode === "plan" ? { effort: "plan" } : {}), + }, + }); + + void context.client + .request("session/prompt", { + sessionId, + prompt, + }) + .then((result) => { + context.pendingBootstrapReset = false; + const record = asObject(result); + const stopReason = asString(record?.stopReason) ?? null; + if ( + context.activeTurn?.id === turnId && + (context.activeTurn.interruptRequested || stopReason === "cancelled") + ) { + settleTurn(context, turnId, { + type: "aborted", + reason: "Turn cancelled", + }); + return; + } + settleTurn(context, turnId, { + type: "completed", + stopReason, + }); + }) + .catch((error) => { + emit({ + ...baseEvent(context, { turnId }), + type: "runtime.error", + payload: { + message: error instanceof Error ? error.message : String(error), + class: "provider_error", + }, + }); + if (context.activeTurn?.id === turnId && context.activeTurn.interruptRequested) { + settleTurn(context, turnId, { + type: "aborted", + reason: "Turn cancelled", + }); + return; + } + settleTurn(context, turnId, { + type: "completed", + errorMessage: error instanceof Error ? error.message : String(error), + }); + }); + + return { + threadId: input.threadId, + turnId, + resumeCursor: context.session.resumeCursor, + } satisfies ProviderTurnStartResult; + }, + catch: (cause) => { + const known = findKnownCursorAdapterError(cause); + return isProviderAdapterSessionNotFoundError(known) || + isProviderAdapterValidationError(known) || + isProviderAdapterRequestError(known) + ? known + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: describeCursorAdapterCause(cause), + }); + }, + }); + + const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId, turnId) => + Effect.tryPromise({ + try: async () => { + const context = sessions.get(threadId); + if (!context) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + if (!context.activeTurn) { + return; + } + if (turnId && context.activeTurn.id !== turnId) { + return; + } + const activeTurnId = context.activeTurn.id; + const sessionId = requireCursorSessionId(context, "session/cancel"); + context.client.notify("session/cancel", { sessionId }); + context.activeTurn.interruptRequested = true; + cancelPendingApprovalsForTurn(context, activeTurnId, { + rawMethod: "session/cancel", + rawPayload: { sessionId }, + }); + cancelPendingUserInputsForTurn(context, activeTurnId, { + rawMethod: "session/cancel", + rawPayload: { sessionId }, + }); + settleTurn(context, activeTurnId, { + type: "aborted", + reason: "Turn cancelled", + }); + }, + catch: (cause) => { + const known = findKnownCursorAdapterError(cause); + return isProviderAdapterSessionNotFoundError(known) || + isProviderAdapterRequestError(known) + ? known + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/cancel", + detail: describeCursorAdapterCause(cause), + }); + }, + }); + + const respondToRequest: CursorAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.tryPromise({ + try: async () => { + const context = sessions.get(threadId); + if (!context) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + const pending = context.pendingApprovals.get(requestId); + if (!pending) { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request '${requestId}'.`, + }); + } + context.pendingApprovals.delete(requestId); + const selectedOption = selectCursorPermissionOption( + pending.options, + cursorPermissionKindsForDecision(decision), + ); + if (selectedOption) { + context.client.respond(pending.jsonRpcId, { + outcome: { + outcome: "selected", + optionId: selectedOption.optionId, + }, + }); + } else { + context.client.respond(pending.jsonRpcId, { + outcome: { + outcome: "cancelled", + }, + }); + } + emit({ + ...baseEvent(context, { + ...(pending.turnId ? { turnId: pending.turnId } : {}), + requestId, + }), + type: "request.resolved", + payload: { + requestType: pending.requestType, + decision, + resolution: { + ...(selectedOption ? { optionId: selectedOption.optionId } : {}), + ...(selectedOption?.kind ? { kind: selectedOption.kind } : {}), + ...(!selectedOption ? { outcome: "cancelled" } : {}), + }, + }, + }); + }, + catch: (cause) => { + const known = findKnownCursorAdapterError(cause); + return isProviderAdapterSessionNotFoundError(known) || + isProviderAdapterRequestError(known) + ? known + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: describeCursorAdapterCause(cause), + }); + }, + }); + + const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.tryPromise({ + try: async () => { + const context = sessions.get(threadId); + if (!context) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + const pending = context.pendingUserInputs.get(requestId); + if (!pending) { + throw new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "cursor/ask_question", + detail: `Unknown pending user-input request '${requestId}'.`, + }); + } + + context.pendingUserInputs.delete(requestId); + + if (pending.kind === "ask-question") { + const selectedAnswers = pending.questions.map((question) => { + const answer = answers[question.id]; + const labels = + typeof answer === "string" + ? [answer] + : Array.isArray(answer) + ? answer.filter((entry): entry is string => typeof entry === "string") + : []; + const optionIdsByLabel = pending.optionIdsByQuestionAndLabel.get(question.id); + return { + questionId: question.id, + selectedOptionIds: [ + ...new Set( + labels + .map((label) => optionIdsByLabel?.get(label)) + .filter((optionId): optionId is string => typeof optionId === "string"), + ), + ], + }; + }); + context.client.respond(pending.jsonRpcId, { + outcome: { + outcome: "answered", + answers: selectedAnswers, + }, + }); + } else { + const answer = + typeof answers.plan_decision === "string" ? answers.plan_decision : "Cancel"; + context.client.respond(pending.jsonRpcId, { + outcome: + answer === "Accept" + ? { outcome: "accepted" } + : answer === "Reject" + ? { outcome: "rejected", reason: "Rejected in T3 Code" } + : { outcome: "cancelled" }, + }); + } + + emit({ + ...baseEvent(context, { + ...(pending.turnId ? { turnId: pending.turnId } : {}), + requestId, + }), + type: "user-input.resolved", + payload: { + answers, + }, + }); + }, + catch: (cause) => { + const known = findKnownCursorAdapterError(cause); + return isProviderAdapterSessionNotFoundError(known) || + isProviderAdapterRequestError(known) + ? known + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToUserInput", + detail: describeCursorAdapterCause(cause), + }); + }, + }); + + const stopSession: CursorAdapterShape["stopSession"] = (threadId) => + Effect.tryPromise({ + try: async () => { + const context = sessions.get(threadId); + if (!context) { + throw new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + context.stopping = true; + await context.client.close(); + sessions.delete(threadId); + }, + catch: (cause) => { + const known = findKnownCursorAdapterError(cause); + return isProviderAdapterSessionNotFoundError(known) || + isProviderAdapterProcessError(known) + ? known + : new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: describeCursorAdapterCause(cause), + }); + }, + }); + + const listSessions: CursorAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (context) => context.session)); + + const hasSession: CursorAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const readThread: CursorAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return { + threadId, + turns: context.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }); + + const rollbackThread: CursorAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( + function* (threadId, numTurns) { + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + if (context.activeTurn) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "rollbackThread", + detail: "Cursor cannot roll back while a turn is still running.", + }); + } + + const nextLength = Math.max(0, context.turns.length - numTurns); + const trimmedTurns = context.turns.slice(0, nextLength).map((turn) => ({ + id: turn.id, + startedAtMs: turn.startedAtMs, + inputText: turn.inputText, + attachmentNames: [...turn.attachmentNames], + items: [...turn.items], + assistantText: turn.assistantText, + interruptRequested: turn.interruptRequested, + reasoningText: turn.reasoningText, + assistantItem: turn.assistantItem, + reasoningItem: turn.reasoningItem, + toolCalls: new Map(turn.toolCalls), + })); + const trimmedReplayTurns = context.replayTurns.slice(0, nextLength).map((turn) => { + if (turn.assistantResponse !== undefined) { + return { + prompt: turn.prompt, + attachmentNames: [...turn.attachmentNames], + assistantResponse: turn.assistantResponse, + }; + } + + return { + prompt: turn.prompt, + attachmentNames: [...turn.attachmentNames], + }; + }); + + const restartInput = { + provider: PROVIDER, + threadId, + runtimeMode: context.session.runtimeMode, + ...(context.session.cwd ? { cwd: context.session.cwd } : {}), + ...(context.session.model + ? { + modelSelection: { + provider: PROVIDER, + model: context.session.model, + } as const, + } + : {}), + }; + + yield* stopSession(threadId); + yield* startSession(restartInput); + + const restarted = sessions.get(threadId); + if (!restarted) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "rollbackThread", + detail: "Cursor rollback failed to recreate the session.", + }); + } + + restarted.turns.push(...trimmedTurns); + restarted.replayTurns.push(...trimmedReplayTurns); + restarted.pendingBootstrapReset = trimmedReplayTurns.length > 0; + + return { + threadId, + turns: restarted.turns.map((turn) => ({ + id: turn.id, + items: [...turn.items], + })), + }; + }, + ); + + const stopAll: CursorAdapterShape["stopAll"] = () => + Effect.promise(() => + Promise.all( + Array.from(sessions.entries()).map(async ([threadId, context]) => { + context.stopping = true; + sessions.delete(threadId); + await context.client.close(); + }), + ).then(() => undefined), + ); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "restart-session" }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + stopAll, + get streamEvents() { + return Stream.fromPubSub(eventsPubSub); + }, + } satisfies CursorAdapterShape; + }), +); + +export function makeCursorAdapterLive() { + return CursorAdapterLive; +} + +export { + classifyCursorToolItemType, + describePermissionRequest, + extractCursorStreamText, + permissionOptionKindForRuntimeMode, + requestTypeForCursorTool, + runtimeItemStatusFromCursorStatus, + streamKindFromUpdateKind, +} from "./CursorAdapterToolHelpers.ts"; diff --git a/apps/server/src/provider/Layers/CursorAdapterErrors.test.ts b/apps/server/src/provider/Layers/CursorAdapterErrors.test.ts new file mode 100644 index 00000000000..b31777369cc --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterErrors.test.ts @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, +} from "../Errors.ts"; +import { + describeCursorAdapterCause, + findKnownCursorAdapterError, + isMissingCursorSessionError, +} from "./CursorAdapterErrors.ts"; + +describe("CursorAdapterErrors", () => { + it("finds known adapter errors through nested causes", () => { + const known = new ProviderAdapterRequestError({ + provider: "cursor", + method: "session/load", + detail: "Session not found: dead-session", + }); + + const nested = new Error("outer wrapper", { + cause: new Error("An error occurred in Effect.tryPromise", { cause: known }), + }); + + assert.strictEqual(findKnownCursorAdapterError(nested), known); + }); + + it("describes the first meaningful error message in a cause chain", () => { + const cause = new Error("An error occurred in Effect.try", { + cause: new ProviderAdapterProcessError({ + provider: "cursor", + threadId: "thread-1", + detail: "cursor-agent exited unexpectedly", + }), + }); + + assert.equal( + describeCursorAdapterCause(cause), + "Provider adapter process error (cursor) for thread thread-1: cursor-agent exited unexpectedly", + ); + }); + + it("detects missing remote session errors from request failures and plain messages, but excludes local adapter thread misses", () => { + const missingAdapterThreadError = new ProviderAdapterSessionNotFoundError({ + provider: "cursor", + threadId: "thread-404", + }); + const missingRequestError = new ProviderAdapterRequestError({ + provider: "cursor", + method: "session/load", + detail: "Session not found: abc123", + }); + + assert.equal(isMissingCursorSessionError(missingAdapterThreadError), false); + assert.equal(isMissingCursorSessionError(missingRequestError), true); + assert.equal( + isMissingCursorSessionError(new Error("Request failed: Session not found: abc123")), + true, + ); + assert.equal(isMissingCursorSessionError(new Error("Unknown session handle")), true); + assert.equal(isMissingCursorSessionError(new Error("permission denied")), false); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapterErrors.ts b/apps/server/src/provider/Layers/CursorAdapterErrors.ts new file mode 100644 index 00000000000..d31a7989fb0 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterErrors.ts @@ -0,0 +1,82 @@ +import { Schema } from "effect"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; + +export const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); +export const isProviderAdapterSessionNotFoundError = Schema.is(ProviderAdapterSessionNotFoundError); +export const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); +export const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); + +function nextCause(value: unknown): unknown | undefined { + if (!value || typeof value !== "object" || !("cause" in value)) { + return undefined; + } + const cause = (value as { readonly cause?: unknown }).cause; + return cause === value ? undefined : cause; +} + +function causeChain(cause: unknown): ReadonlyArray { + const chain: Array = []; + let current: unknown = cause; + let depth = 0; + while (current !== undefined && depth < 8) { + chain.push(current); + current = nextCause(current); + depth += 1; + } + return chain; +} + +export function findKnownCursorAdapterError( + cause: unknown, +): + | ProviderAdapterValidationError + | ProviderAdapterSessionNotFoundError + | ProviderAdapterRequestError + | ProviderAdapterProcessError + | undefined { + for (const candidate of causeChain(cause)) { + if ( + isProviderAdapterValidationError(candidate) || + isProviderAdapterSessionNotFoundError(candidate) || + isProviderAdapterRequestError(candidate) || + isProviderAdapterProcessError(candidate) + ) { + return candidate; + } + } + return undefined; +} + +export function describeCursorAdapterCause(cause: unknown): string { + for (const candidate of causeChain(cause)) { + if (!(candidate instanceof Error)) { + continue; + } + if ( + candidate.message !== "An error occurred in Effect.try" && + candidate.message !== "An error occurred in Effect.tryPromise" + ) { + return candidate.message; + } + } + return cause instanceof Error ? cause.message : String(cause); +} + +export function isMissingCursorSessionError(cause: unknown): boolean { + const known = findKnownCursorAdapterError(cause); + if (isProviderAdapterSessionNotFoundError(known)) { + return false; + } + const message = describeCursorAdapterCause(cause).toLowerCase(); + return ( + message.includes("session not found") || + message.includes("unknown session") || + (message.includes("not found") && message.includes("session")) + ); +} diff --git a/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.test.ts b/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.test.ts new file mode 100644 index 00000000000..d3768be2a6b --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.test.ts @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { + EMPTY_CURSOR_SESSION_METADATA, + buildCursorSessionMetadata, + cursorSessionMetadataSnapshot, + findCursorConfigOption, + parseCursorAvailableCommands, + parseCursorConfigOptions, + parseCursorInitializeState, + parseCursorSessionModeState, + parseCursorSessionModelState, +} from "./CursorAdapterSessionMetadata.ts"; + +describe("CursorAdapterSessionMetadata", () => { + it("parses initialize state and filters invalid auth methods", () => { + const parsed = parseCursorInitializeState({ + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + promptCapabilities: { + image: true, + audio: false, + embeddedContext: true, + }, + }, + authMethods: [{ id: "cursor_login", name: "Cursor Login" }, { id: " " }, null], + }); + + assert.deepEqual(parsed, { + protocolVersion: 1, + agentCapabilities: { + loadSession: true, + promptCapabilities: { + image: true, + audio: false, + embeddedContext: true, + }, + }, + authMethods: [{ id: "cursor_login", name: "Cursor Login" }], + }); + }); + + it("parses mode and model state only when meaningful values exist", () => { + assert.equal(parseCursorSessionModeState(undefined), undefined); + assert.equal(parseCursorSessionModelState(undefined), undefined); + + assert.deepEqual( + parseCursorSessionModeState({ + currentModeId: "plan", + availableModes: [{ id: "agent", name: "Agent" }, { id: "plan" }, { bad: true }], + }), + { + currentModeId: "plan", + availableModes: [{ id: "agent", name: "Agent" }, { id: "plan" }], + }, + ); + + assert.deepEqual( + parseCursorSessionModelState({ + currentModelId: "gpt-5-mini[]", + availableModels: [ + { modelId: "gpt-5-mini[]", name: "GPT-5 mini (current, default)" }, + { bad: true }, + ], + }), + { + currentModelId: "gpt-5-mini[]", + availableModels: [{ modelId: "gpt-5-mini[]", name: "GPT-5 mini" }], + }, + ); + }); + + it("strips Cursor current/default suffixes from config option labels", () => { + const configOptions = parseCursorConfigOptions([ + { + id: "model", + name: "Model", + category: "model", + currentValue: "composer-2-fast[]", + options: [ + { value: "composer-2-fast[]", name: "Composer 2 Fast (current, default)" }, + { value: "gpt-5.1-codex-max[]", name: "GPT-5.1 Codex Max (default)" }, + ], + }, + ]); + + assert.deepEqual(configOptions, [ + { + id: "model", + name: "Model", + category: "model", + currentValue: "composer-2-fast[]", + options: [ + { value: "composer-2-fast[]", name: "Composer 2 Fast" }, + { value: "gpt-5.1-codex-max[]", name: "GPT-5.1 Codex Max" }, + ], + }, + ]); + }); + + it("builds metadata by merging config options with explicit session state", () => { + const configOptions = parseCursorConfigOptions([ + { + id: "mode", + name: "Mode", + category: "mode", + currentValue: "agent", + options: [ + { value: "agent", name: "Agent" }, + { value: "plan", name: "Plan" }, + ], + }, + { + id: "model", + name: "Model", + category: "model", + currentValue: "gpt-5-mini[]", + options: [ + { value: "gpt-5-mini[]", name: "GPT-5 mini" }, + { value: "claude-4.6-opus[]", name: "Claude 4.6 Opus" }, + ], + }, + ]); + + const metadata = buildCursorSessionMetadata({ + previous: EMPTY_CURSOR_SESSION_METADATA, + initialize: parseCursorInitializeState({ + agentCapabilities: { loadSession: true, promptCapabilities: { image: true } }, + }), + configOptions, + currentModeId: "plan", + currentModelId: "claude-4.6-opus[]", + availableCommands: parseCursorAvailableCommands([ + { name: "search", description: "Search files" }, + ]), + }); + + assert.deepEqual(findCursorConfigOption(metadata.configOptions, { category: "mode" }), { + id: "mode", + name: "Mode", + category: "mode", + currentValue: "plan", + options: [ + { value: "agent", name: "Agent" }, + { value: "plan", name: "Plan" }, + ], + }); + assert.equal(metadata.modes?.currentModeId, "plan"); + assert.equal(metadata.models?.currentModelId, "claude-4.6-opus[]"); + assert.equal(metadata.defaultModeId, "plan"); + assert.deepEqual(metadata.availableCommands, [{ name: "search", description: "Search files" }]); + }); + + it("creates a compact metadata snapshot that omits empty optional fields", () => { + const metadata = buildCursorSessionMetadata({ + previous: EMPTY_CURSOR_SESSION_METADATA, + configOptions: [], + }); + + assert.deepEqual(cursorSessionMetadataSnapshot(metadata), { + initialize: metadata.initialize, + configOptions: [], + }); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.ts b/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.ts new file mode 100644 index 00000000000..4e7c8223de2 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.ts @@ -0,0 +1,536 @@ +import { + asObject, + asReadonlyArray as asArray, + asTrimmedNonEmptyString as asString, +} from "../unknown.ts"; + +export type CursorPromptCapabilities = { + readonly image: boolean; + readonly audio: boolean; + readonly embeddedContext: boolean; +}; + +export type CursorPermissionOptionKind = + | "allow_once" + | "allow_always" + | "reject_once" + | "reject_always"; + +export type CursorPermissionOption = { + readonly optionId: string; + readonly kind?: CursorPermissionOptionKind; + readonly name?: string; +}; + +export type CursorAuthMethod = { + readonly id: string; + readonly name?: string; + readonly description?: string; +}; + +export type CursorInitializeState = { + readonly protocolVersion?: number; + readonly agentCapabilities: { + readonly loadSession: boolean; + readonly promptCapabilities: CursorPromptCapabilities; + }; + readonly authMethods: ReadonlyArray; +}; + +export type CursorSessionModeDefinition = { + readonly id: string; + readonly name?: string; + readonly description?: string; +}; + +export type CursorSessionModeState = { + readonly currentModeId?: string; + readonly availableModes: ReadonlyArray; +}; + +export type CursorSessionModelDefinition = { + readonly modelId: string; + readonly name?: string; +}; + +export type CursorSessionModelState = { + readonly currentModelId?: string; + readonly availableModels: ReadonlyArray; +}; + +export type CursorSessionConfigOptionValue = { + readonly value: string; + readonly name: string; + readonly description?: string; +}; + +export type CursorSessionConfigOption = { + readonly id: string; + readonly name: string; + readonly description?: string; + readonly category?: string; + readonly currentValue: string; + readonly options: ReadonlyArray; +}; + +export type CursorAvailableCommand = { + readonly name: string; + readonly description?: string; +}; + +export type CursorSessionMetadata = { + readonly initialize: CursorInitializeState; + readonly configOptions: ReadonlyArray; + readonly modes?: CursorSessionModeState; + readonly models?: CursorSessionModelState; + readonly availableCommands: ReadonlyArray; + readonly defaultModeId?: string; +}; + +export const EMPTY_CURSOR_PROMPT_CAPABILITIES: CursorPromptCapabilities = { + image: false, + audio: false, + embeddedContext: false, +}; + +export const EMPTY_CURSOR_INITIALIZE_STATE: CursorInitializeState = { + agentCapabilities: { + loadSession: false, + promptCapabilities: EMPTY_CURSOR_PROMPT_CAPABILITIES, + }, + authMethods: [], +}; + +export const EMPTY_CURSOR_SESSION_METADATA: CursorSessionMetadata = { + initialize: EMPTY_CURSOR_INITIALIZE_STATE, + configOptions: [], + availableCommands: [], +}; + +function sanitizeCursorDisplayLabel(value: string): string { + const normalized = value.trim().replace(/\s+/g, " "); + const suffixMatch = /\s+\(([^()]+)\)$/.exec(normalized); + if (!suffixMatch) { + return normalized; + } + + const statuses = (suffixMatch[1] ?? "") + .split(",") + .map((entry) => entry.trim().toLowerCase()) + .filter((entry) => entry.length > 0); + if (statuses.length === 0) { + return normalized; + } + + return statuses.every((entry) => entry === "current" || entry === "default") + ? normalized.slice(0, suffixMatch.index).trim() + : normalized; +} + +function parseCursorPromptCapabilities(value: unknown): CursorPromptCapabilities { + const record = asObject(value); + return { + image: record?.image === true, + audio: record?.audio === true, + embeddedContext: record?.embeddedContext === true, + }; +} + +function parseCursorAuthMethods(value: unknown): ReadonlyArray { + const methods = asArray(value); + if (!methods) { + return []; + } + const parsed: Array = []; + for (const method of methods) { + const entry = asObject(method); + if (!entry) { + continue; + } + const id = asString(entry.id); + if (!id) { + continue; + } + const normalized: { id: string; name?: string; description?: string } = { id }; + const name = asString(entry.name); + if (name) { + normalized.name = name; + } + const description = asString(entry.description); + if (description) { + normalized.description = description; + } + parsed.push(normalized); + } + return parsed; +} + +export function parseCursorInitializeState(value: unknown): CursorInitializeState { + const record = asObject(value); + const agentCapabilities = asObject(record?.agentCapabilities); + return { + ...(typeof record?.protocolVersion === "number" + ? { protocolVersion: record.protocolVersion } + : {}), + agentCapabilities: { + loadSession: agentCapabilities?.loadSession === true, + promptCapabilities: parseCursorPromptCapabilities(agentCapabilities?.promptCapabilities), + }, + authMethods: parseCursorAuthMethods(record?.authMethods), + }; +} + +export function parseCursorSessionModeState(value: unknown): CursorSessionModeState | undefined { + const record = asObject(value); + const availableModesRaw = asArray(record?.availableModes); + const availableModes: Array = []; + if (availableModesRaw) { + for (const mode of availableModesRaw) { + const entry = asObject(mode); + if (!entry) { + continue; + } + const id = asString(entry.id); + if (!id) { + continue; + } + const normalized: { id: string; name?: string; description?: string } = { id }; + const name = asString(entry.name); + if (name) { + normalized.name = name; + } + const description = asString(entry.description); + if (description) { + normalized.description = description; + } + availableModes.push(normalized); + } + } + const currentModeId = asString(record?.currentModeId); + if (!currentModeId && availableModes.length === 0) { + return undefined; + } + return { + ...(currentModeId ? { currentModeId } : {}), + availableModes, + }; +} + +export function parseCursorSessionModelState(value: unknown): CursorSessionModelState | undefined { + const record = asObject(value); + const availableModelsRaw = asArray(record?.availableModels); + const availableModels: Array = []; + if (availableModelsRaw) { + for (const model of availableModelsRaw) { + const entry = asObject(model); + if (!entry) { + continue; + } + const modelId = asString(entry.modelId); + if (!modelId) { + continue; + } + const normalized: { modelId: string; name?: string } = { modelId }; + const name = asString(entry.name); + if (name) { + normalized.name = sanitizeCursorDisplayLabel(name); + } + availableModels.push(normalized); + } + } + const currentModelId = asString(record?.currentModelId); + if (!currentModelId && availableModels.length === 0) { + return undefined; + } + return { + ...(currentModelId ? { currentModelId } : {}), + availableModels, + }; +} + +function parseCursorConfigOptionValues( + value: unknown, +): ReadonlyArray { + const options = asArray(value); + if (!options) { + return []; + } + const parsed: Array = []; + for (const option of options) { + const entry = asObject(option); + if (!entry) { + continue; + } + const optionValue = asString(entry.value); + const rawName = asString(entry.name); + const name = rawName ? sanitizeCursorDisplayLabel(rawName) : optionValue; + if (!optionValue || !name) { + continue; + } + const normalized: { value: string; name: string; description?: string } = { + value: optionValue, + name, + }; + const description = asString(entry.description); + if (description) { + normalized.description = description; + } + parsed.push(normalized); + } + return parsed; +} + +export function parseCursorConfigOptions(value: unknown): ReadonlyArray { + const configOptions = asArray(value); + if (!configOptions) { + return []; + } + const parsed: Array = []; + for (const option of configOptions) { + const entry = asObject(option); + if (!entry) { + continue; + } + const id = asString(entry.id); + const name = asString(entry.name); + const currentValue = asString(entry.currentValue); + if (!id || !name || !currentValue) { + continue; + } + const normalized: { + id: string; + name: string; + currentValue: string; + options: ReadonlyArray; + description?: string; + category?: string; + } = { + id, + name, + currentValue, + options: parseCursorConfigOptionValues(entry.options), + }; + const description = asString(entry.description); + if (description) { + normalized.description = description; + } + const category = asString(entry.category); + if (category) { + normalized.category = category; + } + parsed.push(normalized); + } + return parsed; +} + +export function parseCursorAvailableCommands( + value: unknown, +): ReadonlyArray { + const commands = asArray(value); + if (!commands) { + return []; + } + const parsed: Array = []; + for (const command of commands) { + const entry = asObject(command); + if (!entry) { + continue; + } + const name = asString(entry.name); + if (!name) { + continue; + } + const normalized: { name: string; description?: string } = { name }; + const description = asString(entry.description); + if (description) { + normalized.description = description; + } + parsed.push(normalized); + } + return parsed; +} + +export function findCursorConfigOption( + configOptions: ReadonlyArray, + input: { readonly category?: string; readonly id?: string }, +): CursorSessionConfigOption | undefined { + const normalizedCategory = input.category?.trim().toLowerCase(); + const normalizedId = input.id?.trim().toLowerCase(); + return configOptions.find((option) => { + if (normalizedCategory && option.category?.trim().toLowerCase() === normalizedCategory) { + return true; + } + return normalizedId !== undefined && option.id.trim().toLowerCase() === normalizedId; + }); +} + +function replaceCursorConfigOptionCurrentValue( + configOptions: ReadonlyArray, + optionId: string | undefined, + currentValue: string | undefined, +): ReadonlyArray { + if (!optionId || !currentValue) { + return configOptions; + } + return configOptions.map((option) => + option.id === optionId && option.currentValue !== currentValue + ? { ...option, currentValue } + : option, + ); +} + +function cursorModeStateFromConfigOption( + option: CursorSessionConfigOption | undefined, +): CursorSessionModeState | undefined { + if (!option) { + return undefined; + } + return { + currentModeId: option.currentValue, + availableModes: option.options.map((entry) => ({ + id: entry.value, + name: entry.name, + ...(entry.description ? { description: entry.description } : {}), + })), + }; +} + +function cursorModelStateFromConfigOption( + option: CursorSessionConfigOption | undefined, +): CursorSessionModelState | undefined { + if (!option) { + return undefined; + } + return { + currentModelId: option.currentValue, + availableModels: option.options.map((entry) => ({ + modelId: entry.value, + name: entry.name, + })), + }; +} + +function mergeCursorModeStates( + primary: CursorSessionModeState | undefined, + secondary: CursorSessionModeState | undefined, +): CursorSessionModeState | undefined { + const currentModeId = primary?.currentModeId ?? secondary?.currentModeId; + const availableModes = + primary?.availableModes && primary.availableModes.length > 0 + ? primary.availableModes + : (secondary?.availableModes ?? []); + if (!currentModeId && availableModes.length === 0) { + return undefined; + } + return { + ...(currentModeId ? { currentModeId } : {}), + availableModes, + }; +} + +function mergeCursorModelStates( + primary: CursorSessionModelState | undefined, + secondary: CursorSessionModelState | undefined, +): CursorSessionModelState | undefined { + const currentModelId = primary?.currentModelId ?? secondary?.currentModelId; + const availableModels = + primary?.availableModels && primary.availableModels.length > 0 + ? primary.availableModels + : (secondary?.availableModels ?? []); + if (!currentModelId && availableModels.length === 0) { + return undefined; + } + return { + ...(currentModelId ? { currentModelId } : {}), + availableModels, + }; +} + +export function buildCursorSessionMetadata(input: { + readonly previous?: CursorSessionMetadata | undefined; + readonly initialize?: CursorInitializeState | undefined; + readonly configOptions?: ReadonlyArray | undefined; + readonly modes?: CursorSessionModeState | undefined; + readonly models?: CursorSessionModelState | undefined; + readonly availableCommands?: ReadonlyArray | undefined; + readonly currentModeId?: string | undefined; + readonly currentModelId?: string | undefined; +}): CursorSessionMetadata { + const previous = input.previous ?? EMPTY_CURSOR_SESSION_METADATA; + let configOptions = input.configOptions ?? previous.configOptions; + const requestedModeOption = findCursorConfigOption(configOptions, { + category: "mode", + id: "mode", + }); + configOptions = replaceCursorConfigOptionCurrentValue( + configOptions, + requestedModeOption?.id, + input.currentModeId, + ); + const requestedModelOption = findCursorConfigOption(configOptions, { + category: "model", + id: "model", + }); + configOptions = replaceCursorConfigOptionCurrentValue( + configOptions, + requestedModelOption?.id, + input.currentModelId, + ); + const modeConfigState = cursorModeStateFromConfigOption( + findCursorConfigOption(configOptions, { category: "mode", id: "mode" }), + ); + const modelConfigState = cursorModelStateFromConfigOption( + findCursorConfigOption(configOptions, { category: "model", id: "model" }), + ); + const explicitModes = input.modes ?? previous.modes; + const explicitModels = input.models ?? previous.models; + let modes = + input.configOptions !== undefined + ? mergeCursorModeStates(modeConfigState, explicitModes) + : mergeCursorModeStates(explicitModes, modeConfigState); + let models = + input.configOptions !== undefined + ? mergeCursorModelStates(modelConfigState, explicitModels) + : mergeCursorModelStates(explicitModels, modelConfigState); + if (input.currentModeId) { + modes = { + currentModeId: input.currentModeId, + availableModes: modes?.availableModes ?? [], + }; + } + if (input.currentModelId) { + models = { + currentModelId: input.currentModelId, + availableModels: models?.availableModels ?? [], + }; + } + const currentModeId = modes?.currentModeId; + const defaultModeId = + (input.currentModeId && input.currentModeId !== "plan" ? input.currentModeId : undefined) ?? + (currentModeId && currentModeId !== "plan" ? currentModeId : undefined) ?? + previous.defaultModeId ?? + currentModeId; + return { + initialize: input.initialize ?? previous.initialize, + configOptions, + ...(modes ? { modes } : {}), + ...(models ? { models } : {}), + availableCommands: input.availableCommands ?? previous.availableCommands, + ...(defaultModeId ? { defaultModeId } : {}), + }; +} + +export function cursorSessionMetadataSnapshot( + metadata: CursorSessionMetadata, +): Record { + return { + initialize: metadata.initialize, + configOptions: metadata.configOptions, + ...(metadata.modes ? { modes: metadata.modes } : {}), + ...(metadata.models ? { models: metadata.models } : {}), + ...(metadata.availableCommands.length > 0 + ? { availableCommands: metadata.availableCommands } + : {}), + ...(metadata.defaultModeId ? { defaultModeId: metadata.defaultModeId } : {}), + }; +} diff --git a/apps/server/src/provider/Layers/CursorAdapterToolHelpers.test.ts b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.test.ts new file mode 100644 index 00000000000..e55a87cb778 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { + buildCursorToolData, + cursorPermissionKindsForDecision, + cursorPermissionKindsForRuntimeMode, + describePermissionRequest, + extractCursorToolCommand, + extractCursorToolPath, + parseCursorPermissionOptions, + resolveCursorToolTitle, + selectCursorPermissionOption, +} from "./CursorAdapterToolHelpers.ts"; + +describe("CursorAdapterToolHelpers", () => { + it("extracts tool command and file path from raw tool payloads", () => { + const record = { + title: "`npm run lint`", + kind: "execute", + rawInput: { + command: "`npm run lint`", + path: "src/index.ts", + }, + }; + + assert.equal(extractCursorToolCommand(record), "npm run lint"); + assert.equal(extractCursorToolPath(record), "src/index.ts"); + }); + + it("builds tool data by merging prior item state with normalized input", () => { + const built = buildCursorToolData( + { + item: { + title: "Previous title", + kind: "execute", + }, + }, + { + title: "`npm run build`", + kind: "execute", + status: "completed", + toolCallId: "tool-1", + rawInput: { + command: "npm run build", + path: "apps/web", + }, + rawOutput: { + exitCode: 0, + }, + }, + ); + + assert.deepEqual(built, { + command: "npm run build", + path: "apps/web", + input: { + command: "npm run build", + path: "apps/web", + }, + result: { + exitCode: 0, + }, + item: { + title: "npm run build", + kind: "execute", + status: "completed", + toolCallId: "tool-1", + command: "npm run build", + path: "apps/web", + input: { + command: "npm run build", + path: "apps/web", + }, + result: { + exitCode: 0, + }, + }, + }); + }); + + it("describes permission requests from tool calls and nested request payloads", () => { + assert.equal( + describePermissionRequest({ + toolCall: { + title: "`git status`", + kind: "execute", + status: "pending", + }, + }), + "git status", + ); + assert.equal(describePermissionRequest({ request: { path: "src/index.ts" } }), "src/index.ts"); + }); + + it("parses and selects permission options using kinds and fallback matching", () => { + const options = parseCursorPermissionOptions([ + { optionId: "allow-once", kind: "allow_once", name: "Allow once" }, + { optionId: "allow-session", name: "Allow for session" }, + { optionId: "deny-now", name: "Reject" }, + ]); + + assert.deepEqual(cursorPermissionKindsForDecision("acceptForSession"), [ + "allow_always", + "allow_once", + ]); + assert.deepEqual(cursorPermissionKindsForRuntimeMode("approval-required"), [ + "allow_once", + "allow_always", + ]); + assert.deepEqual(selectCursorPermissionOption(options, ["allow_always"]), { + optionId: "allow-session", + name: "Allow for session", + }); + assert.deepEqual(selectCursorPermissionOption(options, ["reject_once"]), { + optionId: "deny-now", + name: "Reject", + }); + assert.equal( + selectCursorPermissionOption( + [{ optionId: "allow-once", name: "Allow once" }], + ["reject_once", "reject_always"], + ), + undefined, + ); + }); + + it("falls back to default titles when a raw title looks like a shell command", () => { + assert.equal(resolveCursorToolTitle("command_execution", "`npm test`"), "Terminal"); + assert.equal(resolveCursorToolTitle("file_change", "Edit README"), "File change"); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts new file mode 100644 index 00000000000..1a2a2090615 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts @@ -0,0 +1,533 @@ +import type { + CanonicalItemType, + CanonicalRequestType, + ProviderApprovalDecision, + ProviderSession, + RuntimeContentStreamKind, + RuntimeItemStatus, +} from "@t3tools/contracts"; + +import { + asObject, + asReadonlyArray as asArray, + asTrimmedNonEmptyString as asString, +} from "../unknown.ts"; + +import type { + CursorPermissionOption, + CursorPermissionOptionKind, +} from "./CursorAdapterSessionMetadata.ts"; + +export function extractCursorStreamText( + update: Record | undefined, +): string | undefined { + const content = asObject(update?.content); + return asStreamText(content?.text) ?? asStreamText(update?.text); +} + +function asStreamText(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function stripWrappingBackticks(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith("`") && trimmed.endsWith("`") ? trimmed.slice(1, -1).trim() : trimmed; +} + +function looksLikeShellCommand(value: string): boolean { + const normalized = stripWrappingBackticks(value); + return ( + normalized.includes(" ") || + normalized.includes("/") || + normalized.includes("&&") || + normalized.includes("||") || + normalized.includes("|") || + normalized.includes("$") || + normalized.includes("=") + ); +} + +export function defaultCursorToolTitle(itemType: CanonicalItemType): string { + switch (itemType) { + case "command_execution": + return "Terminal"; + case "file_change": + return "File change"; + case "mcp_tool_call": + return "MCP tool call"; + case "web_search": + return "Web search"; + case "image_view": + return "Image"; + case "collab_agent_tool_call": + return "Subagent task"; + default: + return "Tool call"; + } +} + +export function classifyCursorToolItemType(input: { + readonly kind?: string | undefined; + readonly title?: string | undefined; + readonly subagentType?: string | undefined; +}): CanonicalItemType { + const normalized = [input.kind, input.title, input.subagentType] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .join(" ") + .toLowerCase(); + if ( + normalized.includes("subagent") || + normalized.includes("sub-agent") || + normalized.includes("agent") || + normalized.includes("explore") || + normalized.includes("browser_use") || + normalized.includes("browser use") || + normalized.includes("computer_use") || + normalized.includes("computer use") || + normalized.includes("video_review") || + normalized.includes("video review") || + normalized.includes("vm_setup_helper") || + normalized.includes("vm setup helper") + ) { + return "collab_agent_tool_call"; + } + if ( + normalized.includes("execute") || + normalized.includes("terminal") || + normalized.includes("bash") || + normalized.includes("shell") || + normalized.includes("command") + ) { + return "command_execution"; + } + if ( + normalized.includes("edit") || + normalized.includes("write") || + normalized.includes("file") || + normalized.includes("patch") || + normalized.includes("replace") || + normalized.includes("create") || + normalized.includes("delete") + ) { + return "file_change"; + } + if (normalized.includes("mcp")) { + return "mcp_tool_call"; + } + if ( + normalized.includes("web") || + normalized.includes("search") || + normalized.includes("url") || + normalized.includes("browser") + ) { + return "web_search"; + } + if (normalized.includes("image")) { + return "image_view"; + } + return "dynamic_tool_call"; +} + +function isReadOnlyCursorTool(input: { + readonly kind?: string | undefined; + readonly title?: string | undefined; +}): boolean { + const normalized = [input.kind, input.title] + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + .join(" ") + .toLowerCase(); + return ( + normalized.includes("read") || + normalized.includes("view") || + normalized.includes("grep") || + normalized.includes("glob") || + normalized.includes("search") + ); +} + +export function requestTypeForCursorTool(input: { + readonly kind?: string | undefined; + readonly title?: string | undefined; +}): CanonicalRequestType { + if (isReadOnlyCursorTool(input)) { + return "file_read_approval"; + } + const itemType = classifyCursorToolItemType(input); + return itemType === "command_execution" + ? "command_execution_approval" + : itemType === "file_change" + ? "file_change_approval" + : "dynamic_tool_call"; +} + +export function runtimeItemStatusFromCursorStatus(status: string | undefined): RuntimeItemStatus { + switch (status?.toLowerCase()) { + case "completed": + case "success": + case "succeeded": + return "completed"; + case "failed": + case "error": + return "failed"; + case "cancelled": + case "canceled": + case "rejected": + case "declined": + return "declined"; + default: + return "inProgress"; + } +} + +export function isFinalCursorToolStatus(status: RuntimeItemStatus): boolean { + return status !== "inProgress"; +} + +export function cursorToolLookupInput(input: { + readonly kind?: string | undefined; + readonly title?: string | undefined; + readonly subagentType?: string | undefined; +}) { + return { + ...(input.kind ? { kind: input.kind } : {}), + ...(input.title ? { title: input.title } : {}), + ...(input.subagentType ? { subagentType: input.subagentType } : {}), + }; +} + +export function extractCursorToolContentText( + record: Record | undefined, +): string | undefined { + const content = asArray(record?.content); + if (!content) { + return undefined; + } + for (const entry of content) { + const contentRecord = asObject(entry); + const nested = asObject(contentRecord?.content); + const text = asString(nested?.text) ?? asString(contentRecord?.text); + if (text) { + return stripWrappingBackticks(text); + } + } + return undefined; +} + +export function extractCursorToolCommand( + record: Record | undefined, +): string | undefined { + if (!record) { + return undefined; + } + const rawInput = asObject(record.rawInput) ?? asObject(record.input); + const rawOutput = asObject(record.rawOutput) ?? asObject(record.output); + for (const candidate of [ + asString(record.command), + asString(rawInput?.command), + asString(rawInput?.cmd), + asString(rawOutput?.command), + ]) { + if (candidate) { + return stripWrappingBackticks(candidate); + } + } + const title = asString(record.title); + const kind = asString(record.kind); + if (title && ((kind && kind.toLowerCase().includes("execute")) || looksLikeShellCommand(title))) { + return stripWrappingBackticks(title); + } + return undefined; +} + +export function extractCursorToolPath( + record: Record | undefined, +): string | undefined { + if (!record) { + return undefined; + } + const rawInput = asObject(record.rawInput) ?? asObject(record.input); + const rawOutput = asObject(record.rawOutput) ?? asObject(record.output); + for (const candidate of [ + asString(record.filePath), + asString(record.path), + asString(record.relativePath), + asString(rawInput?.filePath), + asString(rawInput?.path), + asString(rawOutput?.filePath), + asString(rawOutput?.path), + ]) { + if (candidate) { + return candidate; + } + } + return undefined; +} + +export function resolveCursorToolTitle( + itemType: CanonicalItemType, + rawTitle: string | undefined, + previousTitle?: string, +): string { + const titleCandidate = rawTitle ? stripWrappingBackticks(rawTitle) : undefined; + if (titleCandidate && !looksLikeShellCommand(titleCandidate)) { + return titleCandidate; + } + return previousTitle ?? defaultCursorToolTitle(itemType); +} + +export function buildCursorToolData( + existingData: Record | undefined, + record: Record, +): Record { + const rawInput = asObject(record.rawInput) ?? asObject(record.input); + const rawOutput = asObject(record.rawOutput) ?? asObject(record.output); + const command = extractCursorToolCommand(record); + const path = extractCursorToolPath(record); + const previousItem = asObject(existingData?.item); + return { + ...existingData, + ...(command ? { command } : {}), + ...(path ? { path } : {}), + ...(rawInput ? { input: rawInput } : {}), + ...(rawOutput ? { result: rawOutput } : {}), + item: { + ...previousItem, + ...(asString(record.title) + ? { title: stripWrappingBackticks(asString(record.title) ?? "") } + : {}), + ...(asString(record.kind) ? { kind: asString(record.kind) } : {}), + ...(asString(record.status) ? { status: asString(record.status) } : {}), + ...(asString(record.toolCallId) ? { toolCallId: asString(record.toolCallId) } : {}), + ...(command ? { command } : {}), + ...(path ? { path } : {}), + ...(rawInput ? { input: rawInput } : {}), + ...(rawOutput ? { result: rawOutput } : {}), + }, + }; +} + +export function describePermissionRequest(params: unknown): string | undefined { + const record = asObject(params); + if (!record) { + return undefined; + } + + const toolCall = asObject(record.toolCall); + if (toolCall) { + const itemType = classifyCursorToolItemType( + cursorToolLookupInput({ + kind: asString(toolCall.kind), + title: asString(toolCall.title), + }), + ); + const detail = extractCursorToolCommand(toolCall) ?? extractCursorToolPath(toolCall); + if (detail) { + return detail; + } + const toolDetail = extractCursorToolContentText(toolCall); + if (toolDetail) { + return toolDetail; + } + const title = resolveCursorToolTitle(itemType, asString(toolCall.title)); + if (title.length > 0 && title !== defaultCursorToolTitle(itemType)) { + return title; + } + } + + for (const key of [ + "command", + "title", + "message", + "reason", + "toolName", + "tool", + "filePath", + "path", + ] as const) { + const value = asString(record[key]); + if (value) { + return value; + } + } + + const request = asObject(record.request); + if (!request) { + return undefined; + } + + for (const key of [ + "command", + "title", + "message", + "reason", + "toolName", + "tool", + "filePath", + "path", + ] as const) { + const value = asString(request[key]); + if (value) { + return value; + } + } + + return undefined; +} + +export function streamKindFromUpdateKind(updateKind: string): RuntimeContentStreamKind { + const normalized = updateKind.toLowerCase(); + if (normalized.includes("summary")) { + return "reasoning_summary_text"; + } + if ( + normalized.includes("reason") || + normalized.includes("thought") || + normalized.includes("thinking") + ) { + return "reasoning_text"; + } + if (normalized.includes("plan")) { + return "plan_text"; + } + return "assistant_text"; +} + +export function permissionOptionKindForRuntimeMode(runtimeMode: ProviderSession["runtimeMode"]): { + readonly primary: CursorPermissionOptionKind; + readonly fallback: CursorPermissionOptionKind; + readonly decision: ProviderApprovalDecision; +} { + if (runtimeMode === "full-access") { + return { + primary: "allow_always", + fallback: "allow_once", + decision: "acceptForSession", + }; + } + + return { + primary: "allow_once", + fallback: "allow_always", + decision: "accept", + }; +} + +export function parseCursorPermissionOptions( + value: unknown, +): ReadonlyArray { + const options = asArray(value); + if (!options) { + return []; + } + const parsed: Array = []; + for (const option of options) { + const entry = asObject(option); + if (!entry) { + continue; + } + const optionId = asString(entry.optionId); + if (!optionId) { + continue; + } + const normalized: { + optionId: string; + kind?: CursorPermissionOptionKind; + name?: string; + } = { optionId }; + const kind = asString(entry.kind)?.toLowerCase(); + if ( + kind === "allow_once" || + kind === "allow_always" || + kind === "reject_once" || + kind === "reject_always" + ) { + normalized.kind = kind; + } + const name = asString(entry.name); + if (name) { + normalized.name = name; + } + parsed.push(normalized); + } + return parsed; +} + +export function cursorPermissionKindsForDecision( + decision: ProviderApprovalDecision, +): ReadonlyArray { + switch (decision) { + case "acceptForSession": + return ["allow_always", "allow_once"]; + case "accept": + return ["allow_once", "allow_always"]; + case "decline": + case "cancel": + default: + return ["reject_once", "reject_always"]; + } +} + +export function cursorPermissionKindsForRuntimeMode( + runtimeMode: ProviderSession["runtimeMode"], +): ReadonlyArray { + return runtimeMode === "full-access" + ? ["allow_always", "allow_once"] + : ["allow_once", "allow_always"]; +} + +function permissionOptionMatchesKind( + option: CursorPermissionOption, + kind: CursorPermissionOptionKind, +): boolean { + if (option.kind === kind) { + return true; + } + const normalizedOptionId = option.optionId.toLowerCase(); + const normalizedName = option.name?.toLowerCase() ?? ""; + switch (kind) { + case "allow_once": + return ( + (normalizedOptionId.includes("allow") || normalizedName.includes("allow")) && + (normalizedOptionId.includes("once") || normalizedName.includes("once")) + ); + case "allow_always": + return ( + (normalizedOptionId.includes("allow") || normalizedName.includes("allow")) && + (normalizedOptionId.includes("always") || + normalizedOptionId.includes("session") || + normalizedName.includes("always") || + normalizedName.includes("session")) + ); + case "reject_once": + return ( + normalizedOptionId.includes("reject") || + normalizedOptionId.includes("deny") || + normalizedName.includes("reject") || + normalizedName.includes("deny") + ); + case "reject_always": + return ( + (normalizedOptionId.includes("reject") || + normalizedOptionId.includes("deny") || + normalizedName.includes("reject") || + normalizedName.includes("deny")) && + (normalizedOptionId.includes("always") || + normalizedOptionId.includes("session") || + normalizedName.includes("always") || + normalizedName.includes("session")) + ); + } +} + +export function selectCursorPermissionOption( + options: ReadonlyArray, + preferredKinds: ReadonlyArray, +): CursorPermissionOption | undefined { + for (const kind of preferredKinds) { + const matched = options.find((option) => permissionOptionMatchesKind(option, kind)); + if (matched) { + return matched; + } + } + return preferredKinds.every((kind) => kind.startsWith("allow_")) ? options[0] : undefined; +} diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts new file mode 100644 index 00000000000..d019ec7ba2d --- /dev/null +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -0,0 +1,365 @@ +import assert from "node:assert/strict"; +import { describe, it } from "@effect/vitest"; +import { Effect, Layer, Sink, Stream } from "effect"; +import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { + checkCursorProviderStatus, + parseCursorModelsOutput, + resolveCursorCliModelId, +} from "./CursorProvider.ts"; + +const encoder = new TextEncoder(); + +function mockHandle(result: { stdout: string; stderr: string; code: number }) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout)), + stderr: Stream.make(encoder.encode(result.stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function mockSpawnerLayer( + handler: (args: ReadonlyArray) => { stdout: string; stderr: string; code: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const cmd = command as unknown as { args: ReadonlyArray }; + return Effect.succeed(mockHandle(handler(cmd.args))); + }), + ); +} + +function failingSpawnerLayer(description: string) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description, + }), + ), + ), + ); +} + +describe("CursorProvider", () => { + it("parses Cursor model families and capability metadata", () => { + const parsed = parseCursorModelsOutput( + [ + "claude-4.6-opus - Claude 4.6 Opus", + "claude-4.6-opus-fast - Claude 4.6 Opus Fast", + "claude-4.6-opus-high - Claude 4.6 Opus High", + "claude-4.6-opus-high-thinking - Claude 4.6 Opus High Thinking", + "claude-4.6-opus-max-thinking - Claude 4.6 Opus Max Thinking (default)", + ].join("\n"), + ); + + assert.deepEqual( + parsed.map((model) => model.slug), + [ + "claude-4.6-opus", + "claude-4.6-opus-fast", + "claude-4.6-opus-high", + "claude-4.6-opus-high-thinking", + "claude-4.6-opus-max-thinking", + ], + ); + assert.deepEqual(parsed[0]?.capabilities, { + reasoningEffortLevels: [ + { value: "high", label: "High", isDefault: false }, + { value: "medium", label: "Medium", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }); + assert.deepEqual(parsed[2]?.cursorMetadata, { + familySlug: "claude-4.6-opus", + familyName: "Claude 4.6 Opus", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }); + assert.deepEqual(parsed[3]?.cursorMetadata, { + familySlug: "claude-4.6-opus", + familyName: "Claude 4.6 Opus", + reasoningEffort: "high", + fastMode: false, + thinking: true, + maxMode: false, + }); + assert.deepEqual(parsed[4]?.cursorMetadata, { + familySlug: "claude-4.6-opus", + familyName: "Claude 4.6 Opus", + fastMode: false, + thinking: true, + maxMode: true, + }); + }); + + it("does not synthesize a thinking toggle for explicit GPT effort variants", () => { + const parsed = parseCursorModelsOutput( + [ + "gpt-5.4-nano-none - GPT-5.4 Nano None", + "gpt-5.4-nano-low - GPT-5.4 Nano Low", + "gpt-5.4-nano-medium - GPT-5.4 Nano", + "gpt-5.4-nano-high - GPT-5.4 Nano High", + "gpt-5.4-nano-xhigh - GPT-5.4 Nano Extra High", + ].join("\n"), + ); + + assert.equal(parsed[0]?.capabilities?.supportsThinkingToggle, false); + assert.deepEqual(parsed[2]?.cursorMetadata, { + familySlug: "gpt-5.4-nano", + familyName: "GPT-5.4 Nano", + reasoningEffort: "medium", + fastMode: false, + thinking: false, + maxMode: false, + }); + }); + + it("treats Codex Max variants as a distinct Cursor family", () => { + const parsed = parseCursorModelsOutput( + [ + "gpt-5.1-codex-max-low - GPT-5.1 Codex Max Low", + "gpt-5.1-codex-max-low-fast - GPT-5.1 Codex Max Low Fast", + "gpt-5.1-codex-max-medium - GPT-5.1 Codex Max", + "gpt-5.1-codex-max-medium-fast - GPT-5.1 Codex Max Medium Fast", + "gpt-5.1-codex-max-high - GPT-5.1 Codex Max High", + "gpt-5.1-codex-max-high-fast - GPT-5.1 Codex Max High Fast", + "gpt-5.1-codex-max-xhigh - GPT-5.1 Codex Max Extra High", + "gpt-5.1-codex-max-xhigh-fast - GPT-5.1 Codex Max Extra High Fast", + ].join("\n"), + ); + + assert.deepEqual(parsed[0]?.capabilities, { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High", isDefault: false }, + { value: "high", label: "High", isDefault: false }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "low", label: "Low", isDefault: false }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }); + assert.deepEqual(parsed[2]?.cursorMetadata, { + familySlug: "gpt-5.1-codex-max", + familyName: "GPT-5.1 Codex Max", + reasoningEffort: "medium", + fastMode: false, + thinking: false, + maxMode: false, + }); + assert.deepEqual(parsed[7]?.cursorMetadata, { + familySlug: "gpt-5.1-codex-max", + familyName: "GPT-5.1 Codex Max", + reasoningEffort: "xhigh", + fastMode: true, + thinking: false, + maxMode: false, + }); + }); + + it("keeps explicit Spark Preview effort variants in provider capabilities", () => { + const parsed = parseCursorModelsOutput( + [ + "gpt-5.3-codex-spark-preview-low - GPT-5.3 Codex Spark Low", + "gpt-5.3-codex-spark-preview - GPT-5.3 Codex Spark", + "gpt-5.3-codex-spark-preview-high - GPT-5.3 Codex Spark High", + "gpt-5.3-codex-spark-preview-xhigh - GPT-5.3 Codex Spark Extra High", + ].join("\n"), + ); + + assert.deepEqual(parsed[0]?.capabilities?.reasoningEffortLevels, [ + { value: "xhigh", label: "Extra High", isDefault: false }, + { value: "high", label: "High", isDefault: false }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "low", label: "Low", isDefault: false }, + ]); + assert.equal(parsed[0]?.capabilities?.supportsThinkingToggle, false); + assert.deepEqual(parsed[1]?.cursorMetadata, { + familySlug: "gpt-5.3-codex-spark-preview", + familyName: "GPT-5.3 Codex Spark", + fastMode: false, + thinking: false, + maxMode: false, + }); + }); + + it("does not fabricate medium for high-xhigh only Cursor families", () => { + const parsed = parseCursorModelsOutput( + [ + "gpt-5.3-codex-high - GPT-5.3 Codex High", + "gpt-5.3-codex-xhigh - GPT-5.3 Codex Extra High", + ].join("\n"), + ); + + assert.deepEqual(parsed[0]?.capabilities?.reasoningEffortLevels, [ + { value: "xhigh", label: "Extra High", isDefault: false }, + { value: "high", label: "High", isDefault: true }, + ]); + assert.equal(parsed[0]?.capabilities?.supportsThinkingToggle, false); + }); + + it("keeps fast-only Compose 2 families selectable", () => { + const parsed = parseCursorModelsOutput( + ["composer-2-fast - Composer 2 Fast", "composer-2 - Composer 2"].join("\n"), + ); + + assert.deepEqual(parsed[0]?.capabilities, { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }); + assert.deepEqual(parsed[1]?.cursorMetadata, { + familySlug: "composer-2", + familyName: "Composer 2", + fastMode: false, + thinking: false, + maxMode: false, + }); + assert.equal( + resolveCursorCliModelId({ + model: "composer-2", + options: { fastMode: true }, + }), + "composer-2-fast", + ); + }); + + it("resolves Cursor CLI model ids from family slugs plus options", () => { + assert.equal(resolveCursorCliModelId({ model: "claude-4.6-opus" }), "claude-4.6-opus"); + assert.equal( + resolveCursorCliModelId({ + model: "claude-4.6-opus", + options: { reasoningEffort: "high", fastMode: true }, + }), + "claude-4.6-opus-high-fast", + ); + assert.equal( + resolveCursorCliModelId({ + model: "claude-4.6-opus-none", + options: { reasoningEffort: "medium", fastMode: true }, + }), + "claude-4.6-opus-fast", + ); + }); + + it.effect("returns ready with discovered models when Cursor Agent is installed", () => + Effect.gen(function* () { + const status = yield* checkCursorProviderStatus(); + + assert.equal(status.provider, "cursor"); + assert.equal(status.installed, true); + assert.equal(status.status, "ready"); + assert.equal(status.auth.status, "authenticated"); + assert.equal(status.auth.label, "dev@example.com"); + assert.equal(status.version, "cursor-agent 1.0.0"); + assert.equal( + status.models.some((model) => model.slug === "claude-4.6-opus-fast"), + true, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettingsService.layerTest(), + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "cursor-agent 1.0.0\n", stderr: "", code: 0 }; + } + if (joined === "models") { + return { + stdout: [ + "claude-4.6-opus - Claude 4.6 Opus", + "claude-4.6-opus-fast - Claude 4.6 Opus Fast", + ].join("\n"), + stderr: "", + code: 0, + }; + } + if (joined === "about") { + return { stdout: "User Email dev@example.com\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ), + ); + + it.effect("returns unauthenticated when Cursor Agent about output says not logged in", () => + Effect.gen(function* () { + const status = yield* checkCursorProviderStatus(); + + assert.equal(status.status, "error"); + assert.equal(status.installed, true); + assert.equal(status.auth.status, "unauthenticated"); + assert.equal( + status.message, + "Cursor Agent is not authenticated. Run `cursor-agent login` and try again.", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettingsService.layerTest(), + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") { + return { stdout: "cursor-agent 1.0.0\n", stderr: "", code: 0 }; + } + if (joined === "models") { + return { stdout: "", stderr: "", code: 0 }; + } + if (joined === "about") { + return { stdout: "User Email not logged in\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ), + ); + + it.effect("returns unavailable when Cursor Agent is missing", () => + Effect.gen(function* () { + const status = yield* checkCursorProviderStatus(); + + assert.equal(status.status, "error"); + assert.equal(status.installed, false); + assert.equal(status.auth.status, "unknown"); + assert.equal( + status.message, + "Cursor Agent (`cursor-agent`) is not installed or not on PATH.", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettingsService.layerTest(), + failingSpawnerLayer("spawn cursor-agent ENOENT"), + ), + ), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts new file mode 100644 index 00000000000..57423ecf47b --- /dev/null +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -0,0 +1,523 @@ +import type { + CodexReasoningEffort, + CursorModelMetadata, + CursorModelOptions, + CursorSettings, + ModelCapabilities, + ServerProvider, + ServerProviderModel, + ServerSettingsError, +} from "@t3tools/contracts"; +import { Cache, Duration, Effect, Equal, Layer, Result, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { + buildServerProvider, + isCommandMissingCause, + nonEmptyTrimmed, + providerModelsFromSettings, + spawnAndCollect, +} from "../providerSnapshot"; +import { makeManagedServerProvider } from "../makeManagedServerProvider"; +import { CursorProvider } from "../Services/CursorProvider"; +import { ServerSettingsService } from "../../serverSettings"; + +const PROVIDER = "cursor" as const; +const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\u001b\[[0-9;]*m`, "g"); +const EMPTY_CURSOR_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; +const CURSOR_REASONING_VARIANTS: ReadonlyArray<{ + readonly value: CodexReasoningEffort; + readonly slugSuffix: string; + readonly nameSuffixPattern: RegExp; + readonly label: string; +}> = [ + { + value: "xhigh", + slugSuffix: "-xhigh", + nameSuffixPattern: /\s+extra high$/i, + label: "Extra High", + }, + { + value: "high", + slugSuffix: "-high", + nameSuffixPattern: /\s+high$/i, + label: "High", + }, + { + value: "medium", + slugSuffix: "-medium", + nameSuffixPattern: /\s+medium$/i, + label: "Medium", + }, + { + value: "low", + slugSuffix: "-low", + nameSuffixPattern: /\s+low$/i, + label: "Low", + }, +] as const; +const CURSOR_REASONING_ORDER: ReadonlyArray = [ + "xhigh", + "high", + "medium", + "low", +]; +const FALLBACK_MODELS: ReadonlyArray = [ + { + slug: "auto", + name: "Auto", + isCustom: false, + capabilities: null, + }, + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + ...EMPTY_CURSOR_CAPABILITIES, + supportsFastMode: true, + }, + }, + { + slug: "gpt-5-mini", + name: "GPT-5 Mini", + isCustom: false, + capabilities: null, + }, + { + slug: "claude-4-sonnet", + name: "Sonnet 4", + isCustom: false, + capabilities: null, + }, + { + slug: "claude-4-sonnet-thinking", + name: "Sonnet 4 Thinking", + isCustom: false, + capabilities: null, + }, +]; + +function parseCursorVersion(result: { + readonly stdout: string; + readonly stderr: string; +}): string | null { + return ( + nonEmptyTrimmed(result.stdout.split("\n").find((line) => line.trim().length > 0)) ?? + nonEmptyTrimmed(result.stderr.split("\n").find((line) => line.trim().length > 0)) ?? + null + ); +} + +function parseCursorAuthStatus(output: string): { + readonly status: "ready" | "error" | "warning"; + readonly auth: ServerProvider["auth"]; + readonly message?: string; +} { + const normalized = output.toLowerCase(); + if (normalized.includes("user email") && normalized.includes("not logged in")) { + return { + status: "error", + auth: { status: "unauthenticated" }, + message: "Cursor Agent is not authenticated. Run `cursor-agent login` and try again.", + }; + } + + const emailMatch = output.match(/User Email\s+(.+)/i); + const email = emailMatch?.[1]?.trim(); + if (email && !/^not logged in$/i.test(email)) { + return { + status: "ready", + auth: { status: "authenticated", label: email }, + }; + } + + return { + status: "warning", + auth: { status: "unknown" }, + message: "Could not determine Cursor Agent authentication status.", + }; +} + +function stripAnsi(value: string): string { + return value.replace(ANSI_ESCAPE_PATTERN, ""); +} + +type ParsedCursorVariant = { + readonly rawModel: ServerProviderModel; + readonly familySlug: string; + readonly familyName: string; + readonly reasoningEffort: CodexReasoningEffort | null; + readonly fastMode: boolean; + readonly thinking: boolean; + readonly maxMode: boolean; +}; + +function shouldTreatCursorMaxAsFamily(familySlug: string, familyName: string): boolean { + return /-codex-max$/i.test(familySlug) && /\bcodex max\b/i.test(familyName); +} + +function parseRawCursorModelsOutput(output: string): ReadonlyArray { + const seen = new Set(); + const models: ServerProviderModel[] = []; + + for (const rawLine of stripAnsi(output).split("\n")) { + const line = rawLine.trim(); + const separatorIndex = line.indexOf(" - "); + if (separatorIndex <= 0) { + continue; + } + + const slug = nonEmptyTrimmed(line.slice(0, separatorIndex)); + const name = nonEmptyTrimmed( + line + .slice(separatorIndex + 3) + .replace(/\s+\((?:default|current)\)/gi, "") + .replace(/\s+/g, " "), + ); + if (!slug || !name || seen.has(slug)) { + continue; + } + + seen.add(slug); + models.push({ + slug, + name, + isCustom: false, + capabilities: null, + }); + } + + return models; +} + +function parseCursorVariant(model: ServerProviderModel): ParsedCursorVariant { + let familySlug = model.slug; + let familyName = model.name; + let fastMode = false; + let thinking = false; + let maxMode = false; + + if (familySlug.endsWith("-fast")) { + fastMode = true; + familySlug = familySlug.slice(0, -"-fast".length); + familyName = familyName.replace(/\s+fast$/i, "").trim(); + } + + if (familySlug.endsWith("-thinking")) { + thinking = true; + familySlug = familySlug.slice(0, -"-thinking".length); + familyName = familyName.replace(/\s+thinking$/i, "").trim(); + } + + let reasoningEffort: CodexReasoningEffort | null = null; + if (familySlug.endsWith("-none")) { + reasoningEffort = "medium"; + familySlug = familySlug.slice(0, -"-none".length); + familyName = familyName.replace(/\s+none$/i, "").trim(); + } + for (const variant of CURSOR_REASONING_VARIANTS) { + if (!familySlug.endsWith(variant.slugSuffix)) { + continue; + } + reasoningEffort = variant.value; + familySlug = familySlug.slice(0, -variant.slugSuffix.length); + familyName = familyName.replace(variant.nameSuffixPattern, "").trim(); + break; + } + + if (familySlug.endsWith("-max") && !shouldTreatCursorMaxAsFamily(familySlug, familyName)) { + maxMode = true; + familySlug = familySlug.slice(0, -"-max".length); + familyName = familyName.replace(/\s+max$/i, "").trim(); + } + + return { + rawModel: model, + familySlug, + familyName: familyName || model.name, + reasoningEffort, + fastMode, + thinking, + maxMode, + }; +} + +function buildCursorFamilyCapabilities( + variants: ReadonlyArray, +): ModelCapabilities | null { + const supportsFastMode = new Set(variants.map((variant) => variant.fastMode)).size > 1; + const supportsThinkingToggle = new Set(variants.map((variant) => variant.thinking)).size > 1; + const discoveredEffortLevels = new Set(); + const hasExplicitEffortVariants = variants.some((variant) => variant.reasoningEffort !== null); + const supportsBaseEffort = + (hasExplicitEffortVariants && variants.some((variant) => variant.reasoningEffort === null)) || + variants.some((variant) => variant.reasoningEffort === "medium"); + + for (const variant of variants) { + if (variant.reasoningEffort) { + discoveredEffortLevels.add(variant.reasoningEffort); + } + } + if (supportsBaseEffort) { + discoveredEffortLevels.add("medium"); + } + + if (!supportsFastMode && !supportsThinkingToggle && discoveredEffortLevels.size === 0) { + return null; + } + + const defaultReasoningEffort = supportsBaseEffort + ? ("medium" as const) + : (CURSOR_REASONING_ORDER.toReversed().find((value) => discoveredEffortLevels.has(value)) ?? + null); + + return { + ...EMPTY_CURSOR_CAPABILITIES, + reasoningEffortLevels: CURSOR_REASONING_ORDER.filter((value) => + discoveredEffortLevels.has(value), + ).map((value) => { + const effortLevel = { + value, + label: CURSOR_REASONING_VARIANTS.find((variant) => variant.value === value)?.label ?? value, + isDefault: false, + }; + if (value === defaultReasoningEffort) { + effortLevel.isDefault = true; + } + return effortLevel; + }), + supportsFastMode, + supportsThinkingToggle, + }; +} + +function cursorMetadataFromVariant(variant: ParsedCursorVariant): CursorModelMetadata { + return { + familySlug: variant.familySlug, + familyName: variant.familyName, + ...(variant.reasoningEffort ? { reasoningEffort: variant.reasoningEffort } : {}), + fastMode: variant.fastMode, + thinking: variant.thinking, + maxMode: variant.maxMode, + }; +} + +function buildCursorProviderModels( + models: ReadonlyArray, +): ReadonlyArray { + const variantsByFamilySlug = new Map(); + const parsedVariants = models.map((model) => { + const parsed = parseCursorVariant(model); + const group = variantsByFamilySlug.get(parsed.familySlug); + if (group) { + group.push(parsed); + } else { + variantsByFamilySlug.set(parsed.familySlug, [parsed]); + } + return parsed; + }); + + return parsedVariants.map((parsed) => { + const groupedVariants = variantsByFamilySlug.get(parsed.familySlug) ?? [parsed]; + return Object.assign({}, parsed.rawModel, { + capabilities: buildCursorFamilyCapabilities(groupedVariants), + cursorMetadata: cursorMetadataFromVariant(parsed), + }); + }); +} + +export function parseCursorModelsOutput(output: string): ReadonlyArray { + return buildCursorProviderModels(parseRawCursorModelsOutput(output)); +} + +export function resolveCursorCliModelId(input: { + readonly model: string; + readonly options?: CursorModelOptions | null | undefined; +}): string { + let slug = input.model.trim(); + if (!slug) { + return input.model; + } + + const requestedFastMode = input.options?.fastMode === true; + const requestedEffort = input.options?.reasoningEffort; + if (!requestedFastMode && !requestedEffort) { + return slug; + } + + if (slug.endsWith("-fast")) { + slug = slug.slice(0, -"-fast".length); + } + if (slug.endsWith("-none")) { + slug = slug.slice(0, -"-none".length); + } + for (const variant of CURSOR_REASONING_VARIANTS) { + if (!slug.endsWith(variant.slugSuffix)) { + continue; + } + slug = slug.slice(0, -variant.slugSuffix.length); + break; + } + + if (requestedEffort && requestedEffort !== "medium") { + slug = `${slug}${requestedEffort === "xhigh" ? "-xhigh" : `-${requestedEffort}`}`; + } + if (requestedFastMode) { + slug = `${slug}-fast`; + } + + return slug; +} + +const runCursorCommand = Effect.fn("runCursorCommand")(function* (args: ReadonlyArray) { + const settingsService = yield* ServerSettingsService; + const cursorSettings = yield* settingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.cursor), + ); + const command = ChildProcess.make(cursorSettings.binaryPath, [...args], { + shell: process.platform === "win32", + env: { + ...process.env, + NO_OPEN_BROWSER: process.env.NO_OPEN_BROWSER ?? "1", + }, + }); + return yield* spawnAndCollect(cursorSettings.binaryPath, command); +}); + +export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( + function* (): Effect.fn.Return< + ServerProvider, + ServerSettingsError, + ChildProcessSpawner.ChildProcessSpawner | ServerSettingsService + > { + const settingsService = yield* ServerSettingsService; + const cursorSettings = yield* settingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.cursor), + ); + const checkedAt = new Date().toISOString(); + const fallbackModels = providerModelsFromSettings( + FALLBACK_MODELS, + PROVIDER, + cursorSettings.customModels, + ); + + if (!cursorSettings.enabled) { + return buildServerProvider({ + provider: PROVIDER, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Cursor Agent is disabled in T3 Code settings.", + }, + }); + } + + const versionResult = yield* runCursorCommand(["--version"]).pipe(Effect.result); + if (Result.isFailure(versionResult)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(versionResult.failure), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(versionResult.failure) + ? "Cursor Agent (`cursor-agent`) is not installed or not on PATH." + : `Failed to run Cursor Agent: ${versionResult.failure instanceof Error ? versionResult.failure.message : String(versionResult.failure)}.`, + }, + }); + } + + const modelsResult = yield* runCursorCommand(["models"]).pipe(Effect.result); + const discoveredModels = Result.isSuccess(modelsResult) + ? parseCursorModelsOutput(`${modelsResult.success.stdout}\n${modelsResult.success.stderr}`) + : []; + const models = providerModelsFromSettings( + discoveredModels.length > 0 ? discoveredModels : FALLBACK_MODELS, + PROVIDER, + cursorSettings.customModels, + ); + + const aboutResult = yield* runCursorCommand(["about"]).pipe(Effect.result); + if (Result.isFailure(aboutResult)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: parseCursorVersion(versionResult.success), + status: "warning", + auth: { status: "unknown" }, + message: + aboutResult.failure instanceof Error + ? aboutResult.failure.message + : "Failed to inspect Cursor Agent status.", + }, + }); + } + + const auth = parseCursorAuthStatus( + `${aboutResult.success.stdout}\n${aboutResult.success.stderr}`, + ); + + return buildServerProvider({ + provider: PROVIDER, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: parseCursorVersion(versionResult.success), + status: auth.status, + auth: auth.auth, + ...(auth.message ? { message: auth.message } : {}), + }, + }); + }, +); + +export const CursorProviderLive = Layer.effect( + CursorProvider, + Effect.gen(function* () { + const settingsService = yield* ServerSettingsService; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const settingsCache = yield* Cache.make({ + capacity: 1, + timeToLive: Duration.minutes(1), + lookup: () => + settingsService.getSettings.pipe(Effect.map((settings) => settings.providers.cursor)), + }); + + const checkProvider = checkCursorProviderStatus().pipe( + Effect.provideService(ServerSettingsService, settingsService), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ); + + return yield* makeManagedServerProvider({ + getSettings: Cache.get(settingsCache, "settings" as const).pipe(Effect.orDie), + streamSettings: settingsService.streamChanges.pipe( + Stream.map((settings) => settings.providers.cursor), + ), + haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), + checkProvider, + refreshInterval: "60 seconds", + }); + }), +); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0fea..725907937d0 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -6,6 +6,7 @@ import { Effect, Layer, Stream } from "effect"; import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -45,6 +46,23 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; +const fakeCursorAdapter: CursorAdapterShape = { + provider: "cursor", + capabilities: { sessionModelSwitch: "restart-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -52,6 +70,7 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), + Layer.succeed(CursorAdapter, fakeCursorAdapter), ), ), NodeServices.layer, @@ -64,11 +83,13 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const claude = yield* registry.getByProvider("claudeAgent"); + const cursor = yield* registry.getByProvider("cursor"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); + assert.equal(cursor, fakeCursorAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "cursor"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index b6c987c64c3..22c0842a92e 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -17,6 +17,7 @@ import { } from "../Services/ProviderAdapterRegistry.ts"; import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(fun const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter]; + : [yield* CodexAdapter, yield* ClaudeAdapter, yield* CursorAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index ca27371b61c..4fd7c7b85e2 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -528,11 +528,24 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( if (command === "codex") { return { stdout: "codex 1.0.0\n", stderr: "", code: 0 }; } + if (command === "cursor-agent") { + return { stdout: "1.0.0\n", stderr: "", code: 0 }; + } return { stdout: "", stderr: "spawn ENOENT", code: 1 }; } if (joined === "login status") { return { stdout: "Logged in\n", stderr: "", code: 0 }; } + if (joined === "models") { + return { stdout: "", stderr: "", code: 0 }; + } + if (joined === "about") { + return { + stdout: "User Email cursor@example.com\n", + stderr: "", + code: 0, + }; + } throw new Error(`Unexpected args: ${joined}`); }), ), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index fb2f33c2932..756feb835c7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -8,17 +8,21 @@ import { Effect, Equal, Layer, PubSub, Ref, Stream } from "effect"; import { ClaudeProviderLive } from "./ClaudeProvider"; import { CodexProviderLive } from "./CodexProvider"; +import { CursorProviderLive } from "./CursorProvider"; import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; import { ClaudeProvider } from "../Services/ClaudeProvider"; import type { CodexProviderShape } from "../Services/CodexProvider"; import { CodexProvider } from "../Services/CodexProvider"; +import type { CursorProviderShape } from "../Services/CursorProvider"; +import { CursorProvider } from "../Services/CursorProvider"; import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; const loadProviders = ( codexProvider: CodexProviderShape, claudeProvider: ClaudeProviderShape, -): Effect.Effect => - Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot], { + cursorProvider: CursorProviderShape, +): Effect.Effect> => + Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot, cursorProvider.getSnapshot], { concurrency: "unbounded", }); @@ -32,19 +36,20 @@ export const ProviderRegistryLive = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; + const cursorProvider = yield* CursorProvider; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); const providersRef = yield* Ref.make>( - yield* loadProviders(codexProvider, claudeProvider), + yield* loadProviders(codexProvider, claudeProvider, cursorProvider), ); const syncProviders = Effect.fn("syncProviders")(function* (options?: { readonly publish?: boolean; }) { const previousProviders = yield* Ref.get(providersRef); - const providers = yield* loadProviders(codexProvider, claudeProvider); + const providers = yield* loadProviders(codexProvider, claudeProvider, cursorProvider); yield* Ref.set(providersRef, providers); if (options?.publish !== false && haveProvidersChanged(previousProviders, providers)) { @@ -60,6 +65,9 @@ export const ProviderRegistryLive = Layer.effect( yield* Stream.runForEach(claudeProvider.streamChanges, () => syncProviders()).pipe( Effect.forkScoped, ); + yield* Stream.runForEach(cursorProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); const refresh = Effect.fn("refresh")(function* (provider?: ProviderKind) { switch (provider) { @@ -69,10 +77,16 @@ export const ProviderRegistryLive = Layer.effect( case "claudeAgent": yield* claudeProvider.refresh; break; + case "cursor": + yield* cursorProvider.refresh; + break; default: - yield* Effect.all([codexProvider.refresh, claudeProvider.refresh], { - concurrency: "unbounded", - }); + yield* Effect.all( + [codexProvider.refresh, claudeProvider.refresh, cursorProvider.refresh], + { + concurrency: "unbounded", + }, + ); break; } return yield* syncProviders(); @@ -93,4 +107,8 @@ export const ProviderRegistryLive = Layer.effect( }, } satisfies ProviderRegistryShape; }), -).pipe(Layer.provideMerge(CodexProviderLive), Layer.provideMerge(ClaudeProviderLive)); +).pipe( + Layer.provideMerge(CodexProviderLive), + Layer.provideMerge(ClaudeProviderLive), + Layer.provideMerge(CursorProviderLive), +); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index fc3c9cf25c5..46173e46c30 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -12,6 +12,7 @@ import type { import { ApprovalRequestId, EventId, + MessageId, type ProviderKind, ProviderSessionStartInput, ThreadId, @@ -36,6 +37,7 @@ import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.t import { makeProviderServiceLive } from "./ProviderService.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; import { ProviderSessionRuntimeRepositoryLive } from "../../persistence/Layers/ProviderSessionRuntime.ts"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { @@ -44,11 +46,13 @@ import { } from "../../persistence/Layers/Sqlite.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; const defaultServerSettingsLayer = ServerSettingsService.layerTest(); const asRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.makeUnsafe(value); const asEventId = (value: string): EventId => EventId.makeUnsafe(value); +const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); @@ -232,6 +236,40 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { const sleep = (ms: number) => Effect.promise(() => new Promise((resolve) => setTimeout(resolve, ms))); +const seedProjectionConversation = ( + threadId: ThreadId, + input?: { + readonly prompt?: string; + readonly response?: string; + }, +) => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const turnId = asTurnId(`${String(threadId)}-turn-1`); + yield* repository.upsert({ + messageId: asMessageId(`${String(threadId)}-msg-user-1`), + threadId, + turnId, + role: "user", + text: input?.prompt ?? "Original prompt", + attachments: [], + isStreaming: false, + createdAt: "2026-04-06T00:00:00.000Z", + updatedAt: "2026-04-06T00:00:00.000Z", + }); + yield* repository.upsert({ + messageId: asMessageId(`${String(threadId)}-msg-assistant-1`), + threadId, + turnId, + role: "assistant", + text: input?.response ?? "Original answer", + attachments: [], + isStreaming: false, + createdAt: "2026-04-06T00:00:01.000Z", + updatedAt: "2026-04-06T00:00:01.000Z", + }); + }); + const hasMetricSnapshot = ( snapshots: ReadonlyArray, id: string, @@ -260,6 +298,9 @@ function makeProviderServiceLayer() { const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); + const projectionMessageRepositoryLayer = ProjectionThreadMessageRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const layer = it.layer( @@ -267,10 +308,12 @@ function makeProviderServiceLayer() { makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), + Layer.provide(projectionMessageRepositoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provideMerge(AnalyticsService.layerTest), ), directoryLayer, + projectionMessageRepositoryLayer, runtimeRepositoryLayer, NodeServices.layer, @@ -308,10 +351,14 @@ it.effect("ProviderServiceLive rejects new sessions for disabled providers", () const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(SqlitePersistenceMemory), ); + const projectionMessageRepositoryLayer = ProjectionThreadMessageRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); const providerLayer = makeProviderServiceLive().pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), + Layer.provide(projectionMessageRepositoryLayer), Layer.provide(serverSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -352,6 +399,9 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(persistenceLayer), ); + const projectionMessageRepositoryLayer = ProjectionThreadMessageRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); const directoryLayer = ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)); yield* Effect.gen(function* () { @@ -365,6 +415,7 @@ it.effect("ProviderServiceLive keeps persisted resumable sessions on startup", ( const providerLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), Layer.provide(directoryLayer), + Layer.provide(projectionMessageRepositoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -409,6 +460,9 @@ it.effect( const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(persistenceLayer), ); + const projectionMessageRepositoryLayer = ProjectionThreadMessageRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); const firstCodex = makeFakeCodexAdapter(); const firstRegistry: typeof ProviderAdapterRegistry.Service = { @@ -425,6 +479,7 @@ it.effect( const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), + Layer.provide(projectionMessageRepositoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -477,6 +532,7 @@ it.effect( const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), + Layer.provide(projectionMessageRepositoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -749,6 +805,90 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect( + "recovers stale cursor sessions for sendTurn using persisted resume cursor and replay turns", + () => { + const cursor = makeFakeCodexAdapter("cursor"); + const registry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "cursor" + ? Effect.succeed(cursor.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["cursor"]), + }; + const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const projectionMessageRepositoryLayer = ProjectionThreadMessageRepositoryLive.pipe( + Layer.provide(SqlitePersistenceMemory), + ); + const directoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide(providerAdapterLayer), + Layer.provide(directoryLayer), + Layer.provide(projectionMessageRepositoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + ); + const testLayer = Layer.mergeAll( + providerLayer, + projectionMessageRepositoryLayer, + directoryLayer, + runtimeRepositoryLayer, + ); + + return Effect.gen(function* () { + const provider = yield* ProviderService; + const initial = yield* provider.startSession(asThreadId("thread-cursor-send-turn"), { + provider: "cursor", + threadId: asThreadId("thread-cursor-send-turn"), + cwd: "/tmp/project-cursor-send-turn", + runtimeMode: "full-access", + }); + + yield* seedProjectionConversation(initial.threadId); + yield* cursor.stopAll(); + cursor.startSession.mockClear(); + cursor.sendTurn.mockClear(); + + yield* provider.sendTurn({ + threadId: initial.threadId, + input: "resume with cursor", + attachments: [], + }); + + assert.equal(cursor.startSession.mock.calls.length, 1); + const resumedStartInput = cursor.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + replayTurns?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "cursor"); + assert.equal(startPayload.cwd, "/tmp/project-cursor-send-turn"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.deepEqual(startPayload.replayTurns, [ + { + prompt: "Original prompt", + attachmentNames: [], + assistantResponse: "Original answer", + }, + ]); + assert.equal(startPayload.threadId, initial.threadId); + } + + assert.equal(cursor.sendTurn.mock.calls.length, 1); + }).pipe(Effect.provide(testLayer)); + }, + ); + it.effect("lists no sessions after adapter runtime clears", () => Effect.gen(function* () { const provider = yield* ProviderService; @@ -823,6 +963,9 @@ routing.layer("ProviderServiceLive routing", (it) => { const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( Layer.provide(persistenceLayer), ); + const projectionMessageRepositoryLayer = ProjectionThreadMessageRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); const firstClaude = makeFakeCodexAdapter("claudeAgent"); const firstRegistry: typeof ProviderAdapterRegistry.Service = { @@ -838,6 +981,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const firstProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), Layer.provide(firstDirectoryLayer), + Layer.provide(projectionMessageRepositoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -871,6 +1015,7 @@ routing.layer("ProviderServiceLive routing", (it) => { const secondProviderLayer = makeProviderServiceLive().pipe( Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), Layer.provide(secondDirectoryLayer), + Layer.provide(projectionMessageRepositoryLayer), Layer.provide(defaultServerSettingsLayer), Layer.provide(AnalyticsService.layerTest), ); @@ -906,6 +1051,112 @@ routing.layer("ProviderServiceLive routing", (it) => { fs.rmSync(tempDir, { recursive: true, force: true }); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect( + "reuses persisted cursor resume cursor and local replay turns when startSession is called after a restart", + () => + Effect.gen(function* () { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-service-cursor-start-")); + const dbPath = path.join(tempDir, "orchestration.sqlite"); + const persistenceLayer = makeSqlitePersistenceLive(dbPath); + const runtimeRepositoryLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + const projectionMessageRepositoryLayer = ProjectionThreadMessageRepositoryLive.pipe( + Layer.provide(persistenceLayer), + ); + + const firstCursor = makeFakeCodexAdapter("cursor"); + const firstRegistry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "cursor" + ? Effect.succeed(firstCursor.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["cursor"]), + }; + const firstDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const firstProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, firstRegistry)), + Layer.provide(firstDirectoryLayer), + Layer.provide(projectionMessageRepositoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + const initial = yield* Effect.gen(function* () { + const provider = yield* ProviderService; + return yield* provider.startSession(asThreadId("thread-cursor-start"), { + provider: "cursor", + threadId: asThreadId("thread-cursor-start"), + cwd: "/tmp/project-cursor-start", + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(firstProviderLayer)); + + yield* seedProjectionConversation(initial.threadId).pipe( + Effect.provide(projectionMessageRepositoryLayer), + ); + + const secondCursor = makeFakeCodexAdapter("cursor"); + const secondRegistry: typeof ProviderAdapterRegistry.Service = { + getByProvider: (provider) => + provider === "cursor" + ? Effect.succeed(secondCursor.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["cursor"]), + }; + const secondDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(runtimeRepositoryLayer), + ); + const secondProviderLayer = makeProviderServiceLive().pipe( + Layer.provide(Layer.succeed(ProviderAdapterRegistry, secondRegistry)), + Layer.provide(secondDirectoryLayer), + Layer.provide(projectionMessageRepositoryLayer), + Layer.provide(defaultServerSettingsLayer), + Layer.provide(AnalyticsService.layerTest), + ); + + secondCursor.startSession.mockClear(); + + yield* Effect.gen(function* () { + const provider = yield* ProviderService; + yield* provider.startSession(initial.threadId, { + provider: "cursor", + threadId: initial.threadId, + cwd: "/tmp/project-cursor-start", + runtimeMode: "full-access", + }); + }).pipe(Effect.provide(secondProviderLayer)); + + assert.equal(secondCursor.startSession.mock.calls.length, 1); + const resumedStartInput = secondCursor.startSession.mock.calls[0]?.[0]; + assert.equal(typeof resumedStartInput === "object" && resumedStartInput !== null, true); + if (resumedStartInput && typeof resumedStartInput === "object") { + const startPayload = resumedStartInput as { + provider?: string; + cwd?: string; + resumeCursor?: unknown; + replayTurns?: unknown; + threadId?: string; + }; + assert.equal(startPayload.provider, "cursor"); + assert.equal(startPayload.cwd, "/tmp/project-cursor-start"); + assert.deepEqual(startPayload.resumeCursor, initial.resumeCursor); + assert.deepEqual(startPayload.replayTurns, [ + { + prompt: "Original prompt", + attachmentNames: [], + assistantResponse: "Original answer", + }, + ]); + assert.equal(startPayload.threadId, initial.threadId); + } + + fs.rmSync(tempDir, { recursive: true, force: true }); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); const fanout = makeProviderServiceLayer(); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 85fe9fbc326..5fef7fc59a1 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -12,6 +12,7 @@ import { ModelSelection, NonNegativeInt, + type ProviderKind, ThreadId, ProviderInterruptTurnInput, ProviderRespondToRequestInput, @@ -44,6 +45,8 @@ import { import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { projectionMessagesToReplayTurns } from "../providerReplayTurns.ts"; export interface ProviderServiceLiveOptions { readonly canonicalEventLogPath?: string; @@ -55,6 +58,12 @@ const ProviderRollbackConversationInput = Schema.Struct({ numTurns: NonNegativeInt, }); +const LOCAL_TRANSCRIPT_AUTHORITATIVE_PROVIDERS = new Set(["cursor"]); + +function usesLocalTranscriptAuthority(provider: ProviderKind): boolean { + return LOCAL_TRANSCRIPT_AUTHORITATIVE_PROVIDERS.has(provider); +} + function toValidationError( operation: string, issue: string, @@ -141,6 +150,16 @@ function readPersistedCwd( return trimmed.length > 0 ? trimmed : undefined; } +function readPersistedResumeCursor( + binding: ProviderRuntimeBinding | undefined, + provider: ProviderKind, +): unknown | undefined { + if (binding?.provider !== provider) { + return undefined; + } + return binding.resumeCursor ?? undefined; +} + const makeProviderService = Effect.fn("makeProviderService")(function* ( options?: ProviderServiceLiveOptions, ) { @@ -156,8 +175,27 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory; + const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const runtimeEventPubSub = yield* PubSub.unbounded(); + const loadLocalReplayTurns = Effect.fn("loadLocalReplayTurns")(function* (input: { + readonly provider: ProviderKind; + readonly threadId: ThreadId; + readonly persistedBinding?: ProviderRuntimeBinding | undefined; + }) { + if ( + !usesLocalTranscriptAuthority(input.provider) || + input.persistedBinding?.provider !== input.provider + ) { + return [] as const; + } + return projectionMessagesToReplayTurns( + yield* projectionThreadMessageRepository.listByThreadId({ + threadId: input.threadId, + }), + ); + }); + const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Effect.succeed(event).pipe( Effect.tap((canonicalEvent) => @@ -210,6 +248,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const adapter = yield* registry.getByProvider(input.binding.provider); const hasResumeCursor = input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; + const localTranscriptAuthority = usesLocalTranscriptAuthority(input.binding.provider); const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); if (hasActiveSession) { const activeSessions = yield* adapter.listSessions(); @@ -227,7 +266,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( } } - if (!hasResumeCursor) { + if (!hasResumeCursor && !localTranscriptAuthority) { return yield* toValidationError( input.operation, `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, @@ -236,13 +275,23 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + const persistedResumeCursor = readPersistedResumeCursor( + input.binding, + input.binding.provider, + ); + const replayTurns = yield* loadLocalReplayTurns({ + provider: input.binding.provider, + threadId: input.binding.threadId, + persistedBinding: input.binding, + }); const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), - ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + ...(persistedResumeCursor !== undefined ? { resumeCursor: persistedResumeCursor } : {}), + ...(replayTurns.length > 0 ? { replayTurns } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }); if (resumed.provider !== adapter.provider) { @@ -255,7 +304,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* upsertSessionBinding(resumed, input.binding.threadId); yield* analytics.record("provider.session.recovered", { provider: resumed.provider, - strategy: "resume-thread", + strategy: + persistedResumeCursor !== undefined ? "resume-thread" : "rebuild-local-transcript", hasResumeCursor: resumed.resumeCursor !== undefined, }); return { adapter, session: resumed } as const; @@ -333,15 +383,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); - const effectiveResumeCursor = - input.resumeCursor ?? - (persistedBinding?.provider === input.provider - ? persistedBinding.resumeCursor - : undefined); + const persistedResumeCursor = readPersistedResumeCursor(persistedBinding, input.provider); + const effectiveResumeCursor = input.resumeCursor ?? persistedResumeCursor; + const replayTurns = yield* loadLocalReplayTurns({ + provider: input.provider, + threadId, + persistedBinding, + }); const adapter = yield* registry.getByProvider(input.provider); const session = yield* adapter.startSession({ ...input, ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), + ...(replayTurns.length > 0 ? { replayTurns } : {}), }); if (session.provider !== adapter.provider) { diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index d23b247f21f..176d2940d54 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -133,6 +133,39 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } })); + it("reads persisted cursor bindings", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + + const threadId = ThreadId.makeUnsafe("thread-cursor"); + + yield* directory.upsert({ + provider: "cursor", + threadId, + runtimePayload: { + model: "gpt-5.4-mini", + }, + }); + + const provider = yield* directory.getProvider(threadId); + assert.equal(provider, "cursor"); + + const binding = yield* directory.getBinding(threadId); + assertSome(binding, { + threadId, + provider: "cursor", + adapterKey: "cursor", + }); + + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.equal(runtime.value.providerName, "cursor"); + assert.equal(runtime.value.adapterKey, "cursor"); + } + })); + it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d6961..5992a4fdbfd 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,5 +1,9 @@ -import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; -import { Effect, Layer, Option } from "effect"; +import { + ProviderKind as ProviderKindSchema, + type ProviderKind, + type ThreadId, +} from "@t3tools/contracts"; +import { Effect, Layer, Option, Schema } from "effect"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; import { ProviderSessionDirectoryPersistenceError, ProviderValidationError } from "../Errors.ts"; @@ -22,7 +26,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent") { + if (Schema.is(ProviderKindSchema)(providerName)) { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts new file mode 100644 index 00000000000..8b642389556 --- /dev/null +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -0,0 +1,12 @@ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface CursorAdapterShape extends ProviderAdapterShape { + readonly provider: "cursor"; +} + +export class CursorAdapter extends ServiceMap.Service()( + "t3/provider/Services/CursorAdapter", +) {} diff --git a/apps/server/src/provider/Services/CursorProvider.ts b/apps/server/src/provider/Services/CursorProvider.ts new file mode 100644 index 00000000000..f4b8611bf8c --- /dev/null +++ b/apps/server/src/provider/Services/CursorProvider.ts @@ -0,0 +1,9 @@ +import { ServiceMap } from "effect"; + +import type { ServerProviderShape } from "./ServerProvider"; + +export interface CursorProviderShape extends ServerProviderShape {} + +export class CursorProvider extends ServiceMap.Service()( + "t3/provider/Services/CursorProvider", +) {} diff --git a/apps/server/src/provider/acpClient.test.ts b/apps/server/src/provider/acpClient.test.ts new file mode 100644 index 00000000000..aad31f98c01 --- /dev/null +++ b/apps/server/src/provider/acpClient.test.ts @@ -0,0 +1,191 @@ +import assert from "node:assert/strict"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", () => ({ + spawn: vi.fn(), +})); + +import { spawn } from "node:child_process"; + +import { startAcpClient } from "./acpClient.ts"; + +type FakeChildProcess = ChildProcessWithoutNullStreams & + EventEmitter & { + stdin: PassThrough; + stdout: PassThrough; + stderr: PassThrough; + }; + +function makeFakeChild(): FakeChildProcess { + const events = new EventEmitter() as FakeChildProcess; + const stdin = new PassThrough(); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(events, { + stdin, + stdout, + stderr, + kill: vi.fn(() => true), + }); + return child; +} + +async function flushIo() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("acpClient", () => { + const mockedSpawn = vi.mocked(spawn); + let child: ReturnType; + + beforeEach(() => { + child = makeFakeChild(); + mockedSpawn.mockReturnValue(child); + }); + + afterEach(() => { + mockedSpawn.mockReset(); + }); + + it("sends JSON-RPC requests and resolves responses", async () => { + const client = startAcpClient({ + binaryPath: "cursor-agent", + args: ["acp"], + env: { TEST_ENV: "1" }, + cwd: "/repo", + }); + + const stdinChunks: Array = []; + child.stdin.on("data", (chunk: Buffer | string) => stdinChunks.push(String(chunk))); + + const resultPromise = client.request("session/new", { cwd: "/repo" }); + await flushIo(); + + const payload = JSON.parse(stdinChunks.join("")); + assert.equal(payload.method, "session/new"); + assert.deepEqual(payload.params, { cwd: "/repo" }); + + child.stdout.write( + `${JSON.stringify({ jsonrpc: "2.0", id: payload.id, result: { sessionId: "abc" } })}\n`, + ); + + await expect(resultPromise).resolves.toEqual({ sessionId: "abc" }); + }); + + it("rejects request errors as AcpRequestError with enriched details", async () => { + const client = startAcpClient({ + binaryPath: "cursor-agent", + args: ["acp"], + }); + + const stdinChunks: Array = []; + child.stdin.on("data", (chunk: Buffer | string) => stdinChunks.push(String(chunk))); + + const resultPromise = client.request("session/load", { sessionId: "missing" }); + await flushIo(); + + const payload = JSON.parse(stdinChunks.join("")); + child.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: payload.id, + error: { + code: 404, + message: "session/load failed", + data: { details: "Session not found" }, + }, + })}\n`, + ); + + await expect(resultPromise).rejects.toEqual( + expect.objectContaining({ + name: "AcpRequestError", + method: "session/load", + code: 404, + message: "session/load failed: Session not found", + }), + ); + }); + + it("routes notifications and requests to registered handlers", async () => { + const client = startAcpClient({ + binaryPath: "cursor-agent", + args: ["acp"], + }); + + const notifications: Array = []; + const requests: Array = []; + client.setNotificationHandler((notification) => notifications.push(notification)); + client.setRequestHandler((request) => requests.push(request)); + + child.stdout.write( + `${JSON.stringify({ jsonrpc: "2.0", method: "session/update", params: { ok: true } })}\n`, + ); + child.stdout.write( + `${JSON.stringify({ jsonrpc: "2.0", id: 7, method: "session/request_permission", params: { tool: "bash" } })}\n`, + ); + await flushIo(); + + assert.deepEqual(notifications, [{ method: "session/update", params: { ok: true } }]); + assert.deepEqual(requests, [ + { id: 7, method: "session/request_permission", params: { tool: "bash" } }, + ]); + }); + + it("surfaces malformed responses and rejects pending requests on close", async () => { + const client = startAcpClient({ + binaryPath: "cursor-agent", + args: ["acp"], + }); + + const protocolErrors: Array = []; + client.setProtocolErrorHandler((error) => protocolErrors.push(error)); + const pending = client.request("session/prompt", { prompt: "hello" }, { timeoutMs: 1000 }); + await flushIo(); + + child.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, error: { code: "bad" } })}\n`); + await flushIo(); + assert.equal(protocolErrors[0]?.message, "Received malformed ACP JSON-RPC response."); + + child.emit("close", 1, null); + + await expect(pending).rejects.toBeInstanceOf(Error); + }); + + it("supports explicit respond helpers and close shutdown", async () => { + const client = startAcpClient({ + binaryPath: "cursor-agent", + args: ["acp"], + }); + + const writes: Array = []; + child.stdin.on("data", (chunk: Buffer | string) => writes.push(String(chunk))); + + client.notify("session/cancel", { sessionId: "s1" }); + client.respond(7, { ok: true }); + client.respondError(8, 400, "bad request", { detail: "nope" }); + + const closePromise = client.close(); + child.emit("close", 0, null); + await closePromise; + + const payloads = writes + .join("") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.deepEqual(payloads, [ + { jsonrpc: "2.0", method: "session/cancel", params: { sessionId: "s1" } }, + { jsonrpc: "2.0", id: 7, result: { ok: true } }, + { + jsonrpc: "2.0", + id: 8, + error: { code: 400, message: "bad request", data: { detail: "nope" } }, + }, + ]); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + }); +}); diff --git a/apps/server/src/provider/acpClient.ts b/apps/server/src/provider/acpClient.ts new file mode 100644 index 00000000000..95316d7cb0b --- /dev/null +++ b/apps/server/src/provider/acpClient.ts @@ -0,0 +1,326 @@ +import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import readline from "node:readline"; + +export type AcpJsonRpcId = string | number; + +interface JsonRpcError { + readonly code?: number; + readonly message?: string; + readonly data?: unknown; +} + +interface JsonRpcResponse { + readonly id: AcpJsonRpcId; + readonly result?: unknown; + readonly error?: JsonRpcError; +} + +interface PendingRequest { + readonly method: string; + readonly resolve: (value: unknown) => void; + readonly reject: (error: Error) => void; + readonly timeout?: ReturnType; +} + +export class AcpRequestError extends Error { + constructor( + readonly method: string, + readonly code: number | undefined, + message: string, + readonly data?: unknown, + ) { + super(enrichAcpErrorMessage(message, data)); + this.name = "AcpRequestError"; + } +} + +function enrichAcpErrorMessage(message: string, data: unknown): string { + if (data && typeof data === "object" && !Array.isArray(data)) { + const record = data as Record; + const details = + typeof record.details === "string" && record.details.length > 0 + ? record.details + : typeof record.detail === "string" && record.detail.length > 0 + ? record.detail + : typeof record.message === "string" && record.message.length > 0 + ? record.message + : undefined; + if (details && details !== message) { + return `${message}: ${details}`; + } + } + return message; +} + +export interface AcpNotification { + readonly method: string; + readonly params?: unknown; +} + +export interface AcpRequest { + readonly id: AcpJsonRpcId; + readonly method: string; + readonly params?: unknown; +} + +export interface AcpRequestOptions { + readonly timeoutMs?: number; +} + +export interface AcpClient { + readonly child: ChildProcessWithoutNullStreams; + request: (method: string, params?: unknown, options?: AcpRequestOptions) => Promise; + notify: (method: string, params?: unknown) => void; + respond: (id: AcpJsonRpcId, result: unknown) => void; + respondError: (id: AcpJsonRpcId, code: number, message: string, data?: unknown) => void; + setNotificationHandler: (handler: (notification: AcpNotification) => void) => void; + setRequestHandler: (handler: (request: AcpRequest) => void) => void; + setCloseHandler: ( + handler: (input: { + readonly code: number | null; + readonly signal: NodeJS.Signals | null; + }) => void, + ) => void; + setProtocolErrorHandler: (handler: (error: Error) => void) => void; + close: () => Promise; +} + +export interface StartAcpClientOptions { + readonly binaryPath: string; + readonly args: ReadonlyArray; + readonly env?: NodeJS.ProcessEnv; + readonly cwd?: string; +} + +function writeJsonLine(child: ChildProcessWithoutNullStreams, payload: unknown): void { + child.stdin.write(`${JSON.stringify(payload)}\n`); +} + +function isJsonRpcId(value: unknown): value is AcpJsonRpcId { + return typeof value === "string" || typeof value === "number"; +} + +function readJsonRpcError(value: unknown): JsonRpcError | undefined { + if (value === undefined) { + return undefined; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + const code = typeof record.code === "number" ? record.code : undefined; + const message = typeof record.message === "string" ? record.message : undefined; + + if (("code" in record && code === undefined) || ("message" in record && message === undefined)) { + return undefined; + } + + return { + ...(code !== undefined ? { code } : {}), + ...(message !== undefined ? { message } : {}), + ...("data" in record ? { data: record.data } : {}), + }; +} + +function parseJsonRpcResponse(message: Record): JsonRpcResponse | undefined { + if (!isJsonRpcId(message.id) || (!("result" in message) && !("error" in message))) { + return undefined; + } + + if ("error" in message) { + const error = readJsonRpcError(message.error); + if (message.error !== undefined && error === undefined) { + return undefined; + } + return { + id: message.id, + ...("result" in message ? { result: message.result } : {}), + ...(error ? { error } : {}), + }; + } + + return { + id: message.id, + ...("result" in message ? { result: message.result } : {}), + }; +} + +export function startAcpClient(options: StartAcpClientOptions): AcpClient { + const child = spawn(options.binaryPath, [...options.args], { + stdio: ["pipe", "pipe", "pipe"], + ...(options.cwd ? { cwd: options.cwd } : {}), + env: { + ...process.env, + ...options.env, + }, + }); + const output = readline.createInterface({ input: child.stdout }); + const pending = new Map(); + let nextRequestId = 1; + let notificationHandler: ((notification: AcpNotification) => void) | undefined; + let requestHandler: ((request: AcpRequest) => void) | undefined; + let closeHandler: + | ((input: { readonly code: number | null; readonly signal: NodeJS.Signals | null }) => void) + | undefined; + let protocolErrorHandler: ((error: Error) => void) | undefined; + + const clearPending = (reason: Error) => { + for (const [id, request] of pending.entries()) { + if (request.timeout) { + clearTimeout(request.timeout); + } + pending.delete(id); + request.reject(reason); + } + }; + + output.on("line", (line) => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch (error) { + protocolErrorHandler?.( + error instanceof Error + ? error + : new Error(`Failed to parse ACP JSON line: ${String(error)}`), + ); + return; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return; + } + + const message = parsed as Record; + const response = parseJsonRpcResponse(message); + if (response) { + const pendingRequest = pending.get(response.id); + if (!pendingRequest) { + return; + } + pending.delete(response.id); + if (pendingRequest.timeout) { + clearTimeout(pendingRequest.timeout); + } + if (response.error) { + pendingRequest.reject( + new AcpRequestError( + pendingRequest.method, + response.error.code, + response.error.message ?? `ACP request failed (${pendingRequest.method})`, + response.error.data, + ), + ); + return; + } + pendingRequest.resolve(response.result); + return; + } + if (isJsonRpcId(message.id) && ("result" in message || "error" in message)) { + protocolErrorHandler?.(new Error("Received malformed ACP JSON-RPC response.")); + return; + } + + if (typeof message.method !== "string") { + return; + } + + if (isJsonRpcId(message.id)) { + requestHandler?.({ + id: message.id, + method: message.method, + params: message.params, + }); + return; + } + + notificationHandler?.({ + method: message.method, + params: message.params, + }); + }); + + child.on("error", (error) => { + protocolErrorHandler?.(error); + clearPending(error); + }); + + child.on("close", (code, signal) => { + output.close(); + clearPending( + new Error(`ACP process exited (code=${code ?? "null"}, signal=${signal ?? "null"})`), + ); + closeHandler?.({ code, signal }); + }); + + return { + child, + request(method, params, requestOptions) { + const id = nextRequestId++; + return new Promise((resolve, reject) => { + const timeout = + requestOptions?.timeoutMs !== undefined + ? setTimeout(() => { + pending.delete(id); + reject( + new AcpRequestError(method, undefined, `ACP request timed out for ${method}`), + ); + }, requestOptions.timeoutMs) + : undefined; + pending.set(id, { + method, + resolve, + reject, + ...(timeout ? { timeout } : {}), + }); + writeJsonLine(child, { jsonrpc: "2.0", id, method, params }); + }); + }, + notify(method, params) { + writeJsonLine(child, { jsonrpc: "2.0", method, params }); + }, + respond(id, result) { + writeJsonLine(child, { jsonrpc: "2.0", id, result }); + }, + respondError(id, code, message, data) { + writeJsonLine(child, { + jsonrpc: "2.0", + id, + error: { + code, + message, + ...(data !== undefined ? { data } : {}), + }, + }); + }, + setNotificationHandler(handler) { + notificationHandler = handler; + }, + setRequestHandler(handler) { + requestHandler = handler; + }, + setCloseHandler(handler) { + closeHandler = handler; + }, + setProtocolErrorHandler(handler) { + protocolErrorHandler = handler; + }, + close() { + return new Promise((resolve) => { + let resolved = false; + const finish = () => { + if (resolved) { + return; + } + resolved = true; + resolve(); + }; + child.once("close", finish); + child.stdin.end(); + child.kill("SIGTERM"); + setTimeout(finish, 2_000); + }); + }, + }; +} diff --git a/apps/server/src/provider/cursorAcp.test.ts b/apps/server/src/provider/cursorAcp.test.ts new file mode 100644 index 00000000000..4b9333e0f71 --- /dev/null +++ b/apps/server/src/provider/cursorAcp.test.ts @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it, vi } from "vitest"; + +vi.mock("./acpClient.ts", () => ({ + startAcpClient: vi.fn(() => ({ client: true })), +})); + +import { startAcpClient } from "./acpClient.ts"; +import { startCursorAcpClient } from "./cursorAcp.ts"; + +describe("cursorAcp", () => { + afterEach(() => { + vi.mocked(startAcpClient).mockReset(); + }); + + it("starts ACP with the cursor subcommand and optional model", () => { + const client = startCursorAcpClient({ + binaryPath: "/opt/bin/cursor-agent", + model: "gpt-5-mini", + }); + + assert.deepEqual(client, { client: true }); + assert.deepEqual(vi.mocked(startAcpClient).mock.calls[0]?.[0], { + binaryPath: "/opt/bin/cursor-agent", + args: ["--model", "gpt-5-mini", "acp"], + env: { + NO_OPEN_BROWSER: process.env.NO_OPEN_BROWSER ?? "1", + }, + }); + }); +}); diff --git a/apps/server/src/provider/cursorAcp.ts b/apps/server/src/provider/cursorAcp.ts new file mode 100644 index 00000000000..917c4d981c5 --- /dev/null +++ b/apps/server/src/provider/cursorAcp.ts @@ -0,0 +1,29 @@ +import { + startAcpClient, + type AcpClient, + type AcpJsonRpcId, + type AcpNotification, + type AcpRequest, + type AcpRequestOptions, +} from "./acpClient.ts"; + +export type CursorAcpJsonRpcId = AcpJsonRpcId; +export type CursorAcpNotification = AcpNotification; +export type CursorAcpRequest = AcpRequest; +export type CursorAcpRequestOptions = AcpRequestOptions; +export type CursorAcpClient = AcpClient; + +export interface StartCursorAcpClientOptions { + readonly binaryPath: string; + readonly model?: string; +} + +export function startCursorAcpClient(options: StartCursorAcpClientOptions): CursorAcpClient { + return startAcpClient({ + binaryPath: options.binaryPath, + args: [...(options.model ? ["--model", options.model] : []), "acp"], + env: { + NO_OPEN_BROWSER: process.env.NO_OPEN_BROWSER ?? "1", + }, + }); +} diff --git a/apps/server/src/provider/providerReplayTurns.test.ts b/apps/server/src/provider/providerReplayTurns.test.ts new file mode 100644 index 00000000000..106954b8f04 --- /dev/null +++ b/apps/server/src/provider/providerReplayTurns.test.ts @@ -0,0 +1,61 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { projectionMessagesToReplayTurns } from "./providerReplayTurns.ts"; + +describe("projectionMessagesToReplayTurns", () => { + it("groups user prompts with subsequent assistant responses and unique attachment names", () => { + const turns = projectionMessagesToReplayTurns([ + { + role: "system", + text: "ignore me", + }, + { + role: "user", + text: "First prompt", + attachments: [{ name: "diagram.png" }, { name: "diagram.png" }, { name: "notes.md" }], + }, + { + role: "assistant", + text: "First reply", + }, + { + role: "assistant", + text: "Additional reply", + }, + { + role: "user", + text: "Second prompt", + attachments: [], + }, + ] as never); + + assert.deepEqual(turns, [ + { + prompt: "First prompt", + attachmentNames: ["diagram.png", "notes.md"], + assistantResponse: "First reply\n\nAdditional reply", + }, + { + prompt: "Second prompt", + attachmentNames: [], + }, + ]); + }); + + it("ignores assistant messages before the first user turn", () => { + const turns = projectionMessagesToReplayTurns([ + { role: "assistant", text: "orphan" }, + { role: "user", text: "Prompt", attachments: [] }, + { role: "assistant", text: "Reply" }, + ] as never); + + assert.deepEqual(turns, [ + { + prompt: "Prompt", + attachmentNames: [], + assistantResponse: "Reply", + }, + ]); + }); +}); diff --git a/apps/server/src/provider/providerReplayTurns.ts b/apps/server/src/provider/providerReplayTurns.ts new file mode 100644 index 00000000000..a5588ea0008 --- /dev/null +++ b/apps/server/src/provider/providerReplayTurns.ts @@ -0,0 +1,86 @@ +import { type ProviderReplayTurn } from "@t3tools/contracts"; + +import type { ProjectionThreadMessage } from "../persistence/Services/ProjectionThreadMessages.ts"; + +type MutableReplayTurn = { + prompt: string; + attachmentNames: Array; + assistantParts: Array; +}; + +function uniqueAttachmentNames(message: ProjectionThreadMessage): Array { + const seen = new Set(); + const names: Array = []; + for (const attachment of message.attachments ?? []) { + const normalized = attachment.name.trim(); + if (normalized.length === 0 || seen.has(normalized)) { + continue; + } + seen.add(normalized); + names.push(normalized); + } + return names; +} + +function finalizeReplayTurn( + turn: MutableReplayTurn | null, + replayTurns: Array, +): void { + if (!turn) { + return; + } + + const prompt = turn.prompt.trim(); + if (prompt.length === 0 && turn.attachmentNames.length === 0) { + return; + } + + const assistantResponse = turn.assistantParts.join("\n\n").trim(); + replayTurns.push( + assistantResponse.length > 0 + ? { + prompt, + attachmentNames: [...turn.attachmentNames], + assistantResponse, + } + : { + prompt, + attachmentNames: [...turn.attachmentNames], + }, + ); +} + +export function projectionMessagesToReplayTurns( + messages: ReadonlyArray, +): ReadonlyArray { + const replayTurns: Array = []; + let currentTurn: MutableReplayTurn | null = null; + + for (const message of messages) { + if (message.role === "system") { + continue; + } + + if (message.role === "user") { + finalizeReplayTurn(currentTurn, replayTurns); + currentTurn = { + prompt: message.text, + attachmentNames: uniqueAttachmentNames(message), + assistantParts: [], + }; + continue; + } + + if (!currentTurn) { + continue; + } + + const assistantText = message.text.trim(); + if (assistantText.length > 0) { + currentTurn.assistantParts.push(assistantText); + } + } + + finalizeReplayTurn(currentTurn, replayTurns); + return replayTurns; +} diff --git a/apps/server/src/provider/providerTranscriptBootstrap.test.ts b/apps/server/src/provider/providerTranscriptBootstrap.test.ts new file mode 100644 index 00000000000..456dcc7e300 --- /dev/null +++ b/apps/server/src/provider/providerTranscriptBootstrap.test.ts @@ -0,0 +1,95 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { + buildBootstrapPromptFromReplayTurns, + buildTranscriptBootstrapInput, + cloneReplayTurns, + transcriptMessagesFromReplayTurns, +} from "./providerTranscriptBootstrap.ts"; + +describe("providerTranscriptBootstrap", () => { + it("clones replay turns without preserving nested array references", () => { + const turns = cloneReplayTurns([ + { + prompt: "Prompt", + attachmentNames: ["a.png"], + assistantResponse: "Reply", + }, + ]); + + const attachmentNames = turns[0]?.attachmentNames as Array | undefined; + attachmentNames?.push("b.png"); + + assert.deepEqual(turns, [ + { + prompt: "Prompt", + attachmentNames: ["a.png", "b.png"], + assistantResponse: "Reply", + }, + ]); + }); + + it("converts replay turns into transcript messages", () => { + assert.deepEqual( + transcriptMessagesFromReplayTurns([ + { + prompt: "Prompt", + attachmentNames: ["a.png", "b.png"], + assistantResponse: "Reply", + }, + ]), + [ + { + role: "user", + text: "Prompt", + attachmentNames: ["a.png", "b.png"], + }, + { + role: "assistant", + text: "Reply", + }, + ], + ); + }); + + it("truncates older transcript content to fit the prompt budget", () => { + const result = buildTranscriptBootstrapInput( + [ + { role: "user", text: "First message" }, + { role: "assistant", text: "First response" }, + { + role: "user", + text: "Second message", + attachmentNames: ["a.png", "b.png", "c.png", "d.png"], + }, + { role: "assistant", text: "Second response" }, + ], + "Latest prompt", + 260, + ); + + assert.equal(result.truncated, true); + assert.equal(result.omittedCount > 0, true); + assert.match(result.text, /Latest user request \(answer this now\):\nLatest prompt/); + }); + + it("builds a bootstrap prompt directly from replay turns", () => { + const result = buildBootstrapPromptFromReplayTurns( + [ + { + prompt: "Original prompt", + attachmentNames: ["diagram.png"], + assistantResponse: "Original answer", + }, + ], + "New prompt", + 400, + ); + + assert.equal(result.includedCount, 2); + assert.match(result.text, /Transcript context:/); + assert.match(result.text, /Attached file: diagram\.png/); + assert.match(result.text, /Latest user request \(answer this now\):\nNew prompt/); + }); +}); diff --git a/apps/server/src/provider/providerTranscriptBootstrap.ts b/apps/server/src/provider/providerTranscriptBootstrap.ts new file mode 100644 index 00000000000..8609d8b9b57 --- /dev/null +++ b/apps/server/src/provider/providerTranscriptBootstrap.ts @@ -0,0 +1,192 @@ +import { type ProviderReplayTurn } from "@t3tools/contracts"; + +export interface TranscriptBootstrapMessage { + readonly role: "user" | "assistant"; + readonly text?: string; + readonly attachmentNames?: ReadonlyArray; +} + +export type TranscriptReplayTurn = ProviderReplayTurn; + +export interface TranscriptBootstrapResult { + readonly text: string; + readonly includedCount: number; + readonly omittedCount: number; + readonly truncated: boolean; +} + +export function cloneReplayTurns( + turns: ReadonlyArray | undefined, +): Array { + return (turns ?? []).map((turn) => + turn.assistantResponse !== undefined + ? { + prompt: turn.prompt, + attachmentNames: [...turn.attachmentNames], + assistantResponse: turn.assistantResponse, + } + : { + prompt: turn.prompt, + attachmentNames: [...turn.attachmentNames], + }, + ); +} + +const BOOTSTRAP_PREAMBLE = + "Continue this conversation using the transcript context below. The final section is the latest user request to answer now."; +const TRANSCRIPT_HEADER = "Transcript context:"; +const LATEST_PROMPT_HEADER = "Latest user request (answer this now):"; +const OMITTED_SUMMARY = (count: number) => + `[${count} earlier message(s) omitted to stay within input limits.]`; + +function attachmentSummary(message: TranscriptBootstrapMessage): string | null { + const attachmentNames = message.attachmentNames?.filter((name) => name.trim().length > 0) ?? []; + if (attachmentNames.length === 0) { + return null; + } + + const visibleNames = attachmentNames.slice(0, 3); + const extraCount = attachmentNames.length - visibleNames.length; + return `[Attached file${attachmentNames.length === 1 ? "" : "s"}: ${visibleNames.join(", ")}${extraCount > 0 ? ` (+${extraCount} more)` : ""}]`; +} + +function buildMessageBlock(message: TranscriptBootstrapMessage): string { + const label = message.role === "assistant" ? "ASSISTANT" : "USER"; + const text = message.text?.trim(); + const attachments = attachmentSummary(message); + + if (text && attachments) { + return `${label}:\n${text}\n${attachments}`; + } + if (text) { + return `${label}:\n${text}`; + } + if (attachments) { + return `${label}:\n${attachments}`; + } + return `${label}:\n(empty message)`; +} + +function finalizeWithPrompt( + transcriptBody: string, + latestPrompt: string, + maxChars: number, +): string | null { + const text = `${BOOTSTRAP_PREAMBLE}\n\n${TRANSCRIPT_HEADER}\n${transcriptBody}\n\n${LATEST_PROMPT_HEADER}\n${latestPrompt}`; + return text.length <= maxChars ? text : null; +} + +export function transcriptMessagesFromReplayTurns( + turns: ReadonlyArray, +): ReadonlyArray { + const messages: Array = []; + for (const turn of turns) { + messages.push({ + role: "user", + text: turn.prompt, + attachmentNames: turn.attachmentNames, + }); + const assistantResponse = turn.assistantResponse; + if (typeof assistantResponse === "string" && assistantResponse.trim().length > 0) { + messages.push({ + role: "assistant", + text: assistantResponse, + }); + } + } + return messages; +} + +export function buildTranscriptBootstrapInput( + previousMessages: ReadonlyArray, + latestPrompt: string, + maxChars: number, +): TranscriptBootstrapResult { + const budget = Number.isFinite(maxChars) ? Math.max(1, Math.floor(maxChars)) : 1; + const promptOnly = latestPrompt.length <= budget ? latestPrompt : latestPrompt.slice(0, budget); + + if (previousMessages.length === 0) { + return { + text: promptOnly, + includedCount: 0, + omittedCount: 0, + truncated: promptOnly.length !== latestPrompt.length, + }; + } + + const newestFirstBlocks: string[] = []; + for (let index = previousMessages.length - 1; index >= 0; index -= 1) { + const message = previousMessages[index]; + if (!message) { + continue; + } + newestFirstBlocks.push(buildMessageBlock(message)); + } + + if (newestFirstBlocks.length === 0) { + return { + text: promptOnly, + includedCount: 0, + omittedCount: previousMessages.length, + truncated: true, + }; + } + + let includedNewestFirst: string[] = []; + for (const block of newestFirstBlocks) { + const nextNewestFirst = [...includedNewestFirst, block]; + const nextChronological = nextNewestFirst.toReversed(); + const omittedCount = newestFirstBlocks.length - nextChronological.length; + const transcriptBody = + omittedCount > 0 + ? `${OMITTED_SUMMARY(omittedCount)}\n\n${nextChronological.join("\n\n")}` + : nextChronological.join("\n\n"); + if (!finalizeWithPrompt(transcriptBody, latestPrompt, budget)) { + break; + } + includedNewestFirst = nextNewestFirst; + } + + let includedChronological = includedNewestFirst.toReversed(); + while (true) { + const omittedCount = newestFirstBlocks.length - includedChronological.length; + const transcriptBody = + omittedCount > 0 + ? includedChronological.length > 0 + ? `${OMITTED_SUMMARY(omittedCount)}\n\n${includedChronological.join("\n\n")}` + : OMITTED_SUMMARY(omittedCount) + : includedChronological.join("\n\n"); + const finalized = finalizeWithPrompt(transcriptBody, latestPrompt, budget); + if (finalized) { + return { + text: finalized, + includedCount: includedChronological.length, + omittedCount, + truncated: omittedCount > 0 || latestPrompt.length !== promptOnly.length, + }; + } + + if (includedChronological.length === 0) { + return { + text: promptOnly, + includedCount: 0, + omittedCount: previousMessages.length, + truncated: true, + }; + } + + includedChronological = includedChronological.slice(1); + } +} + +export function buildBootstrapPromptFromReplayTurns( + turns: ReadonlyArray, + latestPrompt: string, + maxChars: number, +): TranscriptBootstrapResult { + return buildTranscriptBootstrapInput( + transcriptMessagesFromReplayTurns(turns), + latestPrompt, + maxChars, + ); +} diff --git a/apps/server/src/provider/unknown.ts b/apps/server/src/provider/unknown.ts new file mode 100644 index 00000000000..f2e58bca0f4 --- /dev/null +++ b/apps/server/src/provider/unknown.ts @@ -0,0 +1,36 @@ +export function asObject(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function asTrimmedNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function asFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export function asRoundedNonNegativeInt(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return Math.max(0, Math.round(value)); +} + +export function asReadonlyArray(value: unknown): ReadonlyArray | undefined { + return Array.isArray(value) ? value : undefined; +} + +export function asArrayOrEmpty(value: unknown): ReadonlyArray { + return asReadonlyArray(value) ?? []; +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 7a23058fc7b..c7c85192f16 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -549,7 +549,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const config = yield* buildAppUnderTest(); const attachmentPath = resolveAttachmentRelativePath({ attachmentsDir: config.attachmentsDir, - relativePath: "thread%20folder/message%20folder/file%20name.png", + relativePath: "thread folder/message folder/file name.png", }); assert.isNotNull(attachmentPath, "Attachment path should be resolvable"); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f56edde6fa3..6bda146a43b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -19,12 +19,14 @@ import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionD import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; +import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; +import { ProjectionThreadMessageRepositoryLive } from "./persistence/Layers/ProjectionThreadMessages"; import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; @@ -148,14 +150,20 @@ const ProviderLayerLive = Layer.unwrap( const claudeAdapterLayer = makeClaudeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const cursorAdapterLayer = makeCursorAdapterLive(); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), + Layer.provide(cursorAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( canonicalEventLogger ? { canonicalEventLogger } : undefined, - ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); + ).pipe( + Layer.provide(adapterRegistryLayer), + Layer.provide(providerSessionDirectoryLayer), + Layer.provide(ProjectionThreadMessageRepositoryLive), + ); }), ); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a727b89ea31..e4464d1d517 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -88,14 +88,44 @@ const COMPACT_FOOTER_VIEWPORT: ViewportSpec = { }; const TEXT_VIEWPORT_MATRIX = [ DEFAULT_VIEWPORT, - { name: "tablet", width: 720, height: 1_024, textTolerancePx: 44, attachmentTolerancePx: 56 }, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, + { + name: "tablet", + width: 720, + height: 1_024, + textTolerancePx: 44, + attachmentTolerancePx: 56, + }, + { + name: "mobile", + width: 430, + height: 932, + textTolerancePx: 56, + attachmentTolerancePx: 56, + }, + { + name: "narrow", + width: 320, + height: 700, + textTolerancePx: 84, + attachmentTolerancePx: 56, + }, ] as const satisfies readonly ViewportSpec[]; const ATTACHMENT_VIEWPORT_MATRIX = [ DEFAULT_VIEWPORT, - { name: "mobile", width: 430, height: 932, textTolerancePx: 56, attachmentTolerancePx: 56 }, - { name: "narrow", width: 320, height: 700, textTolerancePx: 84, attachmentTolerancePx: 56 }, + { + name: "mobile", + width: 430, + height: 932, + textTolerancePx: 56, + attachmentTolerancePx: 56, + }, + { + name: "narrow", + width: 320, + height: 700, + textTolerancePx: 84, + attachmentTolerancePx: 56, + }, ] as const satisfies readonly ViewportSpec[]; interface UserRowMeasurement { @@ -149,6 +179,52 @@ function createBaseServerConfig(): ServerConfig { }; } +const CURSOR_TEST_PROVIDER = { + provider: "cursor", + enabled: true, + installed: true, + version: "0.1.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: NOW_ISO, + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: null, + }, + { + slug: "claude-4.6-opus-high", + name: "Opus 4.6 High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "claude-4.6-opus-max", + name: "Opus 4.6 Max", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + reasoningEffort: undefined, + fastMode: false, + thinking: false, + maxMode: true, + }, + }, + ], +} satisfies ServerConfig["providers"][number]; + function createUserMessage(options: { id: MessageId; text: string; @@ -1013,7 +1089,11 @@ async function measureUserRow(options: { }, ); - return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; + return { + measuredRowHeightPx, + timelineWidthMeasuredPx, + renderedInVirtualizedRegion, + }; } async function mountChatView(options: { @@ -1216,7 +1296,10 @@ describe("ChatView timeline estimator parity (full app)", () => { try { const measurements: Array< - UserRowMeasurement & { viewport: ViewportSpec; estimatedHeightPx: number } + UserRowMeasurement & { + viewport: ViewportSpec; + estimatedHeightPx: number; + } > = []; for (const viewport of TEXT_VIEWPORT_MATRIX) { @@ -1977,7 +2060,11 @@ describe("ChatView timeline estimator parity (full app)", () => { type?: string; bootstrap?: { createThread?: { projectId?: string }; - prepareWorktree?: { projectCwd?: string; baseBranch?: string; branch?: string }; + prepareWorktree?: { + projectCwd?: string; + baseBranch?: string; + branch?: string; + }; runSetupScript?: boolean; }; } @@ -2677,6 +2764,98 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("clears stale cursor traits when switching to a cursor model from the picker", async () => { + setDraftThreadWithoutWorktree(); + useComposerDraftStore.setState({ + draftsByThreadId: { + [THREAD_ID]: { + prompt: "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: { + codex: { + provider: "codex", + model: "gpt-5.4", + }, + cursor: { + provider: "cursor", + model: "claude-4.6-opus-high", + options: { + reasoningEffort: "high", + }, + }, + }, + activeProvider: "codex", + runtimeMode: null, + interactionMode: null, + }, + }, + stickyModelSelectionByProvider: { + cursor: { + provider: "cursor", + model: "claude-4.6-opus-high", + options: { + reasoningEffort: "high", + }, + }, + }, + stickyActiveProvider: "codex", + }); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [...nextFixture.serverConfig.providers, CURSOR_TEST_PROVIDER], + }; + }, + }); + + try { + const providerModelPicker = await waitForElement( + findComposerProviderModelPicker, + "Unable to find provider model picker.", + ); + + providerModelPicker.click(); + await page.getByRole("menuitem", { name: "Cursor" }).hover(); + await page.getByRole("menuitemradio", { name: "GPT-5.4" }).click(); + + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]).toMatchObject({ + modelSelectionByProvider: { + codex: { + provider: "codex", + model: "gpt-5.4", + }, + cursor: { + provider: "cursor", + model: "gpt-5.4", + }, + }, + activeProvider: "cursor", + }); + expect( + useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.modelSelectionByProvider + .cursor?.options, + ).toBeUndefined(); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toEqual({ + provider: "cursor", + model: "gpt-5.4", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("creates a new thread from the global chat.new shortcut", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aeab2d083a0..dbda2147c14 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -608,6 +608,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); + const setComposerDraftProviderModelOptions = useComposerDraftStore( + (store) => store.setProviderModelOptions, + ); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, @@ -1407,6 +1410,7 @@ export default function ChatView({ threadId }: ChatViewProps) { codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], claudeAgent: providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [], }), [providerStatuses], ); @@ -3550,6 +3554,11 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: resolvedProvider, model: resolvedModel, }; + if (resolvedProvider === "cursor") { + setComposerDraftProviderModelOptions(activeThread.id, "cursor", undefined, { + persistSticky: true, + }); + } setComposerDraftModelSelection(activeThread.id, nextModelSelection); setStickyComposerModelSelection(nextModelSelection); scheduleComposerFocus(); @@ -3559,6 +3568,7 @@ export default function ChatView({ threadId }: ChatViewProps) { lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, + setComposerDraftProviderModelOptions, setStickyComposerModelSelection, providerStatuses, settings, @@ -3703,7 +3713,9 @@ export default function ChatView({ threadId }: ChatViewProps) { trigger.rangeStart, replacementRangeEnd, replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }, ); if (applied) { setComposerHighlightedItemId(null); @@ -3722,7 +3734,9 @@ export default function ChatView({ threadId }: ChatViewProps) { trigger.rangeStart, replacementRangeEnd, replacement, - { expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd) }, + { + expectedText: snapshot.value.slice(trigger.rangeStart, replacementRangeEnd), + }, ); if (applied) { setComposerHighlightedItemId(null); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 187ecf497aa..93c241036e4 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -71,6 +71,7 @@ function createBaseServerConfig(): ServerConfig { providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, + cursor: { enabled: true, binaryPath: "", customModels: [] }, }, }, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index dee42a8586c..94d631834ad 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,11 @@ +import { MessageId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { deriveTimelineEntries, type WorkLogEntry } from "../../session-logic"; +import { + computeMessageDurationStart, + deriveMessagesTimelineRows, + normalizeCompactToolLabel, +} from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -143,3 +149,57 @@ describe("normalizeCompactToolLabel", () => { expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); }); }); + +describe("deriveMessagesTimelineRows", () => { + it("groups interleaved work entries into one container row", () => { + const workEntries: WorkLogEntry[] = [ + { + id: "work-1", + createdAt: "2026-01-01T00:00:01Z", + label: "Grep", + tone: "tool", + toolTitle: "grep", + }, + { + id: "work-2", + createdAt: "2026-01-01T00:00:03Z", + label: "Find", + tone: "tool", + toolTitle: "find", + }, + ]; + + const rows = deriveMessagesTimelineRows({ + timelineEntries: deriveTimelineEntries( + [ + { + id: MessageId.makeUnsafe("user-1"), + role: "user", + text: "Help me inspect this repo", + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + { + id: MessageId.makeUnsafe("assistant-1"), + role: "assistant", + text: "Looking around", + createdAt: "2026-01-01T00:00:02Z", + streaming: true, + }, + ], + [], + workEntries, + ), + completionDividerBeforeEntryId: null, + isWorking: false, + activeTurnStartedAt: null, + }); + + expect(rows.map((row) => row.kind)).toEqual(["message", "work", "message"]); + expect(rows[1]).toMatchObject({ + kind: "work", + id: "work-1", + groupedEntries: workEntries, + }); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 16b02ea9b77..6710f1693b7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -69,6 +69,11 @@ export function deriveMessagesTimelineRows(input: { const durationStartByMessageId = computeMessageDurationStart( input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), ); + const groupedWorkEntries = input.timelineEntries.flatMap((entry) => + entry.kind === "work" ? [entry.entry] : [], + ); + const firstWorkTimelineEntry = input.timelineEntries.find((entry) => entry.kind === "work"); + let emittedGroupedWorkRow = false; for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; @@ -77,21 +82,17 @@ export function deriveMessagesTimelineRows(input: { } if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < input.timelineEntries.length) { - const nextEntry = input.timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; + if (emittedGroupedWorkRow || !firstWorkTimelineEntry || groupedWorkEntries.length === 0) { + continue; } + nextRows.push({ kind: "work", - id: timelineEntry.id, - createdAt: timelineEntry.createdAt, - groupedEntries, + id: firstWorkTimelineEntry.id, + createdAt: firstWorkTimelineEntry.createdAt, + groupedEntries: groupedWorkEntries, }); - index = cursor - 1; + emittedGroupedWorkRow = true; continue; } diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 13fe6faba25..bc2a5a8087d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -51,6 +51,70 @@ const TEST_PROVIDERS: ReadonlyArray = [ }, ], }, + { + provider: "cursor", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: new Date().toISOString(), + models: [ + { + slug: "claude-4.6-opus-high", + name: "Opus 4.6 High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "claude-4.6-opus-max", + name: "Opus 4.6 Max", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + fastMode: false, + thinking: false, + maxMode: true, + }, + }, + { + slug: "claude-4.6-opus-fast", + name: "Opus 4.6 Fast", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + fastMode: true, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.4", + familyName: "GPT-5.4", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + ], + }, { provider: "claudeAgent", enabled: true, @@ -361,6 +425,23 @@ describe("ProviderModelPicker", () => { } }); + it("dispatches an exact Cursor model slug when a family is selected", async () => { + const mounted = await mountPicker({ + provider: "cursor", + model: "gpt-5.4", + lockedProvider: "cursor", + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Opus 4.6" }).click(); + + expect(mounted.onProviderModelChange).toHaveBeenCalledWith("cursor", "claude-4.6-opus-max"); + } finally { + await mounted.cleanup(); + } + }); + it("shows disabled providers as non-selectable entries", async () => { const disabledProviders = TEST_PROVIDERS.slice(); const claudeIndex = disabledProviders.findIndex( diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 01fa37516e5..a814ff73279 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,6 +1,6 @@ import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { resolveSelectableModel } from "@t3tools/shared/model"; -import { memo, useState } from "react"; +import { memo, useMemo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; @@ -21,6 +21,12 @@ import { import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { cn } from "~/lib/utils"; import { getProviderSnapshot } from "../../providerModels"; +import { + buildCursorSelectorFamilies, + pickCursorModelFromTraits, + resolveCursorSelectorFamily, + resolveExactCursorModelSelection, +} from "../../cursorModelSelector"; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { value: ProviderKind; @@ -43,6 +49,58 @@ const COMING_SOON_PROVIDER_OPTIONS = [ { id: "gemini", label: "Gemini", icon: Gemini }, ] as const; +const CursorModelMenuContent = memo(function CursorModelMenuContent(props: { + models: ReadonlyArray[number]>; + selectedModel: string; + onModelChange: (value: string) => void; +}) { + const families = useMemo(() => buildCursorSelectorFamilies(props.models), [props.models]); + const selectedExactModel = useMemo( + () => + resolveExactCursorModelSelection({ + models: props.models, + model: props.selectedModel, + }) ?? props.selectedModel, + [props.models, props.selectedModel], + ); + const selectedFamily = + resolveCursorSelectorFamily(props.models, selectedExactModel) ?? families[0] ?? null; + + if (families.length === 0 || !selectedFamily) { + return No Cursor models available.; + } + + const applyFamilySelection = (familySlug: string) => { + const family = families.find((candidate) => candidate.familySlug === familySlug); + if (!family) { + return; + } + const nextModel = + familySlug === selectedFamily.familySlug + ? (props.models.find((model) => model.slug === selectedExactModel) ?? null) + : pickCursorModelFromTraits({ family, selections: {} }); + if (!nextModel) { + return; + } + props.onModelChange(nextModel.slug); + }; + + return ( + + applyFamilySelection(value)} + > + {families.map((family) => ( + + {family.familyName} + + ))} + + + ); +}); + function providerIconClassName( provider: ProviderKind | ProviderPickerKind, fallbackClassName: string, @@ -66,8 +124,32 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; + const cursorModels = useMemo( + () => (props.providers ? (getProviderSnapshot(props.providers, "cursor")?.models ?? []) : []), + [props.providers], + ); + const selectedCursorModel = useMemo( + () => + activeProvider === "cursor" + ? resolveExactCursorModelSelection({ + models: cursorModels, + model: props.model, + }) + : null, + [activeProvider, cursorModels, props.model], + ); + const selectedCursorFamily = useMemo( + () => + activeProvider === "cursor" && selectedCursorModel + ? resolveCursorSelectorFamily(cursorModels, selectedCursorModel) + : null, + [activeProvider, cursorModels, selectedCursorModel], + ); const selectedModelLabel = - selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; + activeProvider === "cursor" + ? (selectedCursorFamily?.familyName ?? props.model) + : (selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? + props.model); const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; const handleModelChange = (provider: ProviderKind, value: string) => { if (props.disabled) return; @@ -128,22 +210,30 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {props.lockedProvider !== null ? ( - - handleModelChange(props.lockedProvider!, value)} - > - {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} - - + props.lockedProvider === "cursor" ? ( + handleModelChange("cursor", value)} + /> + ) : ( + + handleModelChange(props.lockedProvider!, value)} + > + {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( + setIsMenuOpen(false)} + > + {modelOption.name} + + ))} + + + ) ) : ( <> {AVAILABLE_PROVIDER_OPTIONS.map((option) => { @@ -186,22 +276,30 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {option.label} - - handleModelChange(option.value, value)} - > - {props.modelOptionsByProvider[option.value].map((modelOption) => ( - setIsMenuOpen(false)} - > - {modelOption.name} - - ))} - - + {option.value === "cursor" ? ( + handleModelChange("cursor", value)} + /> + ) : ( + + handleModelChange(option.value, value)} + > + {props.modelOptionsByProvider[option.value].map((modelOption) => ( + setIsMenuOpen(false)} + > + {modelOption.name} + + ))} + + + )} ); diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 74c22e64312..0ed2748a67c 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -15,7 +15,7 @@ import { useCallback } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; -import { TraitsPicker } from "./TraitsPicker"; +import { CursorTraitsPicker, TraitsPicker } from "./TraitsPicker"; import { COMPOSER_DRAFT_STORAGE_KEY, ComposerThreadDraftState, @@ -113,6 +113,95 @@ const TEST_PROVIDERS: ReadonlyArray = [ }, ], }, + { + provider: "cursor", + enabled: true, + installed: true, + version: "0.1.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { + slug: "composer-2-fast", + name: "Composer 2 Fast", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + cursorMetadata: { + familySlug: "composer-2", + familyName: "Composer 2", + fastMode: true, + thinking: false, + maxMode: false, + }, + }, + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + cursorMetadata: { + familySlug: "composer-2", + familyName: "Composer 2", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "claude-4.5-opus-high", + name: "Opus 4.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + cursorMetadata: { + familySlug: "claude-4.5-opus", + familyName: "Opus 4.5", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "claude-4.5-opus-high-thinking", + name: "Opus 4.5 Thinking", + isCustom: false, + capabilities: { + reasoningEffortLevels: [{ value: "high", label: "High", isDefault: true }], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + cursorMetadata: { + familySlug: "claude-4.5-opus", + familyName: "Opus 4.5", + reasoningEffort: "high", + fastMode: false, + thinking: true, + maxMode: false, + }, + }, + ], + }, ]; function ClaudeTraitsPickerHarness(props: { @@ -291,8 +380,8 @@ describe("TraitsPicker (Claude)", () => { await vi.waitFor(() => { const text = document.body.textContent ?? ""; expect(text).toContain("Thinking"); - expect(text).toContain("On (default)"); - expect(text).toContain("Off"); + expect(text).toContain("Enabled (default)"); + expect(text).toContain("None"); }); }); @@ -491,3 +580,130 @@ describe("TraitsPicker (Codex)", () => { }); }); }); + +// ── Cursor TraitsPicker tests ───────────────────────────────────────── + +const CURSOR_THREAD_ID = ThreadId.makeUnsafe("thread-cursor-traits"); + +async function mountCursorPicker(props?: { model?: string }) { + const model = props?.model ?? "composer-2"; + const draftsByThreadId: Record = { + [CURSOR_THREAD_ID]: { + prompt: "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: { + cursor: { + provider: "cursor", + model, + }, + }, + activeProvider: "cursor", + runtimeMode: null, + interactionMode: null, + }, + }; + + useComposerDraftStore.setState({ + draftsByThreadId, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }); + const host = document.createElement("div"); + document.body.append(host); + const screen = await render(, { container: host }); + + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + + return { + [Symbol.asyncDispose]: cleanup, + cleanup, + }; +} + +function CursorTraitsPickerHarness(props: { model: string }) { + const { selectedModel } = useEffectiveComposerModelState({ + threadId: CURSOR_THREAD_ID, + providers: TEST_PROVIDERS, + selectedProvider: "cursor", + threadModelSelection: null, + projectModelSelection: null, + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }, + }); + + return ( + + ); +} + +describe("CursorTraitsPicker", () => { + afterEach(() => { + document.body.innerHTML = ""; + useComposerDraftStore.setState({ + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: {}, + }); + }); + + it("does not render a leading separator for fast-only Cursor families", async () => { + await using _ = await mountCursorPicker(); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("Fast Mode"); + expect(document.body.querySelectorAll('[role="separator"]').length).toBe(0); + }); + }); + + it("hides fixed effort when a Cursor family only toggles thinking", async () => { + await using _ = await mountCursorPicker({ model: "claude-4.5-opus-high" }); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Thinking None"); + expect(text).not.toContain("High · Thinking"); + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Thinking"); + expect(text).not.toContain("Effort"); + expect(document.body.querySelectorAll('[role="separator"]').length).toBe(0); + }); + }); + + it("switches Compose 2 to the fast variant when Fast Mode is enabled", async () => { + await using _ = await mountCursorPicker(); + + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "on" }).click(); + + await vi.waitFor(() => { + expect( + useComposerDraftStore.getState().draftsByThreadId[CURSOR_THREAD_ID] + ?.modelSelectionByProvider.cursor, + ).toMatchObject({ + provider: "cursor", + model: "composer-2-fast", + }); + expect(document.body.textContent ?? "").toContain("Fast On"); + }); + }); +}); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 061594ad538..25e5c2cd561 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -1,6 +1,7 @@ import { type ClaudeModelOptions, type CodexModelOptions, + type CursorModelOptions, type ProviderKind, type ProviderModelOptions, type ServerProviderModel, @@ -8,6 +9,7 @@ import { } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, + buildProviderModelSelection, isClaudeUltrathinkPrompt, trimOrNull, getDefaultEffort, @@ -15,7 +17,7 @@ import { hasContextWindowOption, resolveEffort, } from "@t3tools/shared/model"; -import { memo, useCallback, useState } from "react"; +import { Fragment, memo, type ReactElement, useCallback, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { ChevronDownIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; @@ -31,6 +33,14 @@ import { import { useComposerDraftStore } from "../../composerDraftStore"; import { getProviderModelCapabilities } from "../../providerModels"; import { cn } from "~/lib/utils"; +import { + cursorFacetValues, + pickCursorModelFromTraits, + readCursorSelectedTraits, + resolveCursorSelectorFamily, + type CursorSelectorFamily, + type CursorSelectorReasoningEffort, +} from "../../cursorModelSelector"; type ProviderOptions = ProviderModelOptions[ProviderKind]; type TraitsPersistence = @@ -43,14 +53,32 @@ type TraitsPersistence = onModelOptionsChange: (nextOptions: ProviderOptions | undefined) => void; }; +type CursorTraitsPersistence = + | { + threadId: ThreadId; + onModelChange?: never; + } + | { + threadId?: undefined; + onModelChange: (nextModelSlug: string) => void; + }; + const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n"; +const CURSOR_REASONING_LABELS: Record = { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra High", +}; function getRawEffort( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, ): string | null { - if (provider === "codex") { - return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort); + if (provider === "codex" || provider === "cursor") { + return trimOrNull( + (modelOptions as CodexModelOptions | CursorModelOptions | undefined)?.reasoningEffort, + ); } return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); } @@ -71,9 +99,21 @@ function buildNextOptions( patch: Record, ): ProviderOptions { if (provider === "codex") { - return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions; + return { + ...(modelOptions as CodexModelOptions | undefined), + ...patch, + } as CodexModelOptions; + } + if (provider === "cursor") { + return { + ...(modelOptions as CursorModelOptions | undefined), + ...patch, + } as CursorModelOptions; } - return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; + return { + ...(modelOptions as ClaudeModelOptions | undefined), + ...patch, + } as ClaudeModelOptions; } function getSelectedTraits( @@ -95,11 +135,23 @@ function getSelectedTraits( const rawEffort = getRawEffort(provider, modelOptions); const effort = resolveEffort(caps, rawEffort) ?? null; + // Thinking budget options (replaces binary toggle when available) + const thinkingBudgetOptions = caps.thinkingBudgetOptions ?? []; + // Thinking toggle (only for models that support it) const thinkingEnabled = caps.supportsThinkingToggle ? ((modelOptions as ClaudeModelOptions | undefined)?.thinking ?? true) : null; + // Thinking budget (selected budget level, or default) + const thinkingBudget = + thinkingBudgetOptions.length > 0 && thinkingEnabled !== null + ? ((modelOptions as ClaudeModelOptions | undefined)?.thinkingBudget ?? + thinkingBudgetOptions.find((o) => o.isDefault)?.value ?? + thinkingBudgetOptions[0]?.value ?? + null) + : null; + // Fast mode const fastModeEnabled = caps.supportsFastMode && @@ -129,6 +181,8 @@ function getSelectedTraits( effort, effortLevels, thinkingEnabled, + thinkingBudget, + thinkingBudgetOptions, fastModeEnabled, contextWindowOptions, contextWindow, @@ -138,6 +192,328 @@ function getSelectedTraits( }; } +function hasVisibleTraits(input: { + effortLevels: ReadonlyArray<{ value: string }>; + thinkingEnabled: boolean | null; + supportsFastMode: boolean; + contextWindowOptions: ReadonlyArray<{ value: string }>; +}): boolean { + return ( + input.effortLevels.length > 0 || + input.thinkingEnabled !== null || + input.supportsFastMode || + input.contextWindowOptions.length > 1 + ); +} + +function hasVisibleCursorTraits( + models: ReadonlyArray, + model: string | null | undefined, +): boolean { + const family = resolveCursorSelectorFamily(models, model); + if (!family) { + return false; + } + return ( + hasSelectableCursorReasoningEffort(family) || + family.supportsThinkingToggle || + family.supportsFastMode || + family.supportsMaxMode + ); +} + +function readDefaultCursorTraits( + family: CursorSelectorFamily, +): ReturnType { + const defaultModel = pickCursorModelFromTraits({ family, selections: {} }); + return readCursorSelectedTraits({ + family, + model: defaultModel?.slug, + }); +} + +function hasSelectableCursorReasoningEffort(family: CursorSelectorFamily): boolean { + return family.reasoningEffortOptions.length > 1; +} + +function buildCursorTriggerLabel(input: { + family: CursorSelectorFamily; + model: string | null | undefined; +}): string { + const selectedTraits = readCursorSelectedTraits(input); + const primaryLabel = + hasSelectableCursorReasoningEffort(input.family) && selectedTraits.reasoningEffort + ? CURSOR_REASONING_LABELS[selectedTraits.reasoningEffort] + : input.family.supportsThinkingToggle + ? `Thinking ${selectedTraits.thinking ? "On" : "None"}` + : input.family.supportsFastMode + ? `Fast ${selectedTraits.fastMode ? "On" : "Off"}` + : input.family.supportsMaxMode + ? `Max ${selectedTraits.maxMode ? "On" : "Off"}` + : "Variants"; + + return [ + primaryLabel, + selectedTraits.fastMode && !primaryLabel.startsWith("Fast") ? "Fast" : null, + selectedTraits.thinking && !primaryLabel.startsWith("Thinking") ? "Thinking" : null, + selectedTraits.maxMode && !primaryLabel.startsWith("Max") ? "Max" : null, + ] + .filter(Boolean) + .join(" · "); +} + +export function shouldRenderTraitsPicker(input: { + provider: ProviderKind; + models: ReadonlyArray; + model: string | null | undefined; + prompt: string; + modelOptions?: ProviderOptions | null | undefined; + allowPromptInjectedEffort?: boolean; +}): boolean { + if (input.provider === "cursor") { + return hasVisibleCursorTraits(input.models, input.model); + } + + const { caps, effortLevels, thinkingEnabled, contextWindowOptions } = getSelectedTraits( + input.provider, + input.models, + input.model, + input.prompt, + input.modelOptions, + input.allowPromptInjectedEffort ?? true, + ); + + return hasVisibleTraits({ + effortLevels, + thinkingEnabled, + supportsFastMode: caps.supportsFastMode, + contextWindowOptions, + }); +} + +export const CursorTraitsMenuContent = memo(function CursorTraitsMenuContent( + props: { + models: ReadonlyArray; + model: string | null | undefined; + } & CursorTraitsPersistence, +) { + const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); + const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions); + const setStickyModelSelection = useComposerDraftStore((store) => store.setStickyModelSelection); + const family = resolveCursorSelectorFamily(props.models, props.model); + const threadId = "threadId" in props ? props.threadId : undefined; + const onModelChange = "onModelChange" in props ? props.onModelChange : undefined; + + const applySelection = useCallback( + (nextModelSlug: string) => { + if (onModelChange) { + onModelChange(nextModelSlug); + return; + } + if (!threadId) { + return; + } + const modelSelection = buildProviderModelSelection("cursor", nextModelSlug); + setModelSelection(threadId, modelSelection); + setProviderModelOptions(threadId, "cursor", undefined, { + persistSticky: true, + }); + setStickyModelSelection(modelSelection); + }, + [onModelChange, threadId, setModelSelection, setProviderModelOptions, setStickyModelSelection], + ); + + if (!family) { + return null; + } + + const selectedTraits = readCursorSelectedTraits({ + family, + model: props.model, + }); + const defaultTraits = readDefaultCursorTraits(family); + + const renderBinaryFacet = ( + key: "thinking" | "fastMode" | "maxMode", + label: string, + selectedValue: boolean | undefined, + ) => { + const values = cursorFacetValues(family, key, selectedTraits); + if (values.length < 2) { + return null; + } + + const defaultValue = defaultTraits[key]; + + return ( + +
{label}
+ { + const nextModel = pickCursorModelFromTraits({ + family, + selections: { + ...selectedTraits, + [key]: value === "on", + }, + }); + if (nextModel) { + applySelection(nextModel.slug); + } + }} + > + {[ + { + value: "off", + label: key === "thinking" ? "None" : "off", + enabled: values.includes("false"), + active: false, + }, + { + value: "on", + label: "on", + enabled: values.includes("true"), + active: true, + }, + ].map((option) => ( + + {option.label} + {defaultValue === option.active ? " (default)" : ""} + + ))} + +
+ ); + }; + + const sections: Array<{ key: string; element: ReactElement }> = []; + + if (hasSelectableCursorReasoningEffort(family)) { + sections.push({ + key: "effort", + element: ( + +
Effort
+ { + const nextModel = pickCursorModelFromTraits({ + family, + selections: { + ...selectedTraits, + reasoningEffort: value as CursorSelectorReasoningEffort, + }, + }); + if (nextModel) { + applySelection(nextModel.slug); + } + }} + > + {family.reasoningEffortOptions.map((option) => ( + + {CURSOR_REASONING_LABELS[option]} + {defaultTraits.reasoningEffort === option ? " (default)" : ""} + + ))} + +
+ ), + }); + } + + for (const section of [ + { + key: "thinking", + element: renderBinaryFacet("thinking", "Thinking", selectedTraits.thinking), + }, + { + key: "fastMode", + element: renderBinaryFacet("fastMode", "Fast Mode", selectedTraits.fastMode), + }, + { + key: "maxMode", + element: renderBinaryFacet("maxMode", "Max Mode", selectedTraits.maxMode), + }, + ]) { + if (section.element) { + sections.push({ key: section.key, element: section.element }); + } + } + + return ( + <> + {sections.map((section, index) => ( + + {index > 0 ? : null} + {section.element} + + ))} + + ); +}); + +export const CursorTraitsPicker = memo(function CursorTraitsPicker( + props: { + models: ReadonlyArray; + model: string | null | undefined; + triggerVariant?: VariantProps["variant"]; + triggerClassName?: string; + } & CursorTraitsPersistence, +) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const family = resolveCursorSelectorFamily(props.models, props.model); + + if (!family || !hasVisibleCursorTraits(props.models, props.model)) { + return null; + } + + const triggerLabel = buildCursorTriggerLabel({ + family, + model: props.model, + }); + + return ( + { + setIsMenuOpen(open); + }} + > + + } + > + + {triggerLabel} + + + + + + + ); +}); + export interface TraitsMenuContentProps { provider: ProviderKind; models: ReadonlyArray; @@ -167,7 +543,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ persistence.onModelOptionsChange(nextOptions); return; } - setProviderModelOptions(persistence.threadId, provider, nextOptions, { persistSticky: true }); + setProviderModelOptions(persistence.threadId, provider, nextOptions, { + persistSticky: true, + }); }, [persistence, provider, setProviderModelOptions], ); @@ -176,6 +554,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ effort, effortLevels, thinkingEnabled, + thinkingBudget, + thinkingBudgetOptions, fastModeEnabled, contextWindowOptions, contextWindow, @@ -203,9 +583,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); onPromptChange(stripped); } - const effortKey = provider === "codex" ? "reasoningEffort" : "effort"; + const effortKey = provider === "claudeAgent" ? "effort" : "reasoningEffort"; updateModelOptions( - buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), + buildNextOptions(provider, modelOptions, { + [effortKey]: nextOption.value, + }), ); }, [ @@ -221,7 +603,14 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ], ); - if (effort === null && thinkingEnabled === null && contextWindowOptions.length <= 1) { + if ( + !hasVisibleTraits({ + effortLevels, + thinkingEnabled, + supportsFastMode: caps.supportsFastMode, + contextWindowOptions, + }) + ) { return null; } @@ -253,19 +642,53 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ + ) : thinkingEnabled !== null && thinkingBudgetOptions.length > 0 ? ( + +
Thinking
+ { + if (value === "none") { + updateModelOptions( + buildNextOptions(provider, modelOptions, { + thinking: false, + thinkingBudget: undefined, + }), + ); + } else { + updateModelOptions( + buildNextOptions(provider, modelOptions, { + thinking: true, + thinkingBudget: value, + }), + ); + } + }} + > + None + {thinkingBudgetOptions.map((option) => ( + + {option.label} + {option.isDefault ? " (default)" : ""} + + ))} + +
) : thinkingEnabled !== null ? (
Thinking
{ updateModelOptions( - buildNextOptions(provider, modelOptions, { thinking: value === "on" }), + buildNextOptions(provider, modelOptions, { + thinking: value === "on", + }), ); }} > - On (default) - Off + None + Enabled (default)
) : null} @@ -278,7 +701,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ value={fastModeEnabled ? "on" : "off"} onValueChange={(value) => { updateModelOptions( - buildNextOptions(provider, modelOptions, { fastMode: value === "on" }), + buildNextOptions(provider, modelOptions, { + fastMode: value === "on", + }), ); }} > @@ -337,6 +762,8 @@ export const TraitsPicker = memo(function TraitsPicker({ effort, effortLevels, thinkingEnabled, + thinkingBudget, + thinkingBudgetOptions, fastModeEnabled, contextWindowOptions, contextWindow, @@ -347,25 +774,40 @@ export const TraitsPicker = memo(function TraitsPicker({ const effortLabel = effort ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) : null; + const thinkingLabel = + thinkingEnabled === null + ? null + : thinkingBudgetOptions.length > 0 + ? thinkingEnabled + ? (thinkingBudgetOptions.find((o) => o.value === thinkingBudget)?.label ?? "Thinking On") + : "Thinking None" + : thinkingEnabled + ? "Thinking On" + : "Thinking None"; const contextWindowLabel = contextWindowOptions.length > 1 && contextWindow !== defaultContextWindow ? (contextWindowOptions.find((o) => o.value === contextWindow)?.label ?? null) : null; const triggerLabel = [ - ultrathinkPromptControlled - ? "Ultrathink" - : effortLabel - ? effortLabel - : thinkingEnabled === null - ? null - : `Thinking ${thinkingEnabled ? "On" : "Off"}`, + ultrathinkPromptControlled ? "Ultrathink" : effortLabel ? effortLabel : thinkingLabel, ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), ...(contextWindowLabel ? [contextWindowLabel] : []), ] .filter(Boolean) .join(" · "); - const isCodexStyle = provider === "codex"; + const isCodexStyle = provider === "codex" || provider === "cursor"; + + if ( + !hasVisibleTraits({ + effortLevels, + thinkingEnabled, + supportsFastMode: caps.supportsFastMode, + contextWindowOptions, + }) + ) { + return null; + } return ( = [ { @@ -106,6 +106,36 @@ const CLAUDE_MODELS_WITH_CONTEXT_WINDOW: ReadonlyArray = [ }, ]; +const CURSOR_MODELS: ReadonlyArray = [ + { + slug: "claude-4.6-opus-high", + name: "Opus 4.6 High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "claude-4.6-opus-fast", + name: "Opus 4.6 Fast", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + fastMode: true, + thinking: false, + maxMode: false, + }, + }, +]; + describe("getComposerProviderState", () => { it("returns codex defaults when no codex draft options exist", () => { const state = getComposerProviderState({ @@ -416,4 +446,54 @@ describe("getComposerProviderState", () => { expect(state.modelOptionsForDispatch).not.toHaveProperty("fastMode"); }); + + it("clears Cursor dispatch options so selection is driven by the exact model slug", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "claude-4.6-opus-fast", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { + reasoningEffort: "high", + fastMode: true, + }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: null, + modelOptionsForDispatch: undefined, + }); + }); +}); + +describe("renderProviderTraitsPicker", () => { + it("returns null when the selected provider model exposes no visible traits", () => { + const result = renderProviderTraitsPicker({ + provider: "codex", + threadId: "thread-1" as never, + model: "gpt-5.4-lite", + models: [ + { + slug: "gpt-5.4-lite", + name: "GPT-5.4 Lite", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + modelOptions: undefined, + prompt: "", + onPromptChange: () => {}, + }); + + expect(result).toBeNull(); + }); }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 3307442db27..6c3caea388b 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -7,10 +7,17 @@ import { import { isClaudeUltrathinkPrompt, resolveEffort } from "@t3tools/shared/model"; import type { ReactNode } from "react"; import { getProviderModelCapabilities } from "../../providerModels"; -import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; +import { + CursorTraitsMenuContent, + CursorTraitsPicker, + shouldRenderTraitsPicker, + TraitsMenuContent, + TraitsPicker, +} from "./TraitsPicker"; import { normalizeClaudeModelOptionsWithCapabilities, normalizeCodexModelOptionsWithCapabilities, + normalizeCursorModelOptionsWithCapabilities, } from "@t3tools/shared/model"; export type ComposerProviderStateInput = { @@ -72,7 +79,9 @@ function getProviderStateFromCapabilities( const normalizedOptions = provider === "codex" ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) - : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); + : provider === "cursor" + ? normalizeCursorModelOptionsWithCapabilities(caps, providerOptions) + : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); // Ultrathink styling (driven by capabilities data, not provider identity) const ultrathinkActive = @@ -84,7 +93,9 @@ function getProviderStateFromCapabilities( modelOptionsForDispatch: normalizedOptions, ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), ...(ultrathinkActive - ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } + ? { + composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", + } : {}), ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), }; @@ -155,6 +166,19 @@ const composerProviderRegistry: Record = { /> ), }, + cursor: { + getState: (input) => ({ + ...getProviderStateFromCapabilities(input), + promptEffort: null, + modelOptionsForDispatch: undefined, + }), + renderTraitsMenuContent: ({ threadId, model, models }) => ( + + ), + renderTraitsPicker: ({ threadId, model, models }) => ( + + ), + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { @@ -189,6 +213,18 @@ export function renderProviderTraitsPicker(input: { prompt: string; onPromptChange: (prompt: string) => void; }): ReactNode { + if ( + !shouldRenderTraitsPicker({ + provider: input.provider, + models: input.models, + model: input.model, + modelOptions: input.modelOptions, + prompt: input.prompt, + }) + ) { + return null; + } + return composerProviderRegistry[input.provider].renderTraitsPicker({ threadId: input.threadId, model: input.model, diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 090f6f12ad1..1e001a8f545 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -1,6 +1,11 @@ import "../../index.css"; -import { DEFAULT_SERVER_SETTINGS, type NativeApi, type ServerConfig } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + type NativeApi, + type ServerConfig, + type ServerProvider, +} from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -29,6 +34,87 @@ function createBaseServerConfig(): ServerConfig { }; } +function createCursorProvider(): ServerProvider { + return { + provider: "cursor", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { + slug: "gpt-5.2-codex", + name: "GPT-5.2 Codex", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.2-codex", + familyName: "GPT-5.2 Codex", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.2-codex-high", + name: "GPT-5.2 Codex High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.2-codex", + familyName: "GPT-5.2 Codex", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "composer-2", + familyName: "Composer 2", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "composer-2-fast", + name: "Composer 2 Fast", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "composer-2", + familyName: "Composer 2", + fastMode: true, + thinking: false, + maxMode: false, + }, + }, + ], + }; +} + +function createCursorTextGenerationConfig(model: string): ServerConfig { + return { + ...createBaseServerConfig(), + providers: [createCursorProvider()], + settings: { + ...DEFAULT_SERVER_SETTINGS, + textGenerationModelSelection: { + provider: "cursor", + model, + }, + }, + }; +} + describe("GeneralSettingsPanel observability", () => { beforeEach(() => { resetServerStateForTests(); @@ -88,4 +174,80 @@ describe("GeneralSettingsPanel observability", () => { expect(openInEditor).toHaveBeenCalledWith("/repo/project/.t3/logs", "cursor"); }); + + it("updates Cursor text generation reasoning selections", async () => { + const updateSettings = vi + .fn() + .mockResolvedValue(DEFAULT_SERVER_SETTINGS); + window.nativeApi = { + server: { + updateSettings, + }, + } as unknown as NativeApi; + + setServerConfigSnapshot(createCursorTextGenerationConfig("gpt-5.2-codex")); + + const screen = await render( + + + , + ); + + const traitsTrigger = page.getByRole("button", { name: "Medium" }); + await expect.element(traitsTrigger).toBeInTheDocument(); + + await traitsTrigger.click(); + await page.getByRole("menuitemradio", { name: "High" }).click(); + + await vi.waitFor(() => { + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + textGenerationModelSelection: { + provider: "cursor", + model: "gpt-5.2-codex-high", + }, + }), + ); + }); + await expect.element(page.getByRole("button", { name: "High" })).toBeInTheDocument(); + await screen.unmount(); + }); + + it("updates Cursor text generation fast-mode selections", async () => { + const updateSettings = vi + .fn() + .mockResolvedValue(DEFAULT_SERVER_SETTINGS); + window.nativeApi = { + server: { + updateSettings, + }, + } as unknown as NativeApi; + + setServerConfigSnapshot(createCursorTextGenerationConfig("composer-2")); + + const screen = await render( + + + , + ); + + const traitsTrigger = page.getByRole("button", { name: "Fast Off" }); + await expect.element(traitsTrigger).toBeInTheDocument(); + + await traitsTrigger.click(); + await page.getByRole("menuitemradio", { name: "on" }).click(); + + await vi.waitFor(() => { + expect(updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + textGenerationModelSelection: { + provider: "cursor", + model: "composer-2-fast", + }, + }), + ); + }); + await expect.element(page.getByRole("button", { name: "Fast On" })).toBeInTheDocument(); + await screen.unmount(); + }); }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d534eefaa47..49a5788794c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -30,7 +30,7 @@ import { resolveDesktopUpdateButtonAction, } from "../../components/desktopUpdate.logic"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; -import { TraitsPicker } from "../chat/TraitsPicker"; +import { CursorTraitsPicker, TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; import { isElectron } from "../../env"; import { useTheme } from "../../hooks/useTheme"; @@ -112,6 +112,12 @@ const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ binaryPlaceholder: "Claude binary path", binaryDescription: "Path to the Claude binary", }, + { + provider: "cursor", + title: "Cursor", + binaryPlaceholder: "Cursor Agent binary path", + binaryDescription: "Path to the Cursor Agent binary", + }, ] as const; const PROVIDER_STATUS_STYLES = { @@ -537,12 +543,18 @@ export function GeneralSettingsPanel() { DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || settings.providers.claudeAgent.customModels.length > 0, ), + cursor: Boolean( + settings.providers.cursor.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.cursor.binaryPath || + settings.providers.cursor.customModels.length > 0, + ), }); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ codex: "", claudeAgent: "", + cursor: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> @@ -587,6 +599,8 @@ export function GeneralSettingsPanel() { const textGenProvider = textGenerationModelSelection.provider; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; + const textGenModels = + serverProviders.find((provider) => provider.provider === textGenProvider)?.models ?? []; const gitModelOptionsByProvider = getCustomModelOptionsByProvider( settings, serverProviders, @@ -1036,35 +1050,55 @@ export function GeneralSettingsPanel() { }); }} /> - provider.provider === textGenProvider) - ?.models ?? [] - } - model={textGenModel} - prompt="" - onPromptChange={() => {}} - modelOptions={textGenModelOptions} - allowPromptInjectedEffort={false} - triggerVariant="outline" - triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" - onModelOptionsChange={(nextOptions) => { - updateSettings({ - textGenerationModelSelection: resolveAppModelSelectionState( - { - ...settings, - textGenerationModelSelection: { - provider: textGenProvider, - model: textGenModel, - ...(nextOptions ? { options: nextOptions } : {}), + {textGenProvider === "cursor" ? ( + { + updateSettings({ + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: { + provider: "cursor", + model: nextModel, + }, }, - }, - serverProviders, - ), - }); - }} - /> + serverProviders, + ), + }); + }} + /> + ) : ( + {}} + modelOptions={textGenModelOptions} + allowPromptInjectedEffort={false} + triggerVariant="outline" + triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" + onModelOptionsChange={(nextOptions) => { + updateSettings({ + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: { + provider: textGenProvider, + model: textGenModel, + ...(nextOptions ? { options: nextOptions } : {}), + }, + }, + serverProviders, + ), + }); + }} + /> + )} } /> @@ -1385,7 +1419,9 @@ export function GeneralSettingsPanel() { placeholder={ providerCard.provider === "codex" ? "gpt-6.7-codex-ultra-preview" - : "claude-sonnet-5-0" + : providerCard.provider === "claudeAgent" + ? "claude-sonnet-5-0" + : "claude-4-sonnet" } spellCheck={false} /> diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 797e27a6ed9..47561bb9078 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1,10 +1,13 @@ import * as Schema from "effect/Schema"; import { + DEFAULT_SERVER_SETTINGS, ProjectId, ThreadId, type ModelSelection, type ProviderModelOptions, + type ServerProvider, } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -12,6 +15,7 @@ import { clearPromotedDraftThread, clearPromotedDraftThreads, type ComposerImageAttachment, + deriveEffectiveComposerModelState, useComposerDraftStore, } from "./composerDraftStore"; import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; @@ -80,7 +84,7 @@ function resetComposerDraftStore() { } function modelSelection( - provider: "codex" | "claudeAgent", + provider: "codex" | "claudeAgent" | "cursor", model: string, options?: ModelSelection["options"], ): ModelSelection { @@ -95,6 +99,45 @@ function providerModelOptions(options: ProviderModelOptions): ProviderModelOptio return options; } +const CURSOR_PROVIDER: ServerProvider = { + provider: "cursor", + enabled: true, + installed: true, + version: "0.1.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [ + { + slug: "claude-4.6-opus-high", + name: "Opus 4.6 High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "claude-4.6-opus-fast", + name: "Opus 4.6 Fast", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + fastMode: true, + thinking: false, + maxMode: false, + }, + }, + ], +}; + describe("composerDraftStore addImages", () => { const threadId = ThreadId.makeUnsafe("thread-dedupe"); let originalRevokeObjectUrl: typeof URL.revokeObjectURL; @@ -445,6 +488,98 @@ describe("composerDraftStore terminal contexts", () => { }); }); +describe("composerDraftStore persisted cursor state", () => { + const threadId = ThreadId.makeUnsafe("thread-persisted-cursor"); + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("rehydrates persisted cursor providers without dropping the active selection", () => { + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const mergedState = persistApi.getOptions().merge( + { + draftsByThreadId: { + [threadId]: { + prompt: "", + attachments: [], + modelSelectionByProvider: { + cursor: modelSelection("cursor", "claude-4.6-opus-high", { + reasoningEffort: "high", + }), + }, + activeProvider: "cursor", + }, + }, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModelSelectionByProvider: { + cursor: modelSelection("cursor", "claude-4.6-opus-high", { + reasoningEffort: "high", + }), + }, + stickyActiveProvider: "cursor", + }, + useComposerDraftStore.getInitialState(), + ); + + expect(mergedState.draftsByThreadId[threadId]?.modelSelectionByProvider.cursor).toEqual( + modelSelection("cursor", "claude-4.6-opus-high", { + reasoningEffort: "high", + }), + ); + expect(mergedState.draftsByThreadId[threadId]?.activeProvider).toBe("cursor"); + expect(mergedState.stickyActiveProvider).toBe("cursor"); + }); + + it("migrates legacy cursor selections with provider options intact", () => { + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const mergedState = persistApi.getOptions().merge( + { + draftsByThreadId: { + [threadId]: { + prompt: "", + attachments: [], + provider: "cursor", + model: "claude-4.6-opus", + modelOptions: { + cursor: { + reasoningEffort: "high", + fastMode: false, + }, + }, + }, + }, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + }, + useComposerDraftStore.getInitialState(), + ); + + expect(mergedState.draftsByThreadId[threadId]?.modelSelectionByProvider.cursor).toEqual( + modelSelection("cursor", "claude-4.6-opus", { + reasoningEffort: "high", + fastMode: false, + }), + ); + expect(mergedState.draftsByThreadId[threadId]?.activeProvider).toBe("cursor"); + }); +}); + describe("composerDraftStore project draft thread mapping", () => { const projectId = ProjectId.makeUnsafe("project-a"); const otherProjectId = ProjectId.makeUnsafe("project-b"); @@ -1011,6 +1146,61 @@ describe("composerDraftStore provider-scoped option updates", () => { expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); expect(draft?.activeProvider).toBe("codex"); }); + + it("stores cursor traits without changing the active selection", () => { + const store = useComposerDraftStore.getState(); + store.setModelSelection( + threadId, + modelSelection("codex", "gpt-5.3-codex", { + reasoningEffort: "medium", + }), + ); + store.setProviderModelOptions(threadId, "cursor", { + reasoningEffort: "xhigh", + fastMode: true, + }); + + const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; + expect(draft?.modelSelectionByProvider.codex).toEqual( + modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), + ); + expect(draft?.modelSelectionByProvider.cursor).toEqual( + modelSelection("cursor", "auto", { + reasoningEffort: "xhigh", + fastMode: true, + }), + ); + expect(draft?.activeProvider).toBe("codex"); + }); +}); + +describe("composerDraftStore cursor selections", () => { + const threadId = ThreadId.makeUnsafe("thread-cursor"); + + beforeEach(() => { + resetComposerDraftStore(); + }); + + it("preserves cursor options on explicit model selections", () => { + const store = useComposerDraftStore.getState(); + + store.setModelSelection( + threadId, + modelSelection("cursor", "gpt-5.4-mini", { + reasoningEffort: "high", + fastMode: false, + }), + ); + + expect( + useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.cursor, + ).toEqual( + modelSelection("cursor", "gpt-5.4-mini", { + reasoningEffort: "high", + fastMode: false, + }), + ); + }); }); describe("composerDraftStore runtime and interaction settings", () => { @@ -1052,6 +1242,38 @@ describe("composerDraftStore runtime and interaction settings", () => { }); }); +describe("deriveEffectiveComposerModelState", () => { + it("resolves Cursor family selections to exact model slugs", () => { + const result = deriveEffectiveComposerModelState({ + draft: { + modelSelectionByProvider: { + cursor: { + provider: "cursor", + model: "claude-4.6-opus", + options: { + reasoningEffort: "high", + }, + }, + }, + activeProvider: "cursor", + }, + providers: [CURSOR_PROVIDER], + selectedProvider: "cursor", + threadModelSelection: null, + projectModelSelection: null, + settings: { + ...DEFAULT_SERVER_SETTINGS, + ...DEFAULT_CLIENT_SETTINGS, + }, + }); + + expect(result.selectedModel).toBe("claude-4.6-opus-high"); + expect(result.modelOptions?.cursor).toEqual({ + reasoningEffort: "high", + }); + }); +}); + // --------------------------------------------------------------------------- // createDebouncedStorage // --------------------------------------------------------------------------- diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 8a93b7b0daa..6a2605ac35f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,6 +1,8 @@ import { CODEX_REASONING_EFFORT_OPTIONS, + CLAUDE_THINKING_BUDGET_OPTIONS, type ClaudeCodeEffort, + type ClaudeThinkingBudget, type CodexReasoningEffort, DEFAULT_MODEL_BY_PROVIDER, ModelSelection, @@ -15,7 +17,7 @@ import { import * as Schema from "effect/Schema"; import * as Equal from "effect/Equal"; import { DeepMutable } from "effect/Types"; -import { normalizeModelSlug } from "@t3tools/shared/model"; +import { buildProviderModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { useMemo } from "react"; import { getLocalStorageItem } from "./hooks/useLocalStorage"; import { resolveAppModelSelection } from "./modelSelection"; @@ -28,8 +30,9 @@ import { import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; -import { getDefaultServerModel } from "./providerModels"; +import { getDefaultServerModel, getProviderModels } from "./providerModels"; import { UnifiedSettings } from "@t3tools/contracts/settings"; +import { resolveExactCursorModelSelection } from "./cursorModelSelector"; export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 3; @@ -37,6 +40,11 @@ const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; const COMPOSER_PERSIST_DEBOUNCE_MS = 300; +const ALL_PROVIDER_KINDS = [ + "codex", + "claudeAgent", + "cursor", +] as const satisfies ReadonlyArray; const composerDebouncedStorage = createDebouncedStorage( typeof localStorage !== "undefined" ? localStorage : createMemoryStorage(), @@ -407,7 +415,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" || value === "claudeAgent" ? value : null; + return Schema.is(ProviderKind)(value) ? value : null; } function normalizeProviderModelOptions( @@ -461,6 +469,10 @@ function normalizeProviderModelOptions( : claudeCandidate?.thinking === false ? false : undefined; + const claudeThinkingBudget: ClaudeThinkingBudget | undefined = + CLAUDE_THINKING_BUDGET_OPTIONS.includes(claudeCandidate?.thinkingBudget as ClaudeThinkingBudget) + ? (claudeCandidate!.thinkingBudget as ClaudeThinkingBudget) + : undefined; const claudeEffort: ClaudeCodeEffort | undefined = claudeCandidate?.effort === "low" || claudeCandidate?.effort === "medium" || @@ -481,23 +493,53 @@ function normalizeProviderModelOptions( : undefined; const claude = claudeThinking !== undefined || + claudeThinkingBudget !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined || claudeContextWindow !== undefined ? { ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), + ...(claudeThinkingBudget !== undefined ? { thinkingBudget: claudeThinkingBudget } : {}), ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), ...(claudeContextWindow !== undefined ? { contextWindow: claudeContextWindow } : {}), } : undefined; - if (!codex && !claude) { + const cursorCandidate = + candidate?.cursor && typeof candidate.cursor === "object" + ? (candidate.cursor as Record) + : null; + const cursorReasoningEffort: CodexReasoningEffort | undefined = + cursorCandidate?.reasoningEffort === "low" || + cursorCandidate?.reasoningEffort === "medium" || + cursorCandidate?.reasoningEffort === "high" || + cursorCandidate?.reasoningEffort === "xhigh" + ? cursorCandidate.reasoningEffort + : undefined; + const cursorFastMode = + cursorCandidate?.fastMode === true + ? true + : cursorCandidate?.fastMode === false + ? false + : undefined; + const cursor = + cursorReasoningEffort !== undefined || cursorFastMode !== undefined + ? { + ...(cursorReasoningEffort !== undefined + ? { reasoningEffort: cursorReasoningEffort } + : {}), + ...(cursorFastMode !== undefined ? { fastMode: cursorFastMode } : {}), + } + : undefined; + + if (!codex && !claude && !cursor) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), + ...(cursor ? { cursor } : {}), }; } @@ -528,12 +570,7 @@ function normalizeModelSelection( provider, provider === "codex" ? legacy?.legacyCodex : undefined, ); - const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent; - return { - provider, - model, - ...(options ? { options } : {}), - }; + return buildProviderModelSelection(provider, model, modelOptions?.[provider]); } // ── Legacy sync helpers (used only during migration from v2 storage) ── @@ -546,11 +583,7 @@ function legacySyncModelSelectionOptions( return null; } const options = modelOptions?.[modelSelection.provider]; - return { - provider: modelSelection.provider, - model: modelSelection.model, - ...(options ? { options } : {}), - }; + return buildProviderModelSelection(modelSelection.provider, modelSelection.model, options); } function legacyMergeModelSelectionIntoProviderModelOptions( @@ -594,17 +627,16 @@ function legacyToModelSelectionByProvider( const result: Partial> = {}; // Add entries from the options bag (for non-active providers) if (modelOptions) { - for (const provider of ["codex", "claudeAgent"] as const) { + for (const provider of ALL_PROVIDER_KINDS) { const options = modelOptions[provider]; if (options && Object.keys(options).length > 0) { - result[provider] = { + result[provider] = buildProviderModelSelection( provider, - model: - modelSelection?.provider === provider - ? modelSelection.model - : DEFAULT_MODEL_BY_PROVIDER[provider], + modelSelection?.provider === provider + ? modelSelection.model + : DEFAULT_MODEL_BY_PROVIDER[provider], options, - }; + ); } } } @@ -646,8 +678,17 @@ export function deriveEffectiveComposerModelState(input: { providerModelOptionsFromSelection(input.projectModelSelection) ?? null; + const resolvedCursorModel = + input.selectedProvider === "cursor" + ? resolveExactCursorModelSelection({ + models: getProviderModels(input.providers, input.selectedProvider), + model: activeSelection?.model ?? baseModel, + options: modelOptions?.cursor, + }) + : null; + return { - selectedModel, + selectedModel: resolvedCursorModel ?? selectedModel, modelOptions, }; } @@ -1636,11 +1677,11 @@ export const useComposerDraftStore = create()( nextMap[normalized.provider] = normalized; } else { // No options in selection → preserve existing options, update provider+model - nextMap[normalized.provider] = { - provider: normalized.provider, - model: normalized.model, - ...(current?.options ? { options: current.options } : {}), - }; + nextMap[normalized.provider] = buildProviderModelSelection( + normalized.provider, + normalized.model, + current?.options, + ); } } const nextActiveProvider = normalized?.provider ?? base.activeProvider; @@ -1676,17 +1717,17 @@ export const useComposerDraftStore = create()( } const base = existing ?? createEmptyThreadDraft(); const nextMap = { ...base.modelSelectionByProvider }; - for (const provider of ["codex", "claudeAgent"] as const) { + for (const provider of ALL_PROVIDER_KINDS) { // Only touch providers explicitly present in the input if (!normalizedOpts || !(provider in normalizedOpts)) continue; const opts = normalizedOpts[provider]; const current = nextMap[provider]; if (opts) { - nextMap[provider] = { + nextMap[provider] = buildProviderModelSelection( provider, - model: current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], - options: opts, - }; + current?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider], + opts, + ); } else if (current?.options) { // Remove options but keep the selection const { options: _, ...rest } = current; @@ -1732,11 +1773,11 @@ export const useComposerDraftStore = create()( const nextMap = { ...base.modelSelectionByProvider }; const currentForProvider = nextMap[normalizedProvider]; if (providerOpts) { - nextMap[normalizedProvider] = { - provider: normalizedProvider, - model: currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - options: providerOpts, - }; + nextMap[normalizedProvider] = buildProviderModelSelection( + normalizedProvider, + currentForProvider?.model ?? DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], + providerOpts, + ); } else if (currentForProvider?.options) { const { options: _, ...rest } = currentForProvider; nextMap[normalizedProvider] = rest as ModelSelection; @@ -1750,16 +1791,16 @@ export const useComposerDraftStore = create()( const stickyBase = nextStickyMap[normalizedProvider] ?? base.modelSelectionByProvider[normalizedProvider] ?? - ({ - provider: normalizedProvider, - model: DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], - } as ModelSelection); + buildProviderModelSelection( + normalizedProvider, + DEFAULT_MODEL_BY_PROVIDER[normalizedProvider], + ); if (providerOpts) { - nextStickyMap[normalizedProvider] = { - ...stickyBase, - provider: normalizedProvider, - options: providerOpts, - }; + nextStickyMap[normalizedProvider] = buildProviderModelSelection( + normalizedProvider, + stickyBase.model, + providerOpts, + ); } else if (stickyBase.options) { const { options: _, ...rest } = stickyBase; nextStickyMap[normalizedProvider] = rest as ModelSelection; diff --git a/apps/web/src/cursorModelSelector.test.ts b/apps/web/src/cursorModelSelector.test.ts new file mode 100644 index 00000000000..ffed135cbf7 --- /dev/null +++ b/apps/web/src/cursorModelSelector.test.ts @@ -0,0 +1,593 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import type { ServerProviderModel } from "@t3tools/contracts"; + +import { + buildCursorSelectorFamilies, + cursorFacetValues, + pickCursorModelFromTraits, + readCursorMetadataForModel, + readCursorSelectedTraits, + resolveCursorSelectorFamily, + resolveExactCursorModelSelection, +} from "./cursorModelSelector"; + +const CURSOR_MODELS: ReadonlyArray = [ + { + slug: "claude-4.6-opus-high", + name: "Opus 4.6 High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "claude-4.6-opus-high-thinking", + name: "Opus 4.6 High Thinking", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + reasoningEffort: "high", + fastMode: false, + thinking: true, + maxMode: false, + }, + }, + { + slug: "claude-4.6-opus-max", + name: "Opus 4.6 Max", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + fastMode: false, + thinking: false, + maxMode: true, + }, + }, + { + slug: "claude-4.6-opus-max-thinking", + name: "Opus 4.6 Max Thinking", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + fastMode: false, + thinking: true, + maxMode: true, + }, + }, + { + slug: "claude-4.6-opus-fast", + name: "Opus 4.6 Fast", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + fastMode: true, + thinking: false, + maxMode: false, + }, + }, +]; + +const EFFORT_ONLY_CURSOR_MODELS: ReadonlyArray = [ + { + slug: "gpt-5.4-nano-none", + name: "GPT-5.4 Nano None", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.4-nano", + familyName: "GPT-5.4 Nano", + reasoningEffort: "medium", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.4-nano-medium", + name: "GPT-5.4 Nano", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.4-nano", + familyName: "GPT-5.4 Nano", + reasoningEffort: "medium", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.4-nano-high", + name: "GPT-5.4 Nano High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.4-nano", + familyName: "GPT-5.4 Nano", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, +]; + +const SPARK_CURSOR_MODELS: ReadonlyArray = [ + { + slug: "gpt-5.3-codex-spark-preview-low", + name: "GPT-5.3 Codex Spark Low", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.3-codex-spark-preview", + familyName: "GPT-5.3 Codex Spark", + reasoningEffort: "low", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.3-codex-spark-preview", + name: "GPT-5.3 Codex Spark", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.3-codex-spark-preview", + familyName: "GPT-5.3 Codex Spark", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.3-codex-spark-preview-high", + name: "GPT-5.3 Codex Spark High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.3-codex-spark-preview", + familyName: "GPT-5.3 Codex Spark", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.3-codex-spark-preview-xhigh", + name: "GPT-5.3 Codex Spark Extra High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.3-codex-spark-preview", + familyName: "GPT-5.3 Codex Spark", + reasoningEffort: "xhigh", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, +]; + +const HIGH_ONLY_CURSOR_MODELS: ReadonlyArray = [ + { + slug: "gpt-5.3-codex-high", + name: "GPT-5.3 Codex High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.3-codex", + familyName: "GPT-5.3 Codex", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.3-codex-xhigh", + name: "GPT-5.3 Codex Extra High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.3-codex", + familyName: "GPT-5.3 Codex", + reasoningEffort: "xhigh", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, +]; + +const CODEX_MAX_CURSOR_MODELS: ReadonlyArray = [ + { + slug: "gpt-5.1-codex-max-low", + name: "GPT-5.1 Codex Max Low", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.1-codex-max", + familyName: "GPT-5.1 Codex Max", + reasoningEffort: "low", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.1-codex-max-medium", + name: "GPT-5.1 Codex Max", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.1-codex-max", + familyName: "GPT-5.1 Codex Max", + reasoningEffort: "medium", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.1-codex-max-high", + name: "GPT-5.1 Codex Max High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.1-codex-max", + familyName: "GPT-5.1 Codex Max", + reasoningEffort: "high", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.1-codex-max-high-fast", + name: "GPT-5.1 Codex Max High Fast", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.1-codex-max", + familyName: "GPT-5.1 Codex Max", + reasoningEffort: "high", + fastMode: true, + thinking: false, + maxMode: false, + }, + }, + { + slug: "gpt-5.1-codex-max-xhigh", + name: "GPT-5.1 Codex Max Extra High", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "gpt-5.1-codex-max", + familyName: "GPT-5.1 Codex Max", + reasoningEffort: "xhigh", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, +]; + +const FAST_ONLY_CURSOR_MODELS: ReadonlyArray = [ + { + slug: "composer-2-fast", + name: "Composer 2 Fast", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "composer-2", + familyName: "Composer 2", + fastMode: true, + thinking: false, + maxMode: false, + }, + }, + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: null, + cursorMetadata: { + familySlug: "composer-2", + familyName: "Composer 2", + fastMode: false, + thinking: false, + maxMode: false, + }, + }, +]; + +describe("cursorModelSelector", () => { + it("groups exact Cursor models into families with the available variant axes", () => { + const family = buildCursorSelectorFamilies(CURSOR_MODELS)[0]; + + assert.deepEqual(family, { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + models: CURSOR_MODELS, + reasoningEffortOptions: ["medium", "high"], + supportsFastMode: true, + supportsThinkingToggle: true, + supportsMaxMode: true, + }); + }); + + it("resolves family selections plus options to an exact Cursor slug", () => { + assert.equal( + resolveExactCursorModelSelection({ + models: CURSOR_MODELS, + model: "claude-4.6-opus", + options: { + reasoningEffort: "high", + }, + }), + "claude-4.6-opus-high", + ); + }); + + it("finds the matching family for an exact selected slug", () => { + const family = resolveCursorSelectorFamily(CURSOR_MODELS, "claude-4.6-opus-max-thinking"); + + assert.equal(family?.familySlug, "claude-4.6-opus"); + assert.equal(family?.supportsThinkingToggle, true); + }); + + it("picks an exact slug from family facet selections and exposes metadata", () => { + const family = resolveCursorSelectorFamily(CURSOR_MODELS, "claude-4.6-opus-max"); + assert.ok(family); + if (!family) { + return; + } + + const selected = pickCursorModelFromTraits({ + family, + selections: { + reasoningEffort: "medium", + thinking: true, + maxMode: true, + }, + }); + + assert.equal(selected?.slug, "claude-4.6-opus-max-thinking"); + assert.deepEqual(readCursorMetadataForModel(selected!), { + familySlug: "claude-4.6-opus", + familyName: "Opus 4.6", + fastMode: false, + thinking: true, + maxMode: true, + }); + }); + + it("reads the selected traits for an exact Cursor slug", () => { + const family = resolveCursorSelectorFamily(CURSOR_MODELS, "claude-4.6-opus-max-thinking"); + + assert.deepEqual( + readCursorSelectedTraits({ + family, + model: "claude-4.6-opus-max-thinking", + }), + { + reasoningEffort: "medium", + thinking: true, + fastMode: false, + maxMode: true, + }, + ); + }); + + it("filters available Cursor facet values against the other selected traits", () => { + const family = resolveCursorSelectorFamily(CURSOR_MODELS, "claude-4.6-opus-max-thinking"); + assert.ok(family); + if (!family) { + return; + } + + assert.deepEqual( + cursorFacetValues(family, "thinking", { + reasoningEffort: "medium", + maxMode: true, + }), + ["false", "true"], + ); + + assert.deepEqual( + cursorFacetValues(family, "fastMode", { + reasoningEffort: "medium", + maxMode: true, + }), + ["false"], + ); + + assert.deepEqual( + cursorFacetValues(family, "thinking", { + reasoningEffort: "high", + }), + ["false", "true"], + ); + }); + + it("does not invent a thinking toggle for effort-only Cursor families", () => { + const family = buildCursorSelectorFamilies(EFFORT_ONLY_CURSOR_MODELS)[0]; + + assert.deepEqual(family, { + familySlug: "gpt-5.4-nano", + familyName: "GPT-5.4 Nano", + models: EFFORT_ONLY_CURSOR_MODELS, + reasoningEffortOptions: ["medium", "high"], + supportsFastMode: false, + supportsThinkingToggle: false, + supportsMaxMode: false, + }); + + assert.deepEqual( + readCursorSelectedTraits({ + family, + model: "gpt-5.4-nano-medium", + }), + { + reasoningEffort: "medium", + }, + ); + }); + + it("keeps all explicit Spark Preview effort variants available in the selector", () => { + const family = buildCursorSelectorFamilies(SPARK_CURSOR_MODELS)[0]; + + assert.deepEqual(family, { + familySlug: "gpt-5.3-codex-spark-preview", + familyName: "GPT-5.3 Codex Spark", + models: SPARK_CURSOR_MODELS, + reasoningEffortOptions: ["low", "medium", "high", "xhigh"], + supportsFastMode: false, + supportsThinkingToggle: false, + supportsMaxMode: false, + }); + + const selectedTraits = readCursorSelectedTraits({ + family, + model: "gpt-5.3-codex-spark-preview", + }); + + assert.deepEqual(selectedTraits, { + reasoningEffort: "medium", + }); + assert.deepEqual(cursorFacetValues(family, "reasoningEffort", selectedTraits), [ + "low", + "medium", + "high", + "xhigh", + ]); + assert.equal( + pickCursorModelFromTraits({ + family, + selections: { reasoningEffort: "xhigh" }, + })?.slug, + "gpt-5.3-codex-spark-preview-xhigh", + ); + }); + + it("does not synthesize medium for high-xhigh only Cursor families", () => { + const family = buildCursorSelectorFamilies(HIGH_ONLY_CURSOR_MODELS)[0]; + + assert.deepEqual(family, { + familySlug: "gpt-5.3-codex", + familyName: "GPT-5.3 Codex", + models: HIGH_ONLY_CURSOR_MODELS, + reasoningEffortOptions: ["high", "xhigh"], + supportsFastMode: false, + supportsThinkingToggle: false, + supportsMaxMode: false, + }); + + assert.deepEqual( + cursorFacetValues(family, "reasoningEffort", { + reasoningEffort: "high", + }), + ["high", "xhigh"], + ); + assert.equal( + resolveExactCursorModelSelection({ + models: HIGH_ONLY_CURSOR_MODELS, + model: "gpt-5.3-codex", + options: { reasoningEffort: "xhigh" }, + }), + "gpt-5.3-codex-xhigh", + ); + }); + + it("treats Codex Max as the family name for max-only Cursor families", () => { + const family = buildCursorSelectorFamilies(CODEX_MAX_CURSOR_MODELS)[0]; + + assert.deepEqual(family, { + familySlug: "gpt-5.1-codex-max", + familyName: "GPT-5.1 Codex Max", + models: CODEX_MAX_CURSOR_MODELS, + reasoningEffortOptions: ["low", "medium", "high", "xhigh"], + supportsFastMode: true, + supportsThinkingToggle: false, + supportsMaxMode: false, + }); + + assert.deepEqual( + readCursorSelectedTraits({ + family, + model: "gpt-5.1-codex-max-medium", + }), + { + reasoningEffort: "medium", + fastMode: false, + }, + ); + + assert.equal( + resolveExactCursorModelSelection({ + models: CODEX_MAX_CURSOR_MODELS, + model: "gpt-5.1-codex-max", + options: { reasoningEffort: "high", fastMode: true }, + }), + "gpt-5.1-codex-max-high-fast", + ); + }); + + it("supports fast-only Cursor families like Composer 2", () => { + const family = buildCursorSelectorFamilies(FAST_ONLY_CURSOR_MODELS)[0]; + + assert.deepEqual(family, { + familySlug: "composer-2", + familyName: "Composer 2", + models: FAST_ONLY_CURSOR_MODELS, + reasoningEffortOptions: [], + supportsFastMode: true, + supportsThinkingToggle: false, + supportsMaxMode: false, + }); + + assert.deepEqual( + readCursorSelectedTraits({ + family, + model: "composer-2", + }), + { + fastMode: false, + }, + ); + assert.deepEqual(cursorFacetValues(family, "fastMode", { fastMode: false }), ["false", "true"]); + assert.equal( + pickCursorModelFromTraits({ + family, + selections: { fastMode: true }, + })?.slug, + "composer-2-fast", + ); + assert.equal( + resolveExactCursorModelSelection({ + models: FAST_ONLY_CURSOR_MODELS, + model: "composer-2", + options: { fastMode: true }, + }), + "composer-2-fast", + ); + }); +}); diff --git a/apps/web/src/cursorModelSelector.ts b/apps/web/src/cursorModelSelector.ts new file mode 100644 index 00000000000..f1cc1ac6cc5 --- /dev/null +++ b/apps/web/src/cursorModelSelector.ts @@ -0,0 +1,365 @@ +import type { + CursorModelMetadata, + CursorModelOptions, + ServerProviderModel, +} from "@t3tools/contracts"; + +export type CursorSelectorFamily = { + readonly familySlug: string; + readonly familyName: string; + readonly models: ReadonlyArray; + readonly reasoningEffortOptions: ReadonlyArray; + readonly supportsFastMode: boolean; + readonly supportsThinkingToggle: boolean; + readonly supportsMaxMode: boolean; +}; + +export type CursorSelectorReasoningEffort = + | NonNullable + | "medium"; + +const CURSOR_REASONING_ORDER: ReadonlyArray = [ + "low", + "medium", + "high", + "xhigh", +]; + +type CursorFacetKey = "reasoningEffort" | "fastMode" | "thinking" | "maxMode"; + +type DesiredCursorTraits = { + readonly reasoningEffort?: CursorSelectorReasoningEffort | null; + readonly fastMode?: boolean; + readonly thinking?: boolean; + readonly maxMode?: boolean; +}; + +type NormalizedCursorMetadata = { + readonly familySlug: string; + readonly familyName: string; + readonly reasoningEffort: CursorSelectorReasoningEffort | null; + readonly fastMode: boolean; + readonly thinking: boolean; + readonly maxMode: boolean; +}; + +function readCursorMetadata(model: ServerProviderModel): NormalizedCursorMetadata | null { + const metadata = model.cursorMetadata; + if (!metadata) { + return null; + } + return { + familySlug: metadata.familySlug, + familyName: metadata.familyName, + reasoningEffort: metadata.reasoningEffort ?? null, + fastMode: metadata.fastMode === true, + thinking: metadata.thinking === true, + maxMode: metadata.maxMode === true, + }; +} + +function normalizedReasoningEffort( + metadata: NormalizedCursorMetadata, + family: CursorSelectorFamily, +): CursorSelectorReasoningEffort | null { + if (metadata.reasoningEffort) { + return metadata.reasoningEffort; + } + return family.reasoningEffortOptions.length > 0 ? "medium" : null; +} + +function sortFamilies( + families: ReadonlyArray, + sourceModels: ReadonlyArray, +): ReadonlyArray { + const order = new Map(sourceModels.map((model, index) => [model.slug, index])); + return [...families].toSorted( + (left, right) => + (order.get(left.models[0]?.slug ?? left.familySlug) ?? Number.MAX_SAFE_INTEGER) - + (order.get(right.models[0]?.slug ?? right.familySlug) ?? Number.MAX_SAFE_INTEGER), + ); +} + +export function buildCursorSelectorFamilies( + models: ReadonlyArray, +): ReadonlyArray { + const grouped = new Map>(); + for (const model of models) { + const metadata = readCursorMetadata(model); + const familySlug = metadata?.familySlug ?? model.slug; + const group = grouped.get(familySlug); + if (group) { + group.push(model); + } else { + grouped.set(familySlug, [model]); + } + } + + const families = [...grouped.entries()].map(([familySlug, familyModels]) => { + const firstMetadata = readCursorMetadata(familyModels[0]!); + const reasoningEfforts = new Set(); + const fastValues = new Set(); + const thinkingValues = new Set(); + const maxValues = new Set(); + + for (const model of familyModels) { + const metadata = readCursorMetadata(model); + if (!metadata) { + continue; + } + if (metadata.reasoningEffort) { + reasoningEfforts.add(metadata.reasoningEffort); + } else if (familyModels.some((candidate) => readCursorMetadata(candidate)?.reasoningEffort)) { + reasoningEfforts.add("medium"); + } + fastValues.add(metadata.fastMode); + thinkingValues.add(metadata.thinking); + maxValues.add(metadata.maxMode); + } + + return { + familySlug, + familyName: firstMetadata?.familyName ?? familyModels[0]?.name ?? familySlug, + models: familyModels, + reasoningEffortOptions: CURSOR_REASONING_ORDER.filter((value) => reasoningEfforts.has(value)), + supportsFastMode: fastValues.size > 1, + supportsThinkingToggle: thinkingValues.size > 1, + supportsMaxMode: maxValues.size > 1, + } satisfies CursorSelectorFamily; + }); + + return sortFamilies(families, models); +} + +function fallbackScore( + metadata: NormalizedCursorMetadata | null, + family: CursorSelectorFamily, +): number { + if (!metadata) { + return 0; + } + let score = 0; + if (normalizedReasoningEffort(metadata, family) === "medium") { + score += 4; + } + if (!metadata.fastMode) { + score += 2; + } + if (!metadata.thinking) { + score += 2; + } + if (!metadata.maxMode) { + score += 2; + } + return score; +} + +function pickCursorModelForFamily(input: { + readonly family: CursorSelectorFamily; + readonly desired: DesiredCursorTraits; +}): ServerProviderModel | null { + let best: + | { + readonly model: ServerProviderModel; + readonly score: number; + } + | undefined; + + for (const model of input.family.models) { + const metadata = readCursorMetadata(model); + let score = fallbackScore(metadata, input.family); + const reasoningEffort = metadata ? normalizedReasoningEffort(metadata, input.family) : null; + if (input.desired.reasoningEffort !== undefined) { + if (reasoningEffort !== input.desired.reasoningEffort) { + continue; + } + score += 12; + } + if (input.desired.fastMode !== undefined) { + if ((metadata?.fastMode ?? false) !== input.desired.fastMode) { + continue; + } + score += 8; + } + if (input.desired.thinking !== undefined) { + if ((metadata?.thinking ?? false) !== input.desired.thinking) { + continue; + } + score += 8; + } + if (input.desired.maxMode !== undefined) { + if ((metadata?.maxMode ?? false) !== input.desired.maxMode) { + continue; + } + score += 8; + } + if (!best || score > best.score) { + best = { model, score }; + } + } + + return best?.model ?? input.family.models[0] ?? null; +} + +function findFamilyByModel( + families: ReadonlyArray, + model: string | null | undefined, +): CursorSelectorFamily | null { + if (!model) { + return families[0] ?? null; + } + return ( + families.find((family) => family.models.some((candidate) => candidate.slug === model)) ?? + families.find((family) => family.familySlug === model) ?? + null + ); +} + +export function resolveExactCursorModelSelection(input: { + readonly models: ReadonlyArray; + readonly model: string | null | undefined; + readonly options?: CursorModelOptions | null | undefined; +}): string | null { + const hasTraitOptions = + input.options?.reasoningEffort !== undefined || input.options?.fastMode !== undefined; + const direct = input.models.find((candidate) => candidate.slug === input.model); + if (direct && !hasTraitOptions) { + return direct.slug; + } + const families = buildCursorSelectorFamilies(input.models); + const family = findFamilyByModel(families, input.model); + if (!family) { + return direct?.slug ?? null; + } + return ( + pickCursorModelForFamily({ + family, + desired: { + ...(input.options?.reasoningEffort + ? { reasoningEffort: input.options.reasoningEffort } + : {}), + ...(input.options?.fastMode !== undefined ? { fastMode: input.options.fastMode } : {}), + }, + })?.slug ?? + direct?.slug ?? + null + ); +} + +export function resolveCursorSelectorFamily( + models: ReadonlyArray, + model: string | null | undefined, +): CursorSelectorFamily | null { + return findFamilyByModel(buildCursorSelectorFamilies(models), model); +} + +export function modelMatchesCursorFacet( + model: ServerProviderModel, + family: CursorSelectorFamily, + selections: DesiredCursorTraits, + ignoredFacet?: CursorFacetKey, +): boolean { + const metadata = readCursorMetadata(model); + const reasoningEffort = metadata ? normalizedReasoningEffort(metadata, family) : null; + if ( + ignoredFacet !== "reasoningEffort" && + selections.reasoningEffort !== undefined && + reasoningEffort !== selections.reasoningEffort + ) { + return false; + } + if ( + ignoredFacet !== "fastMode" && + selections.fastMode !== undefined && + (metadata?.fastMode ?? false) !== selections.fastMode + ) { + return false; + } + if ( + ignoredFacet !== "thinking" && + selections.thinking !== undefined && + (metadata?.thinking ?? false) !== selections.thinking + ) { + return false; + } + if ( + ignoredFacet !== "maxMode" && + selections.maxMode !== undefined && + (metadata?.maxMode ?? false) !== selections.maxMode + ) { + return false; + } + return true; +} + +export function readCursorSelectedTraits(input: { + readonly family: CursorSelectorFamily | null; + readonly model: string | null | undefined; +}): DesiredCursorTraits { + if (!input.family) { + return {}; + } + const selectedModel = input.family.models.find((candidate) => candidate.slug === input.model); + const metadata = selectedModel ? readCursorMetadata(selectedModel) : null; + return { + ...(input.family.reasoningEffortOptions.length > 0 + ? { + reasoningEffort: metadata ? normalizedReasoningEffort(metadata, input.family) : "medium", + } + : {}), + ...(input.family.supportsFastMode ? { fastMode: metadata?.fastMode ?? false } : {}), + ...(input.family.supportsThinkingToggle ? { thinking: metadata?.thinking ?? false } : {}), + ...(input.family.supportsMaxMode ? { maxMode: metadata?.maxMode ?? false } : {}), + }; +} + +export function cursorFacetValues( + family: CursorSelectorFamily, + key: CursorFacetKey, + selections: DesiredCursorTraits, +): ReadonlyArray { + if (key === "reasoningEffort") { + return family.reasoningEffortOptions.filter((value) => + family.models.some( + (model) => + modelMatchesCursorFacet(model, family, selections, "reasoningEffort") && + (readCursorMetadata(model) + ? normalizedReasoningEffort(readCursorMetadata(model)!, family) + : null) === value, + ), + ); + } + + const values = new Set(); + for (const model of family.models) { + if (!modelMatchesCursorFacet(model, family, selections, key)) { + continue; + } + const metadata = readCursorMetadata(model); + const value = + key === "fastMode" + ? String(metadata?.fastMode === true) + : key === "thinking" + ? String(metadata?.thinking === true) + : String(metadata?.maxMode === true); + values.add(value); + } + + return ["false", "true"].filter((value) => values.has(value)); +} + +export function pickCursorModelFromTraits(input: { + readonly family: CursorSelectorFamily; + readonly selections: DesiredCursorTraits; +}): ServerProviderModel | null { + return pickCursorModelForFamily({ + family: input.family, + desired: input.selections, + }); +} + +export function readCursorMetadataForModel( + model: ServerProviderModel, +): CursorModelMetadata | undefined { + return model.cursorMetadata; +} diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 98e2884adfe..03c69806a14 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -4,7 +4,11 @@ import { type ProviderKind, type ServerProvider, } from "@t3tools/contracts"; -import { normalizeModelSlug, resolveSelectableModel } from "@t3tools/shared/model"; +import { + buildProviderModelSelection, + normalizeModelSlug, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { getComposerProviderState } from "./components/chat/composerProviderRegistry"; import { UnifiedSettings } from "@t3tools/contracts/settings"; import { @@ -12,6 +16,7 @@ import { getProviderModels, resolveSelectableProvider, } from "./providerModels"; +import { resolveExactCursorModelSelection } from "./cursorModelSelector"; const MAX_CUSTOM_MODEL_COUNT = 32; export const MAX_CUSTOM_MODEL_LENGTH = 256; @@ -45,6 +50,13 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record = [ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, - { value: "cursor", label: "Cursor", available: false }, + { value: "cursor", label: "Cursor", available: true }, ]; export interface WorkLogEntry { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 6e768c4ef85..f7fa2aa06d3 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -3,7 +3,7 @@ import { type OrchestrationMessage, type OrchestrationProposedPlan, type ProjectId, - type ProviderKind, + ProviderKind, ThreadId, type OrchestrationReadModel, type OrchestrationSession, @@ -11,6 +11,7 @@ import { type OrchestrationThread, type OrchestrationSessionStatus, } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; import { @@ -81,9 +82,9 @@ function updateProject( return changed ? next : projects; } -function normalizeModelSelection( - selection: T, -): T { +function normalizeModelSelection< + T extends { provider: "codex" | "claudeAgent" | "cursor"; model: string }, +>(selection: T): T { return { ...selection, model: resolveModelSlugForProvider(selection.provider, selection.model), @@ -494,7 +495,7 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex" || providerName === "claudeAgent") { + if (Schema.is(ProviderKind)(providerName)) { return providerName; } return "codex"; diff --git a/bun.lock b/bun.lock index af243cf4eb0..c56f9da4b81 100644 --- a/bun.lock +++ b/bun.lock @@ -169,6 +169,7 @@ }, }, "trustedDependencies": [ + "electron", "node-pty", ], "overrides": { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index e62a957e058..e8fef765c8d 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -14,17 +14,38 @@ export const CodexModelOptions = Schema.Struct({ }); export type CodexModelOptions = typeof CodexModelOptions.Type; +export const CLAUDE_THINKING_BUDGET_OPTIONS = ["low", "medium", "high"] as const; +export type ClaudeThinkingBudget = (typeof CLAUDE_THINKING_BUDGET_OPTIONS)[number]; + export const ClaudeModelOptions = Schema.Struct({ thinking: Schema.optional(Schema.Boolean), + thinkingBudget: Schema.optional(Schema.Literals(CLAUDE_THINKING_BUDGET_OPTIONS)), effort: Schema.optional(Schema.Literals(CLAUDE_CODE_EFFORT_OPTIONS)), fastMode: Schema.optional(Schema.Boolean), contextWindow: Schema.optional(Schema.String), }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; +export const CursorModelOptions = Schema.Struct({ + reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), + fastMode: Schema.optional(Schema.Boolean), +}); +export type CursorModelOptions = typeof CursorModelOptions.Type; + +export const CursorModelMetadata = Schema.Struct({ + familySlug: TrimmedNonEmptyString, + familyName: TrimmedNonEmptyString, + reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), + fastMode: Schema.optional(Schema.Boolean), + thinking: Schema.optional(Schema.Boolean), + maxMode: Schema.optional(Schema.Boolean), +}); +export type CursorModelMetadata = typeof CursorModelMetadata.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), claudeAgent: Schema.optional(ClaudeModelOptions), + cursor: Schema.optional(CursorModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -46,6 +67,7 @@ export const ModelCapabilities = Schema.Struct({ reasoningEffortLevels: Schema.Array(EffortOption), supportsFastMode: Schema.Boolean, supportsThinkingToggle: Schema.Boolean, + thinkingBudgetOptions: Schema.optional(Schema.Array(EffortOption)), contextWindowOptions: Schema.Array(ContextWindowOption), promptInjectedEffortLevels: Schema.Array(TrimmedNonEmptyString), }); @@ -54,6 +76,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", + cursor: "auto", }; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; @@ -62,6 +85,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4-mini", claudeAgent: "claude-haiku-4-5", + cursor: "auto", }; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { @@ -86,6 +110,13 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { codex: "Codex", claudeAgent: "Claude", + cursor: "Cursor", }; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6c7f0736124..59219cdbe40 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,5 +1,5 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; -import { ClaudeModelOptions, CodexModelOptions } from "./model"; +import { ClaudeModelOptions, CodexModelOptions, CursorModelOptions } from "./model"; import { ApprovalRequestId, CheckpointRef, @@ -23,7 +23,7 @@ export const ORCHESTRATION_WS_METHODS = { replayEvents: "orchestration.replayEvents", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeAgent"]); +export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -55,7 +55,18 @@ export const ClaudeModelSelection = Schema.Struct({ }); export type ClaudeModelSelection = typeof ClaudeModelSelection.Type; -export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]); +export const CursorModelSelection = Schema.Struct({ + provider: Schema.Literal("cursor"), + model: TrimmedNonEmptyString, + options: Schema.optionalKey(CursorModelOptions), +}); +export type CursorModelSelection = typeof CursorModelSelection.Type; + +export const ModelSelection = Schema.Union([ + CodexModelSelection, + ClaudeModelSelection, + CursorModelSelection, +]); export type ModelSelection = typeof ModelSelection.Type; export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 16102920d71..8e8b098e77a 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -46,12 +46,20 @@ export const ProviderSession = Schema.Struct({ }); export type ProviderSession = typeof ProviderSession.Type; +export const ProviderReplayTurn = Schema.Struct({ + prompt: Schema.String, + attachmentNames: Schema.Array(TrimmedNonEmptyString), + assistantResponse: Schema.optional(Schema.String), +}); +export type ProviderReplayTurn = typeof ProviderReplayTurn.Type; + export const ProviderSessionStartInput = Schema.Struct({ threadId: ThreadId, provider: Schema.optional(ProviderKind), cwd: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), + replayTurns: Schema.optional(Schema.Array(ProviderReplayTurn)), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), runtimeMode: RuntimeMode, diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 81231d88f68..c0bafb30ca6 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -24,6 +24,8 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.message", "claude.sdk.permission", "codex.sdk.thread-event", + "cursor.acp.notification", + "cursor.acp.request", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 776a0a89e96..0c97fb6081b 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -8,7 +8,7 @@ import { } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; import { EditorId } from "./editor"; -import { ModelCapabilities } from "./model"; +import { CursorModelMetadata, ModelCapabilities } from "./model"; import { ProviderKind } from "./orchestration"; import { ServerSettings } from "./settings"; @@ -53,6 +53,7 @@ export const ServerProviderModel = Schema.Struct({ name: TrimmedNonEmptyString, isCustom: Schema.Boolean, capabilities: Schema.NullOr(ModelCapabilities), + cursorMetadata: Schema.optional(CursorModelMetadata), }); export type ServerProviderModel = typeof ServerProviderModel.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6633ce42a6e..1d2cb6548bc 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -5,6 +5,7 @@ import { TrimmedNonEmptyString, TrimmedString } from "./baseSchemas"; import { ClaudeModelOptions, CodexModelOptions, + CursorModelOptions, DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER, } from "./model"; import { ModelSelection } from "./orchestration"; @@ -71,6 +72,13 @@ export const ClaudeSettings = Schema.Struct({ }); export type ClaudeSettings = typeof ClaudeSettings.Type; +export const CursorSettings = Schema.Struct({ + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), + binaryPath: makeBinaryPathSetting("cursor-agent"), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), +}); +export type CursorSettings = typeof CursorSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), @@ -93,6 +101,7 @@ export const ServerSettings = Schema.Struct({ providers: Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), + cursor: CursorSettings.pipe(Schema.withDecodingDefault(() => ({}))), }).pipe(Schema.withDecodingDefault(() => ({}))), observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(() => ({}))), }); @@ -135,6 +144,11 @@ const ClaudeModelOptionsPatch = Schema.Struct({ contextWindow: Schema.optionalKey(ClaudeModelOptions.fields.contextWindow), }); +const CursorModelOptionsPatch = Schema.Struct({ + reasoningEffort: Schema.optionalKey(CursorModelOptions.fields.reasoningEffort), + fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode), +}); + const ModelSelectionPatch = Schema.Union([ Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("codex")), @@ -146,6 +160,11 @@ const ModelSelectionPatch = Schema.Union([ model: Schema.optionalKey(TrimmedNonEmptyString), options: Schema.optionalKey(ClaudeModelOptionsPatch), }), + Schema.Struct({ + provider: Schema.optionalKey(Schema.Literal("cursor")), + model: Schema.optionalKey(TrimmedNonEmptyString), + options: Schema.optionalKey(CursorModelOptionsPatch), + }), ]); const CodexSettingsPatch = Schema.Struct({ @@ -161,6 +180,12 @@ const ClaudeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const CursorSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), @@ -175,6 +200,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), + cursor: Schema.optionalKey(CursorSettingsPatch), }), ), }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 2aa378cf63d..86a6c766ea2 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -4,9 +4,11 @@ import { type ClaudeCodeEffort, type ClaudeModelOptions, type CodexModelOptions, + type CursorModelOptions, type ModelCapabilities, type ModelSelection, type ProviderKind, + type ProviderModelOptions, } from "@t3tools/contracts"; export interface SelectableModelOption { @@ -14,6 +16,36 @@ export interface SelectableModelOption { name: string; } +type ModelSelectionByProvider = Extract< + ModelSelection, + { provider: TProvider } +>; + +function withoutKnownCursorVariantSuffix(value: string): string { + let normalized = value; + + if (normalized.endsWith("-fast")) { + normalized = normalized.substring(0, normalized.length - "-fast".length); + } + + for (const suffix of ["-xhigh", "-high", "-medium", "-low", "-none"] as const) { + if (normalized.endsWith(suffix)) { + return normalized.substring(0, normalized.length - suffix.length); + } + } + + return normalized; +} + +function normalizeCursorVariantSelectionCandidate(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + const normalized = withoutKnownCursorVariantSuffix(trimmed); + return normalized !== trimmed ? normalized : null; +} + // ── Effort helpers ──────────────────────────────────────────────────── /** Check whether a capabilities object includes a given effort value. */ @@ -106,10 +138,20 @@ export function normalizeClaudeModelOptionsWithCapabilities( ): ClaudeModelOptions | undefined { const effort = resolveEffort(caps, modelOptions?.effort); const thinking = caps.supportsThinkingToggle ? modelOptions?.thinking : undefined; + const thinkingBudgetOptions = caps.thinkingBudgetOptions ?? []; + const thinkingBudget = + thinking === true && thinkingBudgetOptions.length > 0 + ? (thinkingBudgetOptions.find((o) => o.value === modelOptions?.thinkingBudget)?.value ?? + thinkingBudgetOptions.find((o) => o.isDefault)?.value ?? + undefined) + : undefined; const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); const nextOptions: ClaudeModelOptions = { ...(thinking !== undefined ? { thinking } : {}), + ...(thinkingBudget !== undefined + ? { thinkingBudget: thinkingBudget as ClaudeModelOptions["thinkingBudget"] } + : {}), ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), ...(fastMode !== undefined ? { fastMode } : {}), ...(contextWindow !== undefined ? { contextWindow } : {}), @@ -117,6 +159,33 @@ export function normalizeClaudeModelOptionsWithCapabilities( return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } +export function normalizeCursorModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: CursorModelOptions | null | undefined, +): CursorModelOptions | undefined { + const reasoningEffort = resolveEffort(caps, modelOptions?.reasoningEffort); + const fastMode = caps.supportsFastMode ? modelOptions?.fastMode : undefined; + const nextOptions: CursorModelOptions = { + ...(reasoningEffort + ? { reasoningEffort: reasoningEffort as CursorModelOptions["reasoningEffort"] } + : {}), + ...(fastMode !== undefined ? { fastMode } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function buildProviderModelSelection( + provider: TProvider, + model: string, + options?: ProviderModelOptions[TProvider], +): ModelSelectionByProvider { + return { + provider, + model, + ...(options === undefined ? {} : { options }), + } as ModelSelectionByProvider; +} + export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { return typeof text === "string" && /\bultrathink\b/i.test(text); } @@ -171,7 +240,20 @@ export function resolveSelectableModel( } const resolved = options.find((option) => option.slug === normalized); - return resolved ? resolved.slug : null; + if (resolved) { + return resolved.slug; + } + + if (provider === "cursor") { + const canonicalCursorSlug = normalizeCursorVariantSelectionCandidate(normalized); + if (!canonicalCursorSlug) { + return null; + } + const canonicalCursorOption = options.find((option) => option.slug === canonicalCursorSlug); + return canonicalCursorOption ? canonicalCursorOption.slug : null; + } + + return null; } export function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string { @@ -196,6 +278,64 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } +function includesAny(value: string, candidates: ReadonlyArray): boolean { + return candidates.some((candidate) => value.includes(candidate)); +} + +export function inferModelContextWindowTokens( + provider: ProviderKind, + model: string | null | undefined, +): number | undefined { + const normalized = normalizeModelSlug(model, provider); + const lookupValue = (normalized ?? trimOrNull(model) ?? "").toLowerCase(); + if (!lookupValue) { + return undefined; + } + + switch (provider) { + case "cursor": { + if (includesAny(lookupValue, ["composer-2", "composer 2"])) { + return 200_000; + } + if ( + includesAny(lookupValue, [ + "claude-4-sonnet", + "claude 4 sonnet", + "claude-sonnet-4-6", + "claude sonnet 4.6", + "claude-4.6-sonnet", + "sonnet-4", + "sonnet 4", + "claude-opus-4-6", + "claude opus 4.6", + "claude-4.6-opus", + "opus-4.6", + "opus 4.6", + "gemini-3.1-pro", + "gemini 3.1 pro", + ]) + ) { + return 200_000; + } + if ( + lookupValue === "gpt-5.3-codex" || + lookupValue.startsWith("gpt-5.3-codex ") || + lookupValue === "gpt 5.3 codex" || + lookupValue.startsWith("gpt 5.3 codex ") || + lookupValue === "gpt-5.4" || + lookupValue.startsWith("gpt-5.4 ") || + lookupValue === "gpt 5.4" || + lookupValue.startsWith("gpt 5.4 ") + ) { + return 272_000; + } + return undefined; + } + default: + return undefined; + } +} + /** * Resolve the actual API model identifier from a model selection. *