From ce5a325147d329971f984df308eee9367d764a93 Mon Sep 17 00:00:00 2001 From: arpan404 Date: Sun, 5 Apr 2026 21:48:31 -0500 Subject: [PATCH 01/16] feat(cursor): add contracts and shared model helpers --- packages/contracts/src/model.ts | 28 +++++ packages/contracts/src/orchestration.ts | 17 ++- packages/contracts/src/provider.ts | 8 ++ packages/contracts/src/providerRuntime.ts | 2 + packages/contracts/src/server.ts | 3 +- packages/contracts/src/settings.ts | 26 +++++ packages/shared/src/model.ts | 132 +++++++++++++++++++++- 7 files changed, 211 insertions(+), 5 deletions(-) diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index e62a957e058..50d20112990 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -22,9 +22,26 @@ export const ClaudeModelOptions = Schema.Struct({ }); 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; @@ -54,6 +71,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 +80,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 +105,14 @@ 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..e7b92ce6064 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. */ @@ -117,6 +149,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 +230,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 +268,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. * From b52419fe5a3ce30a86db0de47fad827204b9f1db Mon Sep 17 00:00:00 2001 From: arpan404 Date: Sun, 5 Apr 2026 21:48:39 -0500 Subject: [PATCH 02/16] feat(cursor-server): add acp adapter runtime --- .../src/provider/Layers/CursorAdapter.test.ts | 1427 ++++++++++ .../src/provider/Layers/CursorAdapter.ts | 2406 +++++++++++++++++ .../Layers/CursorAdapterErrors.test.ts | 59 + .../provider/Layers/CursorAdapterErrors.ts | 78 + .../CursorAdapterSessionMetadata.test.ts | 136 + .../Layers/CursorAdapterSessionMetadata.ts | 515 ++++ .../Layers/CursorAdapterToolHelpers.test.ts | 125 + .../Layers/CursorAdapterToolHelpers.ts | 533 ++++ .../Layers/CursorAdapterUsageParsing.test.ts | 75 + .../Layers/CursorAdapterUsageParsing.ts | 238 ++ .../provider/Layers/CursorProvider.test.ts | 212 ++ .../src/provider/Layers/CursorProvider.ts | 518 ++++ .../src/provider/Services/CursorAdapter.ts | 12 + .../src/provider/Services/CursorProvider.ts | 9 + apps/server/src/provider/acpClient.test.ts | 191 ++ apps/server/src/provider/acpClient.ts | 326 +++ apps/server/src/provider/cursorAcp.test.ts | 31 + apps/server/src/provider/cursorAcp.ts | 29 + .../src/provider/providerReplayTurns.test.ts | 61 + .../src/provider/providerReplayTurns.ts | 86 + .../providerTranscriptBootstrap.test.ts | 95 + .../provider/providerTranscriptBootstrap.ts | 192 ++ apps/server/src/provider/unknown.ts | 36 + 23 files changed, 7390 insertions(+) create mode 100644 apps/server/src/provider/Layers/CursorAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapter.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapterErrors.test.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapterErrors.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapterSessionMetadata.test.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapterSessionMetadata.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapterToolHelpers.test.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapterUsageParsing.test.ts create mode 100644 apps/server/src/provider/Layers/CursorAdapterUsageParsing.ts create mode 100644 apps/server/src/provider/Layers/CursorProvider.test.ts create mode 100644 apps/server/src/provider/Layers/CursorProvider.ts create mode 100644 apps/server/src/provider/Services/CursorAdapter.ts create mode 100644 apps/server/src/provider/Services/CursorProvider.ts create mode 100644 apps/server/src/provider/acpClient.test.ts create mode 100644 apps/server/src/provider/acpClient.ts create mode 100644 apps/server/src/provider/cursorAcp.test.ts create mode 100644 apps/server/src/provider/cursorAcp.ts create mode 100644 apps/server/src/provider/providerReplayTurns.test.ts create mode 100644 apps/server/src/provider/providerReplayTurns.ts create mode 100644 apps/server/src/provider/providerTranscriptBootstrap.test.ts create mode 100644 apps/server/src/provider/providerTranscriptBootstrap.ts create mode 100644 apps/server/src/provider/unknown.ts 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..a1ca4b7b040 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -0,0 +1,1427 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { ApprovalRequestId, RuntimeItemId, 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, + buildCursorTurnUsageSnapshot, + buildCursorUsageSnapshot, + 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("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("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 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("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()); + } + }); + }); + + it("normalizes Cursor usage updates into thread usage details", () => { + const snapshot = buildCursorUsageSnapshot( + { + used: 32000, + size: 128000, + }, + { + toolCalls: new Map([ + [ + "tool-1", + { + toolCallId: "tool-1", + itemId: RuntimeItemId.makeUnsafe("cursor-tool-1"), + itemType: "command_execution", + title: "Run command", + status: "completed", + data: {}, + }, + ], + ]), + }, + ); + + expect(snapshot).toEqual({ + usedTokens: 32000, + maxTokens: 128000, + lastUsedTokens: 32000, + toolUses: 1, + }); + }); + + it("fills Cursor max tokens from the current model when usage updates omit size", () => { + const snapshot = buildCursorUsageSnapshot( + { + used: 32000, + }, + undefined, + 200000, + ); + + expect(snapshot).toEqual({ + usedTokens: 32000, + maxTokens: 200000, + lastUsedTokens: 32000, + }); + }); + + it("emits token-only Cursor usage details from prompt completion metadata", () => { + const snapshot = buildCursorTurnUsageSnapshot( + { + usage: { + totalTokens: 1472, + inputTokens: 1024, + cachedReadTokens: 256, + outputTokens: 128, + thoughtTokens: 64, + }, + }, + undefined, + undefined, + 200000, + ); + + expect(snapshot).toEqual({ + usedTokens: 1472, + lastUsedTokens: 1472, + lastInputTokens: 1024, + lastCachedInputTokens: 256, + lastOutputTokens: 128, + lastReasoningOutputTokens: 64, + }); + }); + + it("merges Cursor prompt completion token totals with live context usage", () => { + const snapshot = buildCursorTurnUsageSnapshot( + { + usage: { + totalTokens: 1472, + inputTokens: 1024, + outputTokens: 128, + }, + }, + undefined, + { + usedTokens: 32000, + maxTokens: 128000, + lastUsedTokens: 32000, + }, + 200000, + ); + + expect(snapshot).toEqual({ + usedTokens: 32000, + maxTokens: 128000, + lastUsedTokens: 1472, + lastInputTokens: 1024, + lastOutputTokens: 128, + }); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts new file mode 100644 index 00000000000..233b698f069 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -0,0 +1,2406 @@ +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 { inferModelContextWindowTokens } from "@t3tools/shared/model"; +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, + cursorPermissionKindsForDecision, + cursorPermissionKindsForRuntimeMode, + defaultCursorToolTitle, + describePermissionRequest, + extractCursorStreamText, + extractCursorToolCommand, + extractCursorToolContentText, + extractCursorToolPath, + isFinalCursorToolStatus, + parseCursorPermissionOptions, + permissionOptionKindForRuntimeMode, + requestTypeForCursorTool, + resolveCursorToolTitle, + runtimeItemStatusFromCursorStatus, + selectCursorPermissionOption, + streamKindFromUpdateKind, +} from "./CursorAdapterToolHelpers.ts"; +import { + type CursorUsageSnapshot, + buildCursorTurnUsageSnapshot, + buildCursorUsageSnapshot, +} from "./CursorAdapterUsageParsing.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; + lastUsageSnapshot?: CursorUsageSnapshot; + lastUsageTurnId?: TurnId; + 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 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 } : {}), + }; +} + +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; +} + +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 && !sameCursorTokenSet(choiceCoreTokens, targetCoreTokens)) { + continue; + } + let score = 0; + 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 currentCursorContextWindowTokens = (context: CursorSessionContext) => + inferModelContextWindowTokens( + PROVIDER, + currentCursorModelConfigValue(context) ?? context.session.model, + ); + + 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 usage?: unknown; + } + | { readonly type: "aborted"; readonly reason: string }, + ) => { + if (!context.activeTurn || context.activeTurn.id !== turnId) { + return; + } + + const turnUsageSnapshot = buildCursorTurnUsageSnapshot( + outcome.type === "completed" ? outcome.usage : undefined, + context.activeTurn, + context.lastUsageTurnId === turnId ? context.lastUsageSnapshot : undefined, + currentCursorContextWindowTokens(context), + ); + const finalUsageSnapshot = + turnUsageSnapshot ?? + (context.lastUsageTurnId === turnId && context.lastUsageSnapshot + ? context.lastUsageSnapshot + : undefined); + const finalizedUsageSnapshot = + finalUsageSnapshot !== undefined + ? { + ...finalUsageSnapshot, + ...(context.activeTurn.toolCalls.size > 0 + ? { toolUses: context.activeTurn.toolCalls.size } + : {}), + ...(Date.now() - context.activeTurn.startedAtMs > 0 + ? { durationMs: Math.round(Date.now() - context.activeTurn.startedAtMs) } + : {}), + } + : undefined; + + 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 (finalizedUsageSnapshot) { + context.lastUsageSnapshot = finalizedUsageSnapshot; + context.lastUsageTurnId = turnId; + emit({ + ...baseEvent(context, { turnId }), + type: "thread.token-usage.updated", + payload: { + usage: finalizedUsageSnapshot, + }, + }); + } + + 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; + } + + if (updateKind === "usage_update") { + const usage = buildCursorUsageSnapshot( + update, + context.activeTurn, + currentCursorContextWindowTokens(context), + ); + if (!usage) { + return; + } + context.lastUsageSnapshot = usage; + if (context.activeTurn) { + context.lastUsageTurnId = context.activeTurn.id; + } else { + delete context.lastUsageTurnId; + } + emit({ + ...baseEvent(context, { + ...(context.activeTurn ? { turnId: context.activeTurn.id } : {}), + rawMethod: "session/update", + rawPayload: params, + }), + type: "thread.token-usage.updated", + payload: { + usage, + }, + }); + 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 itemId = RuntimeItemId.makeUnsafe( + asString(params?.agentId) ?? `cursor-task:${randomUUID()}`, + ); + 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) } : {}), + ...(asString(params?.agentId) ? { agentId: asString(params?.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( + asString(params?.agentId) ?? `cursor-task:${randomUUID()}`, + ), + 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, + usage: result, + }); + }) + .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.sync(() => { + const context = sessions.get(threadId); + if (!context) { + throw 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 { + buildCursorUsageSnapshot, + buildCursorTurnUsageSnapshot, +} from "./CursorAdapterUsageParsing.ts"; +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..06eec313295 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterErrors.test.ts @@ -0,0 +1,59 @@ +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 session errors from adapter errors and plain messages", () => { + const missingAdapterError = new ProviderAdapterSessionNotFoundError({ + provider: "cursor", + threadId: "thread-404", + }); + + assert.equal(isMissingCursorSessionError(missingAdapterError), false); + 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..5d44598a10c --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterErrors.ts @@ -0,0 +1,78 @@ +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 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..4cd5e7c1bab --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.test.ts @@ -0,0 +1,136 @@ +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" }, { bad: true }], + }), + { + currentModelId: "gpt-5-mini[]", + availableModels: [{ modelId: "gpt-5-mini[]", name: "GPT-5 mini" }], + }, + ); + }); + + 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..e33ad72c6ca --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.ts @@ -0,0 +1,515 @@ +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 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 = 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 name = asString(entry.name) ?? 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..4ac44b8f9a8 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.test.ts @@ -0,0 +1,125 @@ +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", + }); + }); + + 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..e41c1504f9a --- /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"; +} + +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 options[0]; +} diff --git a/apps/server/src/provider/Layers/CursorAdapterUsageParsing.test.ts b/apps/server/src/provider/Layers/CursorAdapterUsageParsing.test.ts new file mode 100644 index 00000000000..e23d89778fa --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterUsageParsing.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { + buildCursorTurnUsageSnapshot, + buildCursorUsageSnapshot, + cursorToolUseCount, +} from "./CursorAdapterUsageParsing.ts"; + +describe("CursorAdapterUsageParsing", () => { + it("counts tool uses from the active turn", () => { + assert.equal( + cursorToolUseCount({ + toolCalls: new Map([ + ["tool-1", {}], + ["tool-2", {}], + ]), + }), + 2, + ); + assert.equal(cursorToolUseCount({ toolCalls: new Map() }), undefined); + }); + + it("builds a live usage snapshot from context-window updates", () => { + assert.deepEqual( + buildCursorUsageSnapshot( + { + used_tokens: 32000, + token_limit: 128000, + }, + { toolCalls: new Map([["tool-1", {}]]) }, + ), + { + usedTokens: 32000, + maxTokens: 128000, + lastUsedTokens: 32000, + toolUses: 1, + }, + ); + }); + + it("derives completion token details from token_count metadata", () => { + assert.deepEqual( + buildCursorTurnUsageSnapshot( + { + usage: { + token_count: { + input_tokens: 1000, + cached_read_tokens: 200, + cached_write_tokens: 50, + output_tokens: 120, + thought_tokens: 30, + }, + }, + }, + { toolCalls: new Map([["tool-1", {}]]) }, + { + usedTokens: 32000, + maxTokens: 128000, + lastUsedTokens: 32000, + }, + ), + { + usedTokens: 32000, + maxTokens: 128000, + lastUsedTokens: 1400, + lastInputTokens: 1000, + lastCachedInputTokens: 250, + lastOutputTokens: 120, + lastReasoningOutputTokens: 30, + toolUses: 1, + }, + ); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapterUsageParsing.ts b/apps/server/src/provider/Layers/CursorAdapterUsageParsing.ts new file mode 100644 index 00000000000..9a370d67427 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapterUsageParsing.ts @@ -0,0 +1,238 @@ +import type { ProviderRuntimeEvent } from "@t3tools/contracts"; + +import { asObject, asRoundedNonNegativeInt } from "../unknown.ts"; + +export type CursorUsageSnapshot = Extract< + ProviderRuntimeEvent, + { type: "thread.token-usage.updated" } +>["payload"]["usage"]; + +export type TurnUsageLike = Record & { + readonly toolCalls?: ReadonlyMap | Map; +}; + +export function cursorToolUseCount(turn: TurnUsageLike | undefined): number | undefined { + const count = turn?.toolCalls?.size ?? 0; + return count > 0 ? count : undefined; +} + +export function buildCursorUsageSnapshot( + update: Record, + turn: TurnUsageLike | undefined, + inferredMaxTokens?: number, +): + | { + readonly usedTokens: number; + readonly maxTokens?: number; + readonly lastUsedTokens: number; + readonly toolUses?: number; + } + | undefined { + const usedTokens = + asRoundedNonNegativeInt(update.used) ?? + asRoundedNonNegativeInt(update.usedTokens) ?? + asRoundedNonNegativeInt(update.used_tokens) ?? + asRoundedNonNegativeInt(update.promptTokenCount) ?? + asRoundedNonNegativeInt(update.prompt_token_count) ?? + asRoundedNonNegativeInt(update.lastPromptTokenCount) ?? + asRoundedNonNegativeInt(update.last_prompt_token_count); + if (usedTokens === undefined || usedTokens <= 0) { + return undefined; + } + + const maxTokens = + asRoundedNonNegativeInt(update.size) ?? + asRoundedNonNegativeInt(update.maxTokens) ?? + asRoundedNonNegativeInt(update.max_tokens) ?? + asRoundedNonNegativeInt(update.tokenLimit) ?? + asRoundedNonNegativeInt(update.token_limit) ?? + asRoundedNonNegativeInt(update.limit) ?? + inferredMaxTokens; + const toolUses = cursorToolUseCount(turn); + + return { + usedTokens, + ...(maxTokens !== undefined && maxTokens > 0 ? { maxTokens } : {}), + lastUsedTokens: usedTokens, + ...(toolUses !== undefined ? { toolUses } : {}), + }; +} + +type CursorTokenCountTotals = { + readonly totalTokens?: number; + readonly inputTokens?: number; + readonly cachedReadTokens?: number; + readonly cachedWriteTokens?: number; + readonly outputTokens?: number; + readonly reasoningOutputTokens?: number; +}; + +function readCursorTokenCountRecord( + record: Record | undefined, +): Record | undefined { + return asObject(record?.token_count) ?? asObject(record?.tokenCount); +} + +function firstRoundedNonNegativeInt( + record: Record | undefined, + keys: ReadonlyArray, +): number | undefined { + if (!record) { + return undefined; + } + + for (const key of keys) { + const value = asRoundedNonNegativeInt(record[key]); + if (value !== undefined) { + return value; + } + } + + return undefined; +} + +function readCursorTokenCountTotals(value: unknown): CursorTokenCountTotals | undefined { + const record = asObject(value); + const tokenCount = readCursorTokenCountRecord(record); + const inputTokens = firstRoundedNonNegativeInt(tokenCount, ["input_tokens", "inputTokens"]); + const cachedReadTokens = firstRoundedNonNegativeInt(tokenCount, [ + "cached_read_tokens", + "cachedReadTokens", + ]); + const cachedWriteTokens = firstRoundedNonNegativeInt(tokenCount, [ + "cached_write_tokens", + "cachedWriteTokens", + ]); + const outputTokens = firstRoundedNonNegativeInt(tokenCount, ["output_tokens", "outputTokens"]); + const reasoningOutputTokens = firstRoundedNonNegativeInt(tokenCount, [ + "thought_tokens", + "thoughtTokens", + "reasoning_output_tokens", + "reasoningOutputTokens", + ]); + const derivedTotalTokens = + (inputTokens ?? 0) + + (cachedReadTokens ?? 0) + + (cachedWriteTokens ?? 0) + + (outputTokens ?? 0) + + (reasoningOutputTokens ?? 0); + const totalTokens = + firstRoundedNonNegativeInt(tokenCount, ["total_tokens", "totalTokens"]) ?? + (derivedTotalTokens > 0 ? derivedTotalTokens : undefined); + + if ( + totalTokens === undefined && + inputTokens === undefined && + cachedReadTokens === undefined && + cachedWriteTokens === undefined && + outputTokens === undefined && + reasoningOutputTokens === undefined + ) { + return undefined; + } + + return { + ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(inputTokens !== undefined ? { inputTokens } : {}), + ...(cachedReadTokens !== undefined ? { cachedReadTokens } : {}), + ...(cachedWriteTokens !== undefined ? { cachedWriteTokens } : {}), + ...(outputTokens !== undefined ? { outputTokens } : {}), + ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), + }; +} + +export function buildCursorTurnUsageSnapshot( + value: unknown, + turn: TurnUsageLike | undefined, + lastUsageSnapshot: CursorUsageSnapshot | undefined, + inferredMaxTokens?: number, +): CursorUsageSnapshot | undefined { + const record = asObject(value); + const usageRecord = + asObject(record?.usage) ?? + asObject(record?.usageMetadata) ?? + asObject(record?.usage_metadata) ?? + asObject(asObject(record?._meta)?.usage) ?? + asObject(asObject(record?._meta)?.quota) ?? + record; + const tokenCountTotals = readCursorTokenCountTotals(usageRecord); + const contextUsage = + usageRecord === undefined + ? undefined + : buildCursorUsageSnapshot(usageRecord, turn, inferredMaxTokens); + const totalTokens = + firstRoundedNonNegativeInt(usageRecord, ["totalTokens", "total_tokens"]) ?? + tokenCountTotals?.totalTokens; + const inputTokens = + firstRoundedNonNegativeInt(usageRecord, ["inputTokens", "input_tokens"]) ?? + tokenCountTotals?.inputTokens; + const cachedReadTokens = + firstRoundedNonNegativeInt(usageRecord, ["cachedReadTokens", "cached_read_tokens"]) ?? + tokenCountTotals?.cachedReadTokens; + const cachedWriteTokens = + firstRoundedNonNegativeInt(usageRecord, ["cachedWriteTokens", "cached_write_tokens"]) ?? + tokenCountTotals?.cachedWriteTokens; + const outputTokens = + firstRoundedNonNegativeInt(usageRecord, ["outputTokens", "output_tokens"]) ?? + tokenCountTotals?.outputTokens; + const reasoningOutputTokens = + firstRoundedNonNegativeInt(usageRecord, [ + "thoughtTokens", + "thought_tokens", + "reasoningTokens", + "reasoning_tokens", + "reasoningOutputTokens", + "reasoning_output_tokens", + ]) ?? tokenCountTotals?.reasoningOutputTokens; + const cachedInputTokens = + (cachedReadTokens ?? 0) + (cachedWriteTokens ?? 0) > 0 + ? (cachedReadTokens ?? 0) + (cachedWriteTokens ?? 0) + : undefined; + const toolUses = cursorToolUseCount(turn); + const hasDetails = + contextUsage !== undefined || + totalTokens !== undefined || + inputTokens !== undefined || + cachedInputTokens !== undefined || + outputTokens !== undefined || + reasoningOutputTokens !== undefined || + toolUses !== undefined; + + if (!hasDetails) { + return undefined; + } + + const contextUsedTokens = lastUsageSnapshot?.usedTokens ?? contextUsage?.usedTokens; + const usedTokens = contextUsedTokens ?? totalTokens; + const maxTokens = + lastUsageSnapshot?.maxTokens ?? + contextUsage?.maxTokens ?? + (contextUsedTokens !== undefined ? inferredMaxTokens : undefined); + + if (usedTokens === undefined || usedTokens <= 0) { + return undefined; + } + + return { + usedTokens, + ...(maxTokens !== undefined && maxTokens > 0 ? { maxTokens } : {}), + ...(totalTokens !== undefined && totalTokens > 0 + ? { lastUsedTokens: totalTokens } + : contextUsage?.lastUsedTokens !== undefined + ? { lastUsedTokens: contextUsage.lastUsedTokens } + : {}), + ...(inputTokens !== undefined && inputTokens > 0 ? { lastInputTokens: inputTokens } : {}), + ...(cachedInputTokens !== undefined && cachedInputTokens > 0 + ? { lastCachedInputTokens: cachedInputTokens } + : {}), + ...(outputTokens !== undefined && outputTokens > 0 ? { lastOutputTokens: outputTokens } : {}), + ...(reasoningOutputTokens !== undefined && reasoningOutputTokens > 0 + ? { lastReasoningOutputTokens: reasoningOutputTokens } + : {}), + ...(toolUses !== undefined + ? { toolUses } + : contextUsage?.toolUses !== undefined + ? { toolUses: contextUsage.toolUses } + : {}), + }; +} 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..2a5ff10739f --- /dev/null +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -0,0 +1,212 @@ +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-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-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[3]?.cursorMetadata, { + familySlug: "claude-4.6-opus", + familyName: "Claude 4.6 Opus", + fastMode: false, + thinking: true, + maxMode: true, + }); + }); + + 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..c3aae3f853f --- /dev/null +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -0,0 +1,518 @@ +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 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")) { + 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.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/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) ?? []; +} From 3d48b7485ee64b5cfae74cfec93b8af44b1058b7 Mon Sep 17 00:00:00 2001 From: arpan404 Date: Sun, 5 Apr 2026 21:48:47 -0500 Subject: [PATCH 03/16] feat(cursor-server): wire provider service and git generation --- .../OrchestrationEngineHarness.integration.ts | 3 + .../providerService.integration.test.ts | 8 +- .../git/Layers/CursorTextGeneration.test.ts | 207 +++++++++++ .../src/git/Layers/CursorTextGeneration.ts | 331 ++++++++++++++++++ .../src/git/Layers/RoutingTextGeneration.ts | 22 +- .../server/src/git/Services/TextGeneration.ts | 2 +- .../Layers/ProviderAdapterRegistry.test.ts | 23 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../provider/Layers/ProviderRegistry.test.ts | 13 + .../src/provider/Layers/ProviderRegistry.ts | 34 +- .../provider/Layers/ProviderService.test.ts | 24 ++ .../src/provider/Layers/ProviderService.ts | 51 ++- apps/server/src/server.ts | 10 +- 13 files changed, 707 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/git/Layers/CursorTextGeneration.test.ts create mode 100644 apps/server/src/git/Layers/CursorTextGeneration.ts 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/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..c258c86f9c2 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -36,6 +36,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 { @@ -260,6 +261,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 +271,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 +314,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 +362,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 +378,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 +423,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 +442,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 +495,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), ); @@ -823,6 +842,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 +860,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 +894,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), ); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 85fe9fbc326..90877a83fb0 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, @@ -156,6 +165,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory; + const projectionThreadMessageRepository = yield* ProjectionThreadMessageRepository; const runtimeEventPubSub = yield* PubSub.unbounded(); const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => @@ -210,6 +220,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 +238,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 +247,23 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); + const replayTurns = localTranscriptAuthority + ? projectionMessagesToReplayTurns( + yield* projectionThreadMessageRepository.listByThreadId({ + threadId: input.binding.threadId, + }), + ) + : []; const resumed = yield* adapter.startSession({ threadId: input.binding.threadId, provider: input.binding.provider, ...(persistedCwd ? { cwd: persistedCwd } : {}), ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), - ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), + ...(!localTranscriptAuthority && hasResumeCursor + ? { resumeCursor: input.binding.resumeCursor } + : {}), + ...(replayTurns.length > 0 ? { replayTurns } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }); if (resumed.provider !== adapter.provider) { @@ -255,7 +276,7 @@ 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: localTranscriptAuthority ? "rebuild-local-transcript" : "resume-thread", hasResumeCursor: resumed.resumeCursor !== undefined, }); return { adapter, session: resumed } as const; @@ -333,15 +354,29 @@ 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 localTranscriptAuthority = usesLocalTranscriptAuthority(input.provider); + const shouldPreferLocalTranscript = + localTranscriptAuthority && + input.resumeCursor === undefined && + persistedBinding?.provider === input.provider; + const effectiveResumeCursor = shouldPreferLocalTranscript + ? undefined + : (input.resumeCursor ?? + (persistedBinding?.provider === input.provider + ? persistedBinding.resumeCursor + : undefined)); + const replayTurns = shouldPreferLocalTranscript + ? projectionMessagesToReplayTurns( + yield* projectionThreadMessageRepository.listByThreadId({ + threadId, + }), + ) + : []; 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/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), + ); }), ); From af0866daaef02b9fef61f540837fef5216617677 Mon Sep 17 00:00:00 2001 From: arpan404 Date: Sun, 5 Apr 2026 21:50:13 -0500 Subject: [PATCH 04/16] feat(cursor-web): add model selection and traits support --- apps/web/src/components/ChatView.tsx | 1 + .../components/KeybindingsToast.browser.tsx | 1 + .../components/chat/ProviderModelPicker.tsx | 166 ++++++-- apps/web/src/components/chat/TraitsPicker.tsx | 318 ++++++++++++++- .../chat/composerProviderRegistry.tsx | 38 +- .../components/settings/SettingsPanels.tsx | 16 +- apps/web/src/composerDraftStore.ts | 127 +++--- apps/web/src/cursorModelSelector.ts | 361 ++++++++++++++++++ apps/web/src/modelSelection.ts | 44 ++- apps/web/src/session-logic.ts | 4 +- apps/web/src/store.ts | 6 +- 11 files changed, 982 insertions(+), 100 deletions(-) create mode 100644 apps/web/src/cursorModelSelector.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aeab2d083a0..24e4a8127a7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1407,6 +1407,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], ); 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/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.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 061594ad538..622983f000e 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, @@ -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 = @@ -44,13 +54,21 @@ type TraitsPersistence = }; 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); } @@ -73,6 +91,12 @@ function buildNextOptions( if (provider === "codex") { 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; } @@ -138,6 +162,272 @@ 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 ( + family.reasoningEffortOptions.length > 0 || + family.supportsThinkingToggle || + family.supportsFastMode || + family.supportsMaxMode + ); +} + +function readDefaultCursorTraits( + family: CursorSelectorFamily, +): ReturnType { + const defaultModel = pickCursorModelFromTraits({ family, selections: {} }); + return readCursorSelectedTraits({ + family, + model: defaultModel?.slug, + }); +} + +function buildCursorTriggerLabel(input: { + family: CursorSelectorFamily; + model: string | null | undefined; +}): string { + const selectedTraits = readCursorSelectedTraits(input); + const primaryLabel = selectedTraits.reasoningEffort + ? CURSOR_REASONING_LABELS[selectedTraits.reasoningEffort] + : input.family.supportsThinkingToggle + ? `Thinking ${selectedTraits.thinking ? "On" : "Off"}` + : 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: { + threadId: ThreadId; + models: ReadonlyArray; + model: string | null | undefined; +}) { + 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 applySelection = useCallback( + (nextModelSlug: string) => { + const modelSelection = buildProviderModelSelection("cursor", nextModelSlug); + setModelSelection(props.threadId, modelSelection); + setProviderModelOptions(props.threadId, "cursor", undefined, { persistSticky: true }); + setStickyModelSelection(modelSelection); + }, + [props.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: "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)" : ""} + + ))} + +
+ + ); + }; + + return ( + <> + {family.reasoningEffortOptions.length > 0 ? ( + +
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)" : ""} + + ))} + +
+ ) : null} + {renderBinaryFacet("thinking", "Thinking", selectedTraits.thinking)} + {renderBinaryFacet("fastMode", "Fast Mode", selectedTraits.fastMode)} + {renderBinaryFacet("maxMode", "Max Mode", selectedTraits.maxMode)} + + ); +}); + +export const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { + threadId: ThreadId; + models: ReadonlyArray; + model: string | null | undefined; +}) { + 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; @@ -203,7 +493,7 @@ 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 }), ); @@ -221,7 +511,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; } @@ -365,7 +662,18 @@ export const TraitsPicker = memo(function TraitsPicker({ .filter(Boolean) .join(" · "); - const isCodexStyle = provider === "codex"; + const isCodexStyle = provider === "codex" || provider === "cursor"; + + if ( + !hasVisibleTraits({ + effortLevels, + thinkingEnabled, + supportsFastMode: caps.supportsFastMode, + contextWindowOptions, + }) + ) { + return null; + } return ( = { /> ), }, + 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 +211,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.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index d534eefaa47..34afded1fcd 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -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> @@ -1385,7 +1397,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.ts b/apps/web/src/composerDraftStore.ts index 8a93b7b0daa..a70f77d330f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -15,7 +15,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 +28,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 +38,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 +413,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( @@ -492,12 +498,40 @@ function normalizeProviderModelOptions( } : 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 +562,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 +575,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 +619,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 +670,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 +1669,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 +1709,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 +1765,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 +1783,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.ts b/apps/web/src/cursorModelSelector.ts new file mode 100644 index 00000000000..61e2d4d266c --- /dev/null +++ b/apps/web/src/cursorModelSelector.ts @@ -0,0 +1,361 @@ +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 direct = input.models.find((candidate) => candidate.slug === input.model); + if (direct) { + return direct.slug; + } + const families = buildCursorSelectorFamilies(input.models); + const family = findFamilyByModel(families, input.model); + if (!family) { + return null; + } + return ( + pickCursorModelForFamily({ + family, + desired: { + ...(input.options?.reasoningEffort + ? { reasoningEffort: input.options.reasoningEffort } + : {}), + ...(input.options?.fastMode !== undefined ? { fastMode: input.options.fastMode } : {}), + }, + })?.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..2a949b6446f 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -81,9 +81,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), From 76040f29a6fd1edd65528d6c5880387c8370150f Mon Sep 17 00:00:00 2001 From: arpan404 Date: Sun, 5 Apr 2026 21:50:13 -0500 Subject: [PATCH 05/16] test(cursor-web): cover picker, traits, and drafts --- .../chat/ProviderModelPicker.browser.tsx | 81 +++++++ .../chat/composerProviderRegistry.test.tsx | 82 ++++++- apps/web/src/composerDraftStore.test.ts | 224 +++++++++++++++++- apps/web/src/cursorModelSelector.test.ts | 173 ++++++++++++++ 4 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/cursorModelSelector.test.ts 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/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 4dc79832d4f..3348b134ecb 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ServerProviderModel } from "@t3tools/contracts"; -import { getComposerProviderState } from "./composerProviderRegistry"; +import { getComposerProviderState, renderProviderTraitsPicker } from "./composerProviderRegistry"; const CODEX_MODELS: ReadonlyArray = [ { @@ -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/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/cursorModelSelector.test.ts b/apps/web/src/cursorModelSelector.test.ts new file mode 100644 index 00000000000..14723e3006d --- /dev/null +++ b/apps/web/src/cursorModelSelector.test.ts @@ -0,0 +1,173 @@ +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-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, + }, + }, +]; + +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"], + ); + }); +}); From 785dfaefd28646059ca25cd2a9de67e356be96bf Mon Sep 17 00:00:00 2001 From: arpan404 Date: Sun, 5 Apr 2026 23:15:02 -0500 Subject: [PATCH 06/16] fix: recognize all ProviderKind values in toLegacyProvider The toLegacyProvider function only recognized 'codex' and 'claudeAgent', silently mapping 'cursor' (and any future providers) to 'codex'. This caused the provider picker to reset to OpenAI after sending a message with a cursor provider session. Use Schema.is(ProviderKind) to validate all provider kinds, matching the ace reference implementation. --- .gitignore | 1 + apps/server/src/http.ts | 10 +- .../Layers/ProviderSessionDirectory.test.ts | 33 +++++ .../Layers/ProviderSessionDirectory.ts | 10 +- apps/server/src/server.test.ts | 2 +- apps/web/src/components/ChatView.browser.tsx | 138 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 9 ++ apps/web/src/store.ts | 5 +- bun.lock | 1 + 9 files changed, 202 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 6e5f8cc59c9..55852f9133d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ apps/web/src/components/__screenshots__ .vitest-* __screenshots__/ .tanstack +ace \ No newline at end of file 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/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/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/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a727b89ea31..738baf13e8e 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -149,6 +149,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; @@ -2677,6 +2723,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 24e4a8127a7..6732427a6d6 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, @@ -3551,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(); @@ -3560,6 +3568,7 @@ export default function ChatView({ threadId }: ChatViewProps) { lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, + setComposerDraftProviderModelOptions, setStickyComposerModelSelection, providerStatuses, settings, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 2a949b6446f..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 { @@ -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": { From fb55e87ecb5176dc5d2d82b8f58850abf61e70be Mon Sep 17 00:00:00 2001 From: arpan404 Date: Mon, 6 Apr 2026 00:43:49 -0500 Subject: [PATCH 07/16] feat(models): add Claude thinking budgets and normalize Cursor traits --- .../src/provider/Layers/ClaudeAdapter.ts | 35 ++ .../src/provider/Layers/ClaudeProvider.ts | 5 + .../src/provider/Layers/CursorAdapter.test.ts | 139 ++++++++ .../src/provider/Layers/CursorAdapter.ts | 27 +- .../provider/Layers/CursorProvider.test.ts | 109 ++++++ .../src/provider/Layers/CursorProvider.ts | 3 +- .../components/chat/TraitsPicker.browser.tsx | 222 ++++++++++++- apps/web/src/components/chat/TraitsPicker.tsx | 252 ++++++++++---- apps/web/src/composerDraftStore.ts | 8 + apps/web/src/cursorModelSelector.test.ts | 313 ++++++++++++++++++ apps/web/src/cursorModelSelector.ts | 10 +- packages/contracts/src/model.ts | 6 +- packages/shared/src/model.ts | 10 + 13 files changed, 1060 insertions(+), 79 deletions(-) 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 index a1ca4b7b040..0ead32bac6f 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -855,6 +855,145 @@ describe("CursorAdapterLive", () => { }); }); + 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) => { diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 233b698f069..dc26976968a 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -295,6 +295,25 @@ function sameCursorTokenSet(left: ReadonlySet, right: 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; @@ -354,10 +373,16 @@ function resolveCursorModelConfigValue(input: { return choice.value; } const choiceCoreTokens = stripCursorVariantTokens(parsed.identityTokens); - if (targetCoreTokens.size > 0 && !sameCursorTokenSet(choiceCoreTokens, targetCoreTokens)) { + 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; diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 2a5ff10739f..83e78e0de1d 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -62,6 +62,7 @@ describe("CursorProvider", () => { [ "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"), @@ -72,6 +73,7 @@ describe("CursorProvider", () => { [ "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", ], @@ -86,7 +88,23 @@ describe("CursorProvider", () => { 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, @@ -95,6 +113,97 @@ describe("CursorProvider", () => { }); }); + 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("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( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index c3aae3f853f..a4b835c19b4 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -271,7 +271,8 @@ function buildCursorFamilyCapabilities( const defaultReasoningEffort = supportsBaseEffort ? ("medium" as const) - : (CURSOR_REASONING_ORDER.find((value) => discoveredEffortLevels.has(value)) ?? null); + : (CURSOR_REASONING_ORDER.toReversed().find((value) => discoveredEffortLevels.has(value)) ?? + null); return { ...EMPTY_CURSOR_CAPABILITIES, 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 622983f000e..2ddae51797c 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -17,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"; @@ -89,7 +89,10 @@ 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 { @@ -97,7 +100,10 @@ function buildNextOptions( ...patch, } as CursorModelOptions; } - return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions; + return { + ...(modelOptions as ClaudeModelOptions | undefined), + ...patch, + } as ClaudeModelOptions; } function getSelectedTraits( @@ -119,11 +125,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 && @@ -153,6 +171,8 @@ function getSelectedTraits( effort, effortLevels, thinkingEnabled, + thinkingBudget, + thinkingBudgetOptions, fastModeEnabled, contextWindowOptions, contextWindow, @@ -185,7 +205,7 @@ function hasVisibleCursorTraits( return false; } return ( - family.reasoningEffortOptions.length > 0 || + hasSelectableCursorReasoningEffort(family) || family.supportsThinkingToggle || family.supportsFastMode || family.supportsMaxMode @@ -202,20 +222,25 @@ function readDefaultCursorTraits( }); } +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 = selectedTraits.reasoningEffort - ? CURSOR_REASONING_LABELS[selectedTraits.reasoningEffort] - : input.family.supportsThinkingToggle - ? `Thinking ${selectedTraits.thinking ? "On" : "Off"}` - : input.family.supportsFastMode - ? `Fast ${selectedTraits.fastMode ? "On" : "Off"}` - : input.family.supportsMaxMode - ? `Max ${selectedTraits.maxMode ? "On" : "Off"}` - : "Variants"; + 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, @@ -270,7 +295,9 @@ export const CursorTraitsMenuContent = memo(function CursorTraitsMenuContent(pro (nextModelSlug: string) => { const modelSelection = buildProviderModelSelection("cursor", nextModelSlug); setModelSelection(props.threadId, modelSelection); - setProviderModelOptions(props.threadId, "cursor", undefined, { persistSticky: true }); + setProviderModelOptions(props.threadId, "cursor", undefined, { + persistSticky: true, + }); setStickyModelSelection(modelSelection); }, [props.threadId, setModelSelection, setProviderModelOptions, setStickyModelSelection], @@ -299,47 +326,57 @@ export const CursorTraitsMenuContent = memo(function CursorTraitsMenuContent(pro const defaultValue = defaultTraits[key]; return ( - <> - - -
{label}
- { - const nextModel = pickCursorModelFromTraits({ - family, - selections: { - ...selectedTraits, - [key]: value === "on", - }, - }); - if (nextModel) { - applySelection(nextModel.slug); - } - }} - > - {[ - { value: "off", label: "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)" : ""} - - ))} - -
- + +
{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)" : ""} + + ))} + +
); }; - return ( - <> - {family.reasoningEffortOptions.length > 0 ? ( + const sections: Array<{ key: string; element: ReactElement }> = []; + + if (hasSelectableCursorReasoningEffort(family)) { + sections.push({ + key: "effort", + element: (
Effort
- ) : null} - {renderBinaryFacet("thinking", "Thinking", selectedTraits.thinking)} - {renderBinaryFacet("fastMode", "Fast Mode", selectedTraits.fastMode)} - {renderBinaryFacet("maxMode", "Max Mode", selectedTraits.maxMode)} + ), + }); + } + + 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} + + ))} ); }); @@ -457,7 +521,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], ); @@ -466,6 +532,8 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ effort, effortLevels, thinkingEnabled, + thinkingBudget, + thinkingBudgetOptions, fastModeEnabled, contextWindowOptions, contextWindow, @@ -495,7 +563,9 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ } const effortKey = provider === "claudeAgent" ? "effort" : "reasoningEffort"; updateModelOptions( - buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), + buildNextOptions(provider, modelOptions, { + [effortKey]: nextOption.value, + }), ); }, [ @@ -550,19 +620,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} @@ -575,7 +679,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", + }), ); }} > @@ -634,6 +740,8 @@ export const TraitsPicker = memo(function TraitsPicker({ effort, effortLevels, thinkingEnabled, + thinkingBudget, + thinkingBudgetOptions, fastModeEnabled, contextWindowOptions, contextWindow, @@ -644,18 +752,22 @@ 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] : []), ] diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index a70f77d330f..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, @@ -467,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" || @@ -487,11 +493,13 @@ 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 } : {}), diff --git a/apps/web/src/cursorModelSelector.test.ts b/apps/web/src/cursorModelSelector.test.ts index 14723e3006d..19778a79cfd 100644 --- a/apps/web/src/cursorModelSelector.test.ts +++ b/apps/web/src/cursorModelSelector.test.ts @@ -28,6 +28,20 @@ const CURSOR_MODELS: ReadonlyArray = [ 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", @@ -69,6 +83,169 @@ const CURSOR_MODELS: ReadonlyArray = [ }, ]; +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 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]; @@ -169,5 +346,141 @@ describe("cursorModelSelector", () => { }), ["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("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 index 61e2d4d266c..f1cc1ac6cc5 100644 --- a/apps/web/src/cursorModelSelector.ts +++ b/apps/web/src/cursorModelSelector.ts @@ -220,14 +220,16 @@ export function resolveExactCursorModelSelection(input: { 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) { + if (direct && !hasTraitOptions) { return direct.slug; } const families = buildCursorSelectorFamilies(input.models); const family = findFamilyByModel(families, input.model); if (!family) { - return null; + return direct?.slug ?? null; } return ( pickCursorModelForFamily({ @@ -238,7 +240,9 @@ export function resolveExactCursorModelSelection(input: { : {}), ...(input.options?.fastMode !== undefined ? { fastMode: input.options.fastMode } : {}), }, - })?.slug ?? null + })?.slug ?? + direct?.slug ?? + null ); } diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 50d20112990..e8fef765c8d 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -14,8 +14,12 @@ 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), @@ -63,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), }); @@ -107,7 +112,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record 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 } : {}), From dcb893994f4df83e902fa61e5a28df98945d219f Mon Sep 17 00:00:00 2001 From: arpan404 Date: Mon, 6 Apr 2026 00:43:58 -0500 Subject: [PATCH 08/16] fix(timeline): group interleaved work log entries --- .../chat/MessagesTimeline.logic.test.ts | 62 ++++++++++++++++++- .../components/chat/MessagesTimeline.logic.ts | 23 +++---- 2 files changed, 73 insertions(+), 12 deletions(-) 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; } From daf41874d07e99a5b320cd2aa29ef9c133d7effb Mon Sep 17 00:00:00 2001 From: arpan404 Date: Mon, 6 Apr 2026 00:44:06 -0500 Subject: [PATCH 09/16] style(web): format chat view helpers --- apps/web/src/components/ChatView.browser.tsx | 57 ++++++++++++++++--- apps/web/src/components/ChatView.tsx | 8 ++- .../chat/composerProviderRegistry.tsx | 4 +- 3 files changed, 58 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 738baf13e8e..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 { @@ -1059,7 +1089,11 @@ async function measureUserRow(options: { }, ); - return { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion }; + return { + measuredRowHeightPx, + timelineWidthMeasuredPx, + renderedInVirtualizedRegion, + }; } async function mountChatView(options: { @@ -1262,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) { @@ -2023,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; }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6732427a6d6..dbda2147c14 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3713,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); @@ -3732,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/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index c1821b1c5e7..6c3caea388b 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -93,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" } : {}), }; From 434ae0a7e70a50927bf5923dae32b12e7f6f2c5a Mon Sep 17 00:00:00 2001 From: arpan404 Date: Mon, 6 Apr 2026 00:44:16 -0500 Subject: [PATCH 10/16] fix(orchestration): clear running state on aborted turns --- .../Layers/ProviderRuntimeIngestion.test.ts | 67 +++++++++++++++++ .../Layers/ProviderRuntimeIngestion.ts | 75 +++++++++++++++---- 2 files changed, 126 insertions(+), 16 deletions(-) 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)); + } } } From b0a721be23ed5e88c69ca10a8339de4e157a9203 Mon Sep 17 00:00:00 2001 From: arpan404 Date: Mon, 6 Apr 2026 01:05:28 -0500 Subject: [PATCH 11/16] fix(cursor): align traits UI and drop usage parsing --- .../src/provider/Layers/CursorAdapter.test.ts | 104 +------- .../src/provider/Layers/CursorAdapter.ts | 85 ------- .../Layers/CursorAdapterUsageParsing.test.ts | 75 ------ .../Layers/CursorAdapterUsageParsing.ts | 238 ------------------ .../provider/Layers/CursorProvider.test.ts | 44 ++++ .../src/provider/Layers/CursorProvider.ts | 6 +- apps/web/src/components/chat/TraitsPicker.tsx | 62 +++-- .../settings/SettingsPanels.browser.tsx | 164 +++++++++++- .../components/settings/SettingsPanels.tsx | 80 +++--- apps/web/src/cursorModelSelector.test.ts | 107 ++++++++ 10 files changed, 413 insertions(+), 552 deletions(-) delete mode 100644 apps/server/src/provider/Layers/CursorAdapterUsageParsing.test.ts delete mode 100644 apps/server/src/provider/Layers/CursorAdapterUsageParsing.ts diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 0ead32bac6f..c7151eefab8 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1,7 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { ApprovalRequestId, RuntimeItemId, ThreadId } from "@t3tools/contracts"; +import { ApprovalRequestId, ThreadId } from "@t3tools/contracts"; import { Effect, Layer, Stream } from "effect"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -15,8 +15,6 @@ vi.mock("../cursorAcp.ts", async (importOriginal) => { import { CursorAdapterLive, - buildCursorTurnUsageSnapshot, - buildCursorUsageSnapshot, classifyCursorToolItemType, describePermissionRequest, extractCursorStreamText, @@ -1463,104 +1461,4 @@ describe("CursorAdapterLive", () => { } }); }); - - it("normalizes Cursor usage updates into thread usage details", () => { - const snapshot = buildCursorUsageSnapshot( - { - used: 32000, - size: 128000, - }, - { - toolCalls: new Map([ - [ - "tool-1", - { - toolCallId: "tool-1", - itemId: RuntimeItemId.makeUnsafe("cursor-tool-1"), - itemType: "command_execution", - title: "Run command", - status: "completed", - data: {}, - }, - ], - ]), - }, - ); - - expect(snapshot).toEqual({ - usedTokens: 32000, - maxTokens: 128000, - lastUsedTokens: 32000, - toolUses: 1, - }); - }); - - it("fills Cursor max tokens from the current model when usage updates omit size", () => { - const snapshot = buildCursorUsageSnapshot( - { - used: 32000, - }, - undefined, - 200000, - ); - - expect(snapshot).toEqual({ - usedTokens: 32000, - maxTokens: 200000, - lastUsedTokens: 32000, - }); - }); - - it("emits token-only Cursor usage details from prompt completion metadata", () => { - const snapshot = buildCursorTurnUsageSnapshot( - { - usage: { - totalTokens: 1472, - inputTokens: 1024, - cachedReadTokens: 256, - outputTokens: 128, - thoughtTokens: 64, - }, - }, - undefined, - undefined, - 200000, - ); - - expect(snapshot).toEqual({ - usedTokens: 1472, - lastUsedTokens: 1472, - lastInputTokens: 1024, - lastCachedInputTokens: 256, - lastOutputTokens: 128, - lastReasoningOutputTokens: 64, - }); - }); - - it("merges Cursor prompt completion token totals with live context usage", () => { - const snapshot = buildCursorTurnUsageSnapshot( - { - usage: { - totalTokens: 1472, - inputTokens: 1024, - outputTokens: 128, - }, - }, - undefined, - { - usedTokens: 32000, - maxTokens: 128000, - lastUsedTokens: 32000, - }, - 200000, - ); - - expect(snapshot).toEqual({ - usedTokens: 32000, - maxTokens: 128000, - lastUsedTokens: 1472, - lastInputTokens: 1024, - lastOutputTokens: 128, - }); - }); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index dc26976968a..1721e9b3df0 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -19,7 +19,6 @@ import { type RuntimeItemStatus, type UserInputQuestion, } from "@t3tools/contracts"; -import { inferModelContextWindowTokens } from "@t3tools/shared/model"; import { Effect, FileSystem, Layer, PubSub, Stream } from "effect"; import { @@ -83,11 +82,6 @@ import { selectCursorPermissionOption, streamKindFromUpdateKind, } from "./CursorAdapterToolHelpers.ts"; -import { - type CursorUsageSnapshot, - buildCursorTurnUsageSnapshot, - buildCursorUsageSnapshot, -} from "./CursorAdapterUsageParsing.ts"; import { asObject, asTrimmedNonEmptyString as asString } from "../unknown.ts"; const PROVIDER = "cursor" as const; @@ -151,8 +145,6 @@ type CursorSessionContext = { readonly turns: Array; readonly replayTurns: Array; activeTurn: TurnSnapshot | undefined; - lastUsageSnapshot?: CursorUsageSnapshot; - lastUsageTurnId?: TurnId; pendingBootstrapReset: boolean; stopping: boolean; startPromise: Promise | undefined; @@ -542,12 +534,6 @@ export const CursorAdapterLive = Layer.effect( findCursorConfigOption(context.metadata.configOptions, { category: "model", id: "model" }) ?.currentValue ?? context.metadata.models?.currentModelId; - const currentCursorContextWindowTokens = (context: CursorSessionContext) => - inferModelContextWindowTokens( - PROVIDER, - currentCursorModelConfigValue(context) ?? context.session.model, - ); - const availableCursorModeIds = (context: CursorSessionContext) => { const modeOption = findCursorConfigOption(context.metadata.configOptions, { category: "mode", @@ -972,7 +958,6 @@ export const CursorAdapterLive = Layer.effect( readonly type: "completed"; readonly stopReason?: string | null; readonly errorMessage?: string; - readonly usage?: unknown; } | { readonly type: "aborted"; readonly reason: string }, ) => { @@ -980,30 +965,6 @@ export const CursorAdapterLive = Layer.effect( return; } - const turnUsageSnapshot = buildCursorTurnUsageSnapshot( - outcome.type === "completed" ? outcome.usage : undefined, - context.activeTurn, - context.lastUsageTurnId === turnId ? context.lastUsageSnapshot : undefined, - currentCursorContextWindowTokens(context), - ); - const finalUsageSnapshot = - turnUsageSnapshot ?? - (context.lastUsageTurnId === turnId && context.lastUsageSnapshot - ? context.lastUsageSnapshot - : undefined); - const finalizedUsageSnapshot = - finalUsageSnapshot !== undefined - ? { - ...finalUsageSnapshot, - ...(context.activeTurn.toolCalls.size > 0 - ? { toolUses: context.activeTurn.toolCalls.size } - : {}), - ...(Date.now() - context.activeTurn.startedAtMs > 0 - ? { durationMs: Math.round(Date.now() - context.activeTurn.startedAtMs) } - : {}), - } - : undefined; - completeActiveContentItems(context, turnId); context.turns.push(context.activeTurn); context.replayTurns.push({ @@ -1022,18 +983,6 @@ export const CursorAdapterLive = Layer.effect( : {}), }); - if (finalizedUsageSnapshot) { - context.lastUsageSnapshot = finalizedUsageSnapshot; - context.lastUsageTurnId = turnId; - emit({ - ...baseEvent(context, { turnId }), - type: "thread.token-usage.updated", - payload: { - usage: finalizedUsageSnapshot, - }, - }); - } - if (outcome.type === "completed") { emit({ ...baseEvent(context, { turnId }), @@ -1175,35 +1124,6 @@ export const CursorAdapterLive = Layer.effect( return; } - if (updateKind === "usage_update") { - const usage = buildCursorUsageSnapshot( - update, - context.activeTurn, - currentCursorContextWindowTokens(context), - ); - if (!usage) { - return; - } - context.lastUsageSnapshot = usage; - if (context.activeTurn) { - context.lastUsageTurnId = context.activeTurn.id; - } else { - delete context.lastUsageTurnId; - } - emit({ - ...baseEvent(context, { - ...(context.activeTurn ? { turnId: context.activeTurn.id } : {}), - rawMethod: "session/update", - rawPayload: params, - }), - type: "thread.token-usage.updated", - payload: { - usage, - }, - }); - return; - } - const turnId = context.activeTurn?.id; if (!turnId) { return; @@ -1983,7 +1903,6 @@ export const CursorAdapterLive = Layer.effect( settleTurn(context, turnId, { type: "completed", stopReason, - usage: result, }); }) .catch((error) => { @@ -2416,10 +2335,6 @@ export function makeCursorAdapterLive() { return CursorAdapterLive; } -export { - buildCursorUsageSnapshot, - buildCursorTurnUsageSnapshot, -} from "./CursorAdapterUsageParsing.ts"; export { classifyCursorToolItemType, describePermissionRequest, diff --git a/apps/server/src/provider/Layers/CursorAdapterUsageParsing.test.ts b/apps/server/src/provider/Layers/CursorAdapterUsageParsing.test.ts deleted file mode 100644 index e23d89778fa..00000000000 --- a/apps/server/src/provider/Layers/CursorAdapterUsageParsing.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import assert from "node:assert/strict"; -import { describe, it } from "vitest"; - -import { - buildCursorTurnUsageSnapshot, - buildCursorUsageSnapshot, - cursorToolUseCount, -} from "./CursorAdapterUsageParsing.ts"; - -describe("CursorAdapterUsageParsing", () => { - it("counts tool uses from the active turn", () => { - assert.equal( - cursorToolUseCount({ - toolCalls: new Map([ - ["tool-1", {}], - ["tool-2", {}], - ]), - }), - 2, - ); - assert.equal(cursorToolUseCount({ toolCalls: new Map() }), undefined); - }); - - it("builds a live usage snapshot from context-window updates", () => { - assert.deepEqual( - buildCursorUsageSnapshot( - { - used_tokens: 32000, - token_limit: 128000, - }, - { toolCalls: new Map([["tool-1", {}]]) }, - ), - { - usedTokens: 32000, - maxTokens: 128000, - lastUsedTokens: 32000, - toolUses: 1, - }, - ); - }); - - it("derives completion token details from token_count metadata", () => { - assert.deepEqual( - buildCursorTurnUsageSnapshot( - { - usage: { - token_count: { - input_tokens: 1000, - cached_read_tokens: 200, - cached_write_tokens: 50, - output_tokens: 120, - thought_tokens: 30, - }, - }, - }, - { toolCalls: new Map([["tool-1", {}]]) }, - { - usedTokens: 32000, - maxTokens: 128000, - lastUsedTokens: 32000, - }, - ), - { - usedTokens: 32000, - maxTokens: 128000, - lastUsedTokens: 1400, - lastInputTokens: 1000, - lastCachedInputTokens: 250, - lastOutputTokens: 120, - lastReasoningOutputTokens: 30, - toolUses: 1, - }, - ); - }); -}); diff --git a/apps/server/src/provider/Layers/CursorAdapterUsageParsing.ts b/apps/server/src/provider/Layers/CursorAdapterUsageParsing.ts deleted file mode 100644 index 9a370d67427..00000000000 --- a/apps/server/src/provider/Layers/CursorAdapterUsageParsing.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type { ProviderRuntimeEvent } from "@t3tools/contracts"; - -import { asObject, asRoundedNonNegativeInt } from "../unknown.ts"; - -export type CursorUsageSnapshot = Extract< - ProviderRuntimeEvent, - { type: "thread.token-usage.updated" } ->["payload"]["usage"]; - -export type TurnUsageLike = Record & { - readonly toolCalls?: ReadonlyMap | Map; -}; - -export function cursorToolUseCount(turn: TurnUsageLike | undefined): number | undefined { - const count = turn?.toolCalls?.size ?? 0; - return count > 0 ? count : undefined; -} - -export function buildCursorUsageSnapshot( - update: Record, - turn: TurnUsageLike | undefined, - inferredMaxTokens?: number, -): - | { - readonly usedTokens: number; - readonly maxTokens?: number; - readonly lastUsedTokens: number; - readonly toolUses?: number; - } - | undefined { - const usedTokens = - asRoundedNonNegativeInt(update.used) ?? - asRoundedNonNegativeInt(update.usedTokens) ?? - asRoundedNonNegativeInt(update.used_tokens) ?? - asRoundedNonNegativeInt(update.promptTokenCount) ?? - asRoundedNonNegativeInt(update.prompt_token_count) ?? - asRoundedNonNegativeInt(update.lastPromptTokenCount) ?? - asRoundedNonNegativeInt(update.last_prompt_token_count); - if (usedTokens === undefined || usedTokens <= 0) { - return undefined; - } - - const maxTokens = - asRoundedNonNegativeInt(update.size) ?? - asRoundedNonNegativeInt(update.maxTokens) ?? - asRoundedNonNegativeInt(update.max_tokens) ?? - asRoundedNonNegativeInt(update.tokenLimit) ?? - asRoundedNonNegativeInt(update.token_limit) ?? - asRoundedNonNegativeInt(update.limit) ?? - inferredMaxTokens; - const toolUses = cursorToolUseCount(turn); - - return { - usedTokens, - ...(maxTokens !== undefined && maxTokens > 0 ? { maxTokens } : {}), - lastUsedTokens: usedTokens, - ...(toolUses !== undefined ? { toolUses } : {}), - }; -} - -type CursorTokenCountTotals = { - readonly totalTokens?: number; - readonly inputTokens?: number; - readonly cachedReadTokens?: number; - readonly cachedWriteTokens?: number; - readonly outputTokens?: number; - readonly reasoningOutputTokens?: number; -}; - -function readCursorTokenCountRecord( - record: Record | undefined, -): Record | undefined { - return asObject(record?.token_count) ?? asObject(record?.tokenCount); -} - -function firstRoundedNonNegativeInt( - record: Record | undefined, - keys: ReadonlyArray, -): number | undefined { - if (!record) { - return undefined; - } - - for (const key of keys) { - const value = asRoundedNonNegativeInt(record[key]); - if (value !== undefined) { - return value; - } - } - - return undefined; -} - -function readCursorTokenCountTotals(value: unknown): CursorTokenCountTotals | undefined { - const record = asObject(value); - const tokenCount = readCursorTokenCountRecord(record); - const inputTokens = firstRoundedNonNegativeInt(tokenCount, ["input_tokens", "inputTokens"]); - const cachedReadTokens = firstRoundedNonNegativeInt(tokenCount, [ - "cached_read_tokens", - "cachedReadTokens", - ]); - const cachedWriteTokens = firstRoundedNonNegativeInt(tokenCount, [ - "cached_write_tokens", - "cachedWriteTokens", - ]); - const outputTokens = firstRoundedNonNegativeInt(tokenCount, ["output_tokens", "outputTokens"]); - const reasoningOutputTokens = firstRoundedNonNegativeInt(tokenCount, [ - "thought_tokens", - "thoughtTokens", - "reasoning_output_tokens", - "reasoningOutputTokens", - ]); - const derivedTotalTokens = - (inputTokens ?? 0) + - (cachedReadTokens ?? 0) + - (cachedWriteTokens ?? 0) + - (outputTokens ?? 0) + - (reasoningOutputTokens ?? 0); - const totalTokens = - firstRoundedNonNegativeInt(tokenCount, ["total_tokens", "totalTokens"]) ?? - (derivedTotalTokens > 0 ? derivedTotalTokens : undefined); - - if ( - totalTokens === undefined && - inputTokens === undefined && - cachedReadTokens === undefined && - cachedWriteTokens === undefined && - outputTokens === undefined && - reasoningOutputTokens === undefined - ) { - return undefined; - } - - return { - ...(totalTokens !== undefined ? { totalTokens } : {}), - ...(inputTokens !== undefined ? { inputTokens } : {}), - ...(cachedReadTokens !== undefined ? { cachedReadTokens } : {}), - ...(cachedWriteTokens !== undefined ? { cachedWriteTokens } : {}), - ...(outputTokens !== undefined ? { outputTokens } : {}), - ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), - }; -} - -export function buildCursorTurnUsageSnapshot( - value: unknown, - turn: TurnUsageLike | undefined, - lastUsageSnapshot: CursorUsageSnapshot | undefined, - inferredMaxTokens?: number, -): CursorUsageSnapshot | undefined { - const record = asObject(value); - const usageRecord = - asObject(record?.usage) ?? - asObject(record?.usageMetadata) ?? - asObject(record?.usage_metadata) ?? - asObject(asObject(record?._meta)?.usage) ?? - asObject(asObject(record?._meta)?.quota) ?? - record; - const tokenCountTotals = readCursorTokenCountTotals(usageRecord); - const contextUsage = - usageRecord === undefined - ? undefined - : buildCursorUsageSnapshot(usageRecord, turn, inferredMaxTokens); - const totalTokens = - firstRoundedNonNegativeInt(usageRecord, ["totalTokens", "total_tokens"]) ?? - tokenCountTotals?.totalTokens; - const inputTokens = - firstRoundedNonNegativeInt(usageRecord, ["inputTokens", "input_tokens"]) ?? - tokenCountTotals?.inputTokens; - const cachedReadTokens = - firstRoundedNonNegativeInt(usageRecord, ["cachedReadTokens", "cached_read_tokens"]) ?? - tokenCountTotals?.cachedReadTokens; - const cachedWriteTokens = - firstRoundedNonNegativeInt(usageRecord, ["cachedWriteTokens", "cached_write_tokens"]) ?? - tokenCountTotals?.cachedWriteTokens; - const outputTokens = - firstRoundedNonNegativeInt(usageRecord, ["outputTokens", "output_tokens"]) ?? - tokenCountTotals?.outputTokens; - const reasoningOutputTokens = - firstRoundedNonNegativeInt(usageRecord, [ - "thoughtTokens", - "thought_tokens", - "reasoningTokens", - "reasoning_tokens", - "reasoningOutputTokens", - "reasoning_output_tokens", - ]) ?? tokenCountTotals?.reasoningOutputTokens; - const cachedInputTokens = - (cachedReadTokens ?? 0) + (cachedWriteTokens ?? 0) > 0 - ? (cachedReadTokens ?? 0) + (cachedWriteTokens ?? 0) - : undefined; - const toolUses = cursorToolUseCount(turn); - const hasDetails = - contextUsage !== undefined || - totalTokens !== undefined || - inputTokens !== undefined || - cachedInputTokens !== undefined || - outputTokens !== undefined || - reasoningOutputTokens !== undefined || - toolUses !== undefined; - - if (!hasDetails) { - return undefined; - } - - const contextUsedTokens = lastUsageSnapshot?.usedTokens ?? contextUsage?.usedTokens; - const usedTokens = contextUsedTokens ?? totalTokens; - const maxTokens = - lastUsageSnapshot?.maxTokens ?? - contextUsage?.maxTokens ?? - (contextUsedTokens !== undefined ? inferredMaxTokens : undefined); - - if (usedTokens === undefined || usedTokens <= 0) { - return undefined; - } - - return { - usedTokens, - ...(maxTokens !== undefined && maxTokens > 0 ? { maxTokens } : {}), - ...(totalTokens !== undefined && totalTokens > 0 - ? { lastUsedTokens: totalTokens } - : contextUsage?.lastUsedTokens !== undefined - ? { lastUsedTokens: contextUsage.lastUsedTokens } - : {}), - ...(inputTokens !== undefined && inputTokens > 0 ? { lastInputTokens: inputTokens } : {}), - ...(cachedInputTokens !== undefined && cachedInputTokens > 0 - ? { lastCachedInputTokens: cachedInputTokens } - : {}), - ...(outputTokens !== undefined && outputTokens > 0 ? { lastOutputTokens: outputTokens } : {}), - ...(reasoningOutputTokens !== undefined && reasoningOutputTokens > 0 - ? { lastReasoningOutputTokens: reasoningOutputTokens } - : {}), - ...(toolUses !== undefined - ? { toolUses } - : contextUsage?.toolUses !== undefined - ? { toolUses: contextUsage.toolUses } - : {}), - }; -} diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 83e78e0de1d..d019ec7ba2d 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -135,6 +135,50 @@ describe("CursorProvider", () => { }); }); + 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( [ diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index a4b835c19b4..57423ecf47b 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -159,6 +159,10 @@ type ParsedCursorVariant = { 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[] = []; @@ -228,7 +232,7 @@ function parseCursorVariant(model: ServerProviderModel): ParsedCursorVariant { break; } - if (familySlug.endsWith("-max")) { + if (familySlug.endsWith("-max") && !shouldTreatCursorMaxAsFamily(familySlug, familyName)) { maxMode = true; familySlug = familySlug.slice(0, -"-max".length); familyName = familyName.replace(/\s+max$/i, "").trim(); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 2ddae51797c..25e5c2cd561 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -53,6 +53,16 @@ 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", @@ -281,26 +291,36 @@ export function shouldRenderTraitsPicker(input: { }); } -export const CursorTraitsMenuContent = memo(function CursorTraitsMenuContent(props: { - threadId: ThreadId; - models: ReadonlyArray; - model: string | null | undefined; -}) { +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(props.threadId, modelSelection); - setProviderModelOptions(props.threadId, "cursor", undefined, { + setModelSelection(threadId, modelSelection); + setProviderModelOptions(threadId, "cursor", undefined, { persistSticky: true, }); setStickyModelSelection(modelSelection); }, - [props.threadId, setModelSelection, setProviderModelOptions, setStickyModelSelection], + [onModelChange, threadId, setModelSelection, setProviderModelOptions, setStickyModelSelection], ); if (!family) { @@ -443,11 +463,14 @@ export const CursorTraitsMenuContent = memo(function CursorTraitsMenuContent(pro ); }); -export const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { - threadId: ThreadId; - models: ReadonlyArray; - model: string | null | undefined; -}) { +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); @@ -471,8 +494,11 @@ export const CursorTraitsPicker = memo(function CursorTraitsPicker(props: { render={
); 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 34afded1fcd..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"; @@ -599,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, @@ -1048,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, + ), + }); + }} + /> + )} } /> diff --git a/apps/web/src/cursorModelSelector.test.ts b/apps/web/src/cursorModelSelector.test.ts index 19778a79cfd..ffed135cbf7 100644 --- a/apps/web/src/cursorModelSelector.test.ts +++ b/apps/web/src/cursorModelSelector.test.ts @@ -217,6 +217,79 @@ const HIGH_ONLY_CURSOR_MODELS: ReadonlyArray = [ }, ]; +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", @@ -444,6 +517,40 @@ describe("cursorModelSelector", () => { ); }); + 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]; From a5f465752f9df4573063585ef7955ec61316a6c7 Mon Sep 17 00:00:00 2001 From: arpan404 Date: Mon, 6 Apr 2026 01:40:31 -0500 Subject: [PATCH 12/16] fix: Fixed the composer 2 naming issue --- .gitignore | 3 +- .../CursorAdapterSessionMetadata.test.ts | 33 ++++++++++++++++++- .../Layers/CursorAdapterSessionMetadata.ts | 25 ++++++++++++-- 3 files changed, 56 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 55852f9133d..e137de24a61 100644 --- a/.gitignore +++ b/.gitignore @@ -20,5 +20,4 @@ apps/web/playwright-report apps/web/src/components/__screenshots__ .vitest-* __screenshots__/ -.tanstack -ace \ No newline at end of file +.tanstack \ No newline at end of file diff --git a/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.test.ts b/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.test.ts index 4cd5e7c1bab..d3768be2a6b 100644 --- a/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.test.ts @@ -60,7 +60,10 @@ describe("CursorAdapterSessionMetadata", () => { assert.deepEqual( parseCursorSessionModelState({ currentModelId: "gpt-5-mini[]", - availableModels: [{ modelId: "gpt-5-mini[]", name: "GPT-5 mini" }, { bad: true }], + availableModels: [ + { modelId: "gpt-5-mini[]", name: "GPT-5 mini (current, default)" }, + { bad: true }, + ], }), { currentModelId: "gpt-5-mini[]", @@ -69,6 +72,34 @@ describe("CursorAdapterSessionMetadata", () => { ); }); + 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([ { diff --git a/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.ts b/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.ts index e33ad72c6ca..4e7c8223de2 100644 --- a/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.ts +++ b/apps/server/src/provider/Layers/CursorAdapterSessionMetadata.ts @@ -107,6 +107,26 @@ export const EMPTY_CURSOR_SESSION_METADATA: CursorSessionMetadata = { 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 { @@ -213,7 +233,7 @@ export function parseCursorSessionModelState(value: unknown): CursorSessionModel const normalized: { modelId: string; name?: string } = { modelId }; const name = asString(entry.name); if (name) { - normalized.name = name; + normalized.name = sanitizeCursorDisplayLabel(name); } availableModels.push(normalized); } @@ -242,7 +262,8 @@ function parseCursorConfigOptionValues( continue; } const optionValue = asString(entry.value); - const name = asString(entry.name) ?? optionValue; + const rawName = asString(entry.name); + const name = rawName ? sanitizeCursorDisplayLabel(rawName) : optionValue; if (!optionValue || !name) { continue; } From 5b084c0ecc76432b42945e60e3d8dbb86e17bd20 Mon Sep 17 00:00:00 2001 From: arpan404 Date: Mon, 6 Apr 2026 01:57:39 -0500 Subject: [PATCH 13/16] fix(cursor): harden task and error handling --- .../src/provider/Layers/CursorAdapter.test.ts | 70 +++++++++++++++++++ .../src/provider/Layers/CursorAdapter.ts | 25 ++----- .../Layers/CursorAdapterErrors.test.ts | 12 +++- .../provider/Layers/CursorAdapterErrors.ts | 4 ++ .../Layers/CursorAdapterToolHelpers.ts | 2 +- 5 files changed, 90 insertions(+), 23 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index c7151eefab8..61183582ae5 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1216,6 +1216,76 @@ describe("CursorAdapterLive", () => { }); }); + 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 }>(); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 1721e9b3df0..cbc5a60bbc5 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -65,6 +65,7 @@ import { import { buildCursorToolData, classifyCursorToolItemType, + cursorToolLookupInput, cursorPermissionKindsForDecision, cursorPermissionKindsForRuntimeMode, defaultCursorToolTitle, @@ -201,18 +202,6 @@ function setContentItemState( turn.reasoningItem = state; } -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 } : {}), - }; -} - function requestIdFromApprovalRequest(requestId: ApprovalRequestId) { return RuntimeRequestId.makeUnsafe(requestId); } @@ -1502,9 +1491,9 @@ export const CursorAdapterLive = Layer.effect( }), ); const prompt = asString(params?.prompt); - const itemId = RuntimeItemId.makeUnsafe( - asString(params?.agentId) ?? `cursor-task:${randomUUID()}`, - ); + const agentId = asString(params?.agentId); + const taskIdentity = agentId ?? `cursor-task:${randomUUID()}`; + const itemId = RuntimeItemId.makeUnsafe(taskIdentity); emit({ ...baseEvent(context, { ...(turnId ? { turnId } : {}), @@ -1522,7 +1511,7 @@ export const CursorAdapterLive = Layer.effect( ...(subagentType ? { subagentType } : {}), ...(prompt ? { prompt } : {}), ...(asString(params?.model) ? { model: asString(params?.model) } : {}), - ...(asString(params?.agentId) ? { agentId: asString(params?.agentId) } : {}), + ...(agentId ? { agentId } : {}), ...(typeof params?.durationMs === "number" ? { durationMs: params.durationMs } : {}), }, }, @@ -1535,9 +1524,7 @@ export const CursorAdapterLive = Layer.effect( }), type: "task.completed", payload: { - taskId: RuntimeTaskId.makeUnsafe( - asString(params?.agentId) ?? `cursor-task:${randomUUID()}`, - ), + taskId: RuntimeTaskId.makeUnsafe(taskIdentity), status: "completed", ...(asString(params?.description) ? { summary: asString(params?.description) } : {}), ...(params && "durationMs" in params diff --git a/apps/server/src/provider/Layers/CursorAdapterErrors.test.ts b/apps/server/src/provider/Layers/CursorAdapterErrors.test.ts index 06eec313295..b31777369cc 100644 --- a/apps/server/src/provider/Layers/CursorAdapterErrors.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapterErrors.test.ts @@ -42,13 +42,19 @@ describe("CursorAdapterErrors", () => { ); }); - it("detects missing session errors from adapter errors and plain messages", () => { - const missingAdapterError = new ProviderAdapterSessionNotFoundError({ + 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(missingAdapterError), false); + assert.equal(isMissingCursorSessionError(missingAdapterThreadError), false); + assert.equal(isMissingCursorSessionError(missingRequestError), true); assert.equal( isMissingCursorSessionError(new Error("Request failed: Session not found: abc123")), true, diff --git a/apps/server/src/provider/Layers/CursorAdapterErrors.ts b/apps/server/src/provider/Layers/CursorAdapterErrors.ts index 5d44598a10c..d31a7989fb0 100644 --- a/apps/server/src/provider/Layers/CursorAdapterErrors.ts +++ b/apps/server/src/provider/Layers/CursorAdapterErrors.ts @@ -69,6 +69,10 @@ export function describeCursorAdapterCause(cause: unknown): string { } 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") || diff --git a/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts index e41c1504f9a..966be3ce808 100644 --- a/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts +++ b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts @@ -183,7 +183,7 @@ export function isFinalCursorToolStatus(status: RuntimeItemStatus): boolean { return status !== "inProgress"; } -function cursorToolLookupInput(input: { +export function cursorToolLookupInput(input: { readonly kind?: string | undefined; readonly title?: string | undefined; readonly subagentType?: string | undefined; From bcecb571d43428836acc53c2470fecf124755037 Mon Sep 17 00:00:00 2001 From: arpan404 Date: Mon, 6 Apr 2026 02:13:40 -0500 Subject: [PATCH 14/16] fix(cursor): preserve resume state during recovery --- .../provider/Layers/ProviderService.test.ts | 227 ++++++++++++++++++ .../src/provider/Layers/ProviderService.ts | 76 +++--- 2 files changed, 274 insertions(+), 29 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index c258c86f9c2..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, @@ -45,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); @@ -233,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, @@ -768,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; @@ -930,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 90877a83fb0..5fef7fc59a1 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -150,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, ) { @@ -168,6 +178,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( 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) => @@ -247,22 +275,22 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedCwd = readPersistedCwd(input.binding.runtimePayload); const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - const replayTurns = localTranscriptAuthority - ? projectionMessagesToReplayTurns( - yield* projectionThreadMessageRepository.listByThreadId({ - threadId: input.binding.threadId, - }), - ) - : []; + 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 } : {}), - ...(!localTranscriptAuthority && hasResumeCursor - ? { resumeCursor: input.binding.resumeCursor } - : {}), + ...(persistedResumeCursor !== undefined ? { resumeCursor: persistedResumeCursor } : {}), ...(replayTurns.length > 0 ? { replayTurns } : {}), runtimeMode: input.binding.runtimeMode ?? "full-access", }); @@ -276,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: localTranscriptAuthority ? "rebuild-local-transcript" : "resume-thread", + strategy: + persistedResumeCursor !== undefined ? "resume-thread" : "rebuild-local-transcript", hasResumeCursor: resumed.resumeCursor !== undefined, }); return { adapter, session: resumed } as const; @@ -354,24 +383,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ); } const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); - const localTranscriptAuthority = usesLocalTranscriptAuthority(input.provider); - const shouldPreferLocalTranscript = - localTranscriptAuthority && - input.resumeCursor === undefined && - persistedBinding?.provider === input.provider; - const effectiveResumeCursor = shouldPreferLocalTranscript - ? undefined - : (input.resumeCursor ?? - (persistedBinding?.provider === input.provider - ? persistedBinding.resumeCursor - : undefined)); - const replayTurns = shouldPreferLocalTranscript - ? projectionMessagesToReplayTurns( - yield* projectionThreadMessageRepository.listByThreadId({ - threadId, - }), - ) - : []; + 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, From 4cf2302fa1dfd53e6850f066ea765d5136ef0bcc Mon Sep 17 00:00:00 2001 From: arpan404 Date: Mon, 6 Apr 2026 02:25:07 -0500 Subject: [PATCH 15/16] fix(cursor): type readThread missing-session failures --- .../src/provider/Layers/CursorAdapter.test.ts | 21 +++++++++++++++++++ .../src/provider/Layers/CursorAdapter.ts | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 61183582ae5..b5324cf0c45 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -442,6 +442,27 @@ describe("CursorAdapterLive", () => { }); }); + 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({ diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index cbc5a60bbc5..06808bd230b 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -2174,10 +2174,10 @@ export const CursorAdapterLive = Layer.effect( Effect.sync(() => sessions.has(threadId)); const readThread: CursorAdapterShape["readThread"] = (threadId) => - Effect.sync(() => { + Effect.gen(function* () { const context = sessions.get(threadId); if (!context) { - throw new ProviderAdapterSessionNotFoundError({ + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId, }); From 695b4258b4aae2c2afb751f1be8d7f9f2af383f3 Mon Sep 17 00:00:00 2001 From: arpan404 Date: Mon, 6 Apr 2026 02:32:14 -0500 Subject: [PATCH 16/16] fix(cursor): avoid fallback approving cursor permission when reject/cancel only --- .../src/provider/Layers/CursorAdapter.test.ts | 107 ++++++++++++++++++ .../Layers/CursorAdapterToolHelpers.test.ts | 7 ++ .../Layers/CursorAdapterToolHelpers.ts | 2 +- 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index b5324cf0c45..7d8fdfb67fd 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1123,6 +1123,113 @@ describe("CursorAdapterLive", () => { }); }); + 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 }>(); diff --git a/apps/server/src/provider/Layers/CursorAdapterToolHelpers.test.ts b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.test.ts index 4ac44b8f9a8..e55a87cb778 100644 --- a/apps/server/src/provider/Layers/CursorAdapterToolHelpers.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.test.ts @@ -116,6 +116,13 @@ describe("CursorAdapterToolHelpers", () => { 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", () => { diff --git a/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts index 966be3ce808..1a2a2090615 100644 --- a/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts +++ b/apps/server/src/provider/Layers/CursorAdapterToolHelpers.ts @@ -529,5 +529,5 @@ export function selectCursorPermissionOption( return matched; } } - return options[0]; + return preferredKinds.every((kind) => kind.startsWith("allow_")) ? options[0] : undefined; }