diff --git a/apps/server/scripts/acp-mock-agent.mjs b/apps/server/scripts/acp-mock-agent.mjs new file mode 100644 index 0000000000..a261ed692d --- /dev/null +++ b/apps/server/scripts/acp-mock-agent.mjs @@ -0,0 +1,279 @@ +#!/usr/bin/env node +/** + * Minimal NDJSON JSON-RPC "agent" for ACP client tests. + * Reads stdin lines; writes responses/notifications to stdout. + */ +import * as readline from "node:readline"; +import { appendFileSync } from "node:fs"; + +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; +const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; +const sessionId = "mock-session-1"; +let currentModeId = "ask"; +let nextRequestId = 1; +const availableModes = [ + { + id: "ask", + name: "Ask", + description: "Request permission before making any changes", + }, + { + id: "architect", + name: "Architect", + description: "Design and plan software systems without implementation", + }, + { + id: "code", + name: "Code", + description: "Write and modify code with full tool access", + }, +]; +const pendingPermissionRequests = new Map(); + +function send(obj) { + process.stdout.write(`${JSON.stringify(obj)}\n`); +} + +function modeState() { + return { + currentModeId, + availableModes, + }; +} + +function sendSessionUpdate(update, session = sessionId) { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: session, + update, + }, + }); +} + +rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let msg; + try { + msg = JSON.parse(trimmed); + } catch { + return; + } + if (!msg || typeof msg !== "object") return; + if (requestLogPath) { + appendFileSync(requestLogPath, `${JSON.stringify(msg)}\n`, "utf8"); + } + + const id = msg.id; + const method = msg.method; + + if (method === undefined && id !== undefined && pendingPermissionRequests.has(id)) { + const pending = pendingPermissionRequests.get(id); + pendingPermissionRequests.delete(id); + sendSessionUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId: pending.toolCallId, + title: "Terminal", + kind: "execute", + status: "completed", + rawOutput: { + exitCode: 0, + stdout: '{ "name": "t3" }', + stderr: "", + }, + }, + pending.sessionId, + ); + sendSessionUpdate( + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from mock" }, + }, + pending.sessionId, + ); + send({ + jsonrpc: "2.0", + id: pending.promptRequestId, + result: { stopReason: "end_turn" }, + }); + return; + } + + if (method === "initialize" && id !== undefined) { + send({ + jsonrpc: "2.0", + id, + result: { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + }, + }); + return; + } + + if (method === "authenticate" && id !== undefined) { + send({ jsonrpc: "2.0", id, result: { authenticated: true } }); + return; + } + + if (method === "session/new" && id !== undefined) { + send({ + jsonrpc: "2.0", + id, + result: { + sessionId, + modes: modeState(), + }, + }); + return; + } + + if (method === "session/load" && id !== undefined) { + const requestedSessionId = msg.params?.sessionId ?? sessionId; + sendSessionUpdate( + { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "replay" }, + }, + requestedSessionId, + ); + send({ + jsonrpc: "2.0", + id, + result: { + modes: modeState(), + }, + }); + return; + } + + if (method === "session/prompt" && id !== undefined) { + const requestedSessionId = msg.params?.sessionId ?? sessionId; + if (emitToolCalls) { + const toolCallId = "tool-call-1"; + const permissionRequestId = nextRequestId++; + sendSessionUpdate( + { + sessionUpdate: "tool_call", + toolCallId, + title: "Terminal", + kind: "execute", + status: "pending", + rawInput: { + command: ["cat", "server/package.json"], + }, + }, + requestedSessionId, + ); + sendSessionUpdate( + { + sessionUpdate: "tool_call_update", + toolCallId, + status: "in_progress", + }, + requestedSessionId, + ); + pendingPermissionRequests.set(permissionRequestId, { + promptRequestId: id, + sessionId: requestedSessionId, + toolCallId, + }); + send({ + jsonrpc: "2.0", + id: permissionRequestId, + method: "session/request_permission", + params: { + sessionId: requestedSessionId, + toolCall: { + toolCallId, + title: "`cat server/package.json`", + kind: "execute", + status: "pending", + content: [ + { + type: "content", + content: { + type: "text", + text: "Not in allowlist: cat server/package.json", + }, + }, + ], + }, + options: [ + { optionId: "allow-once", name: "Allow once", kind: "allow_once" }, + { optionId: "allow-always", name: "Allow always", kind: "allow_always" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + }, + }); + return; + } + sendSessionUpdate( + { + sessionUpdate: "plan", + explanation: `Mock plan while in ${currentModeId}`, + entries: [ + { + content: "Inspect mock ACP state", + priority: "high", + status: "completed", + }, + { + content: "Implement the requested change", + priority: "high", + status: "in_progress", + }, + ], + }, + requestedSessionId, + ); + sendSessionUpdate( + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "hello from mock" }, + }, + requestedSessionId, + ); + send({ + jsonrpc: "2.0", + id, + result: { stopReason: "end_turn" }, + }); + return; + } + + if ((method === "session/set_mode" || method === "session/mode/set") && id !== undefined) { + const nextModeId = + typeof msg.params?.modeId === "string" + ? msg.params.modeId + : typeof msg.params?.mode === "string" + ? msg.params.mode + : undefined; + if (typeof nextModeId === "string" && nextModeId.trim()) { + currentModeId = nextModeId.trim(); + sendSessionUpdate({ + sessionUpdate: "current_mode_update", + currentModeId, + }); + } + send({ jsonrpc: "2.0", id, result: null }); + return; + } + + if (method === "session/cancel" && id !== undefined) { + send({ jsonrpc: "2.0", id, result: null }); + return; + } + + if (id !== undefined) { + send({ + jsonrpc: "2.0", + id, + error: { code: -32601, message: `Unhandled method: ${String(method)}` }, + }); + } +}); diff --git a/apps/server/scripts/cursor-acp-model-selection-probe.ts b/apps/server/scripts/cursor-acp-model-selection-probe.ts new file mode 100644 index 0000000000..1b06902eec --- /dev/null +++ b/apps/server/scripts/cursor-acp-model-selection-probe.ts @@ -0,0 +1,132 @@ +import * as os from "node:os"; +import * as path from "node:path"; +import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Layer } from "effect"; + +import { ThreadId } from "@t3tools/contracts"; +import { resolveCursorDispatchModel } from "@t3tools/shared/model"; + +import { ServerConfig } from "../src/config.ts"; +import { CursorAdapter } from "../src/provider/Services/CursorAdapter.ts"; +import { makeCursorAdapterLive } from "../src/provider/Layers/CursorAdapter.ts"; + +const scriptDir = import.meta.dir; +const mockAgentPath = path.join(scriptDir, "acp-mock-agent.mjs"); + +function parseArgs(argv: string[]) { + const args = new Map(); + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token?.startsWith("--")) continue; + const key = token.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + args.set(key, true); + continue; + } + args.set(key, next); + index += 1; + } + return args; +} + +async function makeProbeWrapper(requestLogPath: string, argvLogPath: string) { + const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-script-")); + const wrapperPath = path.join(dir, "fake-agent.sh"); + const script = `#!/bin/sh +printf '%s\n' "$@" > ${JSON.stringify(argvLogPath)} +export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await writeFile(wrapperPath, script, "utf8"); + await chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function readJsonLines(filePath: string) { + const raw = await readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const cliArgs = parseArgs(process.argv.slice(2)); +const model = + typeof cliArgs.get("model") === "string" ? String(cliArgs.get("model")) : "composer-2"; +const fastMode = cliArgs.get("fast") === true; + +const layer = makeCursorAdapterLive().pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), +); + +const program = Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => makeProbeWrapper(requestLogPath, argvLogPath)); + const threadId = ThreadId.makeUnsafe("cursor-acp-model-selection-probe"); + const modelOptions = fastMode ? { cursor: { fastMode: true as const } } : undefined; + const dispatchedModel = resolveCursorDispatchModel(model, modelOptions?.cursor); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + model, + ...(modelOptions ? { modelOptions } : {}), + providerOptions: { + cursor: { + binaryPath: wrapperPath, + }, + }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "probe model selection", + attachments: [], + }); + yield* adapter.stopSession(threadId); + + const argv = (yield* Effect.promise(() => readFile(argvLogPath, "utf8"))) + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + const promptParams = + promptRequest?.params && + typeof promptRequest.params === "object" && + !Array.isArray(promptRequest.params) + ? promptRequest.params + : null; + + return { + input: { + model, + fastMode, + }, + dispatchedModel, + spawnedArgv: argv, + acpMethods: requests + .map((entry) => entry.method) + .filter((method): method is string => typeof method === "string"), + promptParams, + promptCarriesModel: Boolean( + promptParams && Object.prototype.hasOwnProperty.call(promptParams, "model"), + ), + conclusion: + "Cursor model selection is decided before ACP initialize via CLI argv. The ACP session/prompt payload does not carry a model field.", + }; +}).pipe(Effect.provide(layer)); + +const result = await Effect.runPromise(program); +process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 5a7084a61b..a33d35cd33 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -110,7 +110,9 @@ describe("ProviderCommandReactor", () => { typeof input === "object" && input !== null && "provider" in input && - (input.provider === "codex" || input.provider === "claudeAgent") + (input.provider === "codex" || + input.provider === "claudeAgent" || + input.provider === "cursor") ? input.provider : "codex"; const resumeCursor = @@ -205,7 +207,7 @@ describe("ProviderCommandReactor", () => { listSessions: () => Effect.succeed(runtimeSessions), getCapabilities: (provider) => Effect.succeed({ - sessionModelSwitch: provider === "codex" ? "in-session" : "in-session", + sessionModelSwitch: provider === "cursor" ? "restart-session" : "in-session", }), rollbackConversation: () => unsupported(), streamEvents: Stream.fromPubSub(runtimeEventPubSub), @@ -552,50 +554,51 @@ describe("ProviderCommandReactor", () => { }); }); - it("rejects a turn when the requested model belongs to a different provider", async () => { + it("routes turns by explicit provider even when the model slug is shared", async () => { const harness = await createHarness(); const now = new Date().toISOString(); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create-cursor-shared-slug"), + threadId: ThreadId.makeUnsafe("thread-shared-slug"), + projectId: asProjectId("project-1"), + title: "Shared slug thread", + provider: "cursor", + model: "gpt-5.3-codex", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }), + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.makeUnsafe("cmd-turn-start-model-provider-mismatch"), - threadId: ThreadId.makeUnsafe("thread-1"), + commandId: CommandId.makeUnsafe("cmd-turn-start-shared-slug"), + threadId: ThreadId.makeUnsafe("thread-shared-slug"), message: { - messageId: asMessageId("user-message-model-provider-mismatch"), + messageId: asMessageId("user-message-shared-slug"), role: "user", text: "hello", attachments: [], }, - model: "claude-sonnet-4-6", + provider: "cursor", + model: "gpt-5.3-codex", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, }), ); - await waitFor(async () => { - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find( - (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), - ); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false - ); - }); - - expect(harness.startSession).not.toHaveBeenCalled(); - expect(harness.sendTurn).not.toHaveBeenCalled(); - - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); - expect( - thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), - ).toMatchObject({ - payload: { - detail: expect.stringContaining("does not belong to provider 'codex'"), - }, + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + provider: "cursor", + model: "gpt-5.3-codex", }); }); @@ -713,6 +716,61 @@ describe("ProviderCommandReactor", () => { }); }); + it("restarts cursor sessions on model changes while preserving resumeCursor", async () => { + const harness = await createHarness({ threadModel: "composer-2" }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-1"), + role: "user", + text: "first cursor turn", + attachments: [], + }, + provider: "cursor", + model: "composer-2", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-cursor-model-2"), + role: "user", + text: "second cursor turn", + attachments: [], + }, + provider: "cursor", + model: "composer-2-fast", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 2); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + provider: "cursor", + model: "composer-2-fast", + resumeCursor: { opaque: "resume-1" }, + }); + }); + it("restarts the provider session when runtime mode is updated on the thread", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 57405ca515..b809771b90 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -46,6 +46,27 @@ function toNonEmptyProviderInput(value: string | undefined): string | undefined return normalized && normalized.length > 0 ? normalized : undefined; } +function mergeThreadModelOptions( + cached: ProviderModelOptions | undefined, + incoming: ProviderModelOptions | undefined, +): ProviderModelOptions | undefined { + if (!cached && !incoming) { + return undefined; + } + const next = { + ...(incoming?.codex !== undefined || cached?.codex !== undefined + ? { codex: incoming?.codex ?? cached?.codex } + : {}), + ...(incoming?.claudeAgent !== undefined || cached?.claudeAgent !== undefined + ? { claudeAgent: incoming?.claudeAgent ?? cached?.claudeAgent } + : {}), + ...(incoming?.cursor !== undefined || cached?.cursor !== undefined + ? { cursor: incoming?.cursor ?? cached?.cursor } + : {}), + } satisfies Partial; + return Object.keys(next).length > 0 ? (next as ProviderModelOptions) : undefined; +} + function mapProviderSessionStatusToOrchestrationStatus( status: "connecting" | "ready" | "running" | "error" | "closed", ): OrchestrationSession["status"] { @@ -233,7 +254,10 @@ const make = Effect.gen(function* () { ) ? thread.session.providerName : undefined; - const threadProvider: ProviderKind = currentProvider ?? inferProviderForModel(thread.model); + const threadProvider: ProviderKind = + currentProvider ?? + (Schema.is(ProviderKind)(thread.provider) ? thread.provider : undefined) ?? + inferProviderForModel(thread.model); if (options?.provider !== undefined && options.provider !== threadProvider) { return yield* new ProviderAdapterRequestError({ provider: threadProvider, @@ -241,16 +265,6 @@ const make = Effect.gen(function* () { detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${options.provider}'.`, }); } - if ( - options?.model !== undefined && - inferProviderForModel(options.model, threadProvider) !== threadProvider - ) { - return yield* new ProviderAdapterRequestError({ - provider: threadProvider, - method: "thread.turn.start", - detail: `Model '${options.model}' does not belong to provider '${threadProvider}' for thread '${threadId}'.`, - }); - } const preferredProvider: ProviderKind = currentProvider ?? threadProvider; const desiredModel = options?.model ?? thread.model; const effectiveCwd = resolveThreadWorkspaceCwd({ @@ -326,10 +340,7 @@ const make = Effect.gen(function* () { return existingSessionThreadId; } - const resumeCursor = - providerChanged || shouldRestartForModelChange - ? undefined - : (activeSession?.resumeCursor ?? undefined); + const resumeCursor = providerChanged ? undefined : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, existingSessionThreadId, @@ -390,8 +401,12 @@ const make = Effect.gen(function* () { if (input.providerOptions !== undefined) { threadProviderOptions.set(input.threadId, input.providerOptions); } - if (input.modelOptions !== undefined) { - threadModelOptions.set(input.threadId, input.modelOptions); + const mergedModelOptions = mergeThreadModelOptions( + threadModelOptions.get(input.threadId), + input.modelOptions, + ); + if (mergedModelOptions !== undefined) { + threadModelOptions.set(input.threadId, mergedModelOptions); } const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; @@ -411,7 +426,7 @@ const make = Effect.gen(function* () { ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), - ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), + ...(mergedModelOptions !== undefined ? { modelOptions: mergedModelOptions } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index c1ba48108f..3a9f139576 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -679,6 +679,62 @@ describe("ProviderRuntimeIngestion", () => { expect(message?.streaming).toBe(false); }); + it("preserves completed tool metadata on projected tool activities", async () => { + const harness = await createHarness(); + const now = new Date().toISOString(); + + harness.emit({ + type: "item.completed", + eventId: asEventId("evt-tool-completed-with-data"), + provider: "cursor", + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-tool-completed"), + itemId: asItemId("item-tool-completed"), + payload: { + itemType: "dynamic_tool_call", + status: "completed", + title: "Read File", + detail: "Read File", + data: { + toolCallId: "tool-read-1", + kind: "read", + rawOutput: { + content: 'import * as Effect from "effect/Effect"\n', + }, + }, + }, + }); + + const thread = await waitForThread(harness.engine, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-tool-completed-with-data", + ), + ); + const activity = thread.activities.find( + (entry: ProviderRuntimeTestActivity) => entry.id === "evt-tool-completed-with-data", + ); + const payload = + activity?.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : undefined; + const data = + payload?.data && typeof payload.data === "object" + ? (payload.data as Record) + : undefined; + const rawOutput = + data?.rawOutput && typeof data.rawOutput === "object" + ? (data.rawOutput as Record) + : undefined; + + expect(activity?.kind).toBe("tool.completed"); + expect(payload?.itemType).toBe("dynamic_tool_call"); + expect(payload?.detail).toBe("Read File"); + expect(data?.toolCallId).toBe("tool-read-1"); + expect(data?.kind).toBe("read"); + expect(rawOutput?.content).toBe('import * as Effect from "effect/Effect"\n'); + }); + it("projects completed plan items into first-class proposed plans", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3df47941af..d25102a81a 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -446,6 +446,7 @@ function runtimeEventToActivities( payload: { itemType: event.payload.itemType, ...(event.payload.detail ? { detail: truncateDetail(event.payload.detail) } : {}), + ...(event.payload.data !== undefined ? { data: event.payload.data } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 6ea4c51759..73b4af2ffa 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -156,6 +156,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, projectId: command.projectId, title: command.title, + ...(command.provider !== undefined ? { provider: command.provider } : {}), model: command.model, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 015f82a677..e27b0bd443 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -252,6 +252,7 @@ export function projectEvent( id: payload.threadId, projectId: payload.projectId, title: payload.title, + provider: payload.provider, model: payload.model, runtimeMode: payload.runtimeMode, interactionMode: payload.interactionMode, 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 0000000000..f4b4777ac1 --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -0,0 +1,487 @@ +import * as path from "node:path"; +import * as os from "node:os"; +import { chmod, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Deferred, Effect, Fiber, Layer, Stream } from "effect"; + +import { ApprovalRequestId, type ProviderRuntimeEvent, ThreadId } from "@t3tools/contracts"; +import { resolveCursorDispatchModel } from "@t3tools/shared/model"; + +import { ServerConfig } from "../../config.ts"; +import { CursorAdapter } from "../Services/CursorAdapter.ts"; +import { makeCursorAdapterLive } from "./CursorAdapter.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); + +async function makeProbeWrapper( + requestLogPath: string, + argvLogPath: string, + extraEnv?: Record, +) { + const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-probe-")); + const wrapperPath = path.join(dir, "fake-agent.sh"); + const envExports = Object.entries(extraEnv ?? {}) + .map(([key, value]) => `export ${key}=${JSON.stringify(value)}`) + .join("\n"); + const script = `#!/bin/sh +printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)} +printf '\n' >> ${JSON.stringify(argvLogPath)} +export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} +${envExports} +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" +`; + await writeFile(wrapperPath, script, "utf8"); + await chmod(wrapperPath, 0o755); + return wrapperPath; +} + +async function readArgvLog(filePath: string) { + const raw = await readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => line.split("\t").filter((token) => token.length > 0)); +} + +async function readJsonLines(filePath: string) { + const raw = await readFile(filePath, "utf8"); + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +const cursorAdapterTestLayer = it.layer( + makeCursorAdapterLive().pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), +); + +cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("starts a session and maps mock ACP prompt flow to runtime events", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const threadId = ThreadId.makeUnsafe("cursor-mock-thread"); + + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( + Stream.runCollect, + Effect.forkChild, + ); + + const session = yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + model: "default", + providerOptions: { + cursor: { + binaryPath: process.execPath, + args: [mockAgentPath], + }, + }, + }); + + assert.equal(session.provider, "cursor"); + assert.deepStrictEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "mock-session-1", + }); + + yield* adapter.sendTurn({ + threadId, + input: "hello mock", + attachments: [], + }); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const types = runtimeEvents.map((e) => e.type); + + for (const t of [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "turn.plan.updated", + "content.delta", + "turn.completed", + ] as const) { + assert.include(types, t); + } + + const delta = runtimeEvents.find((e) => e.type === "content.delta"); + assert.isDefined(delta); + if (delta?.type === "content.delta") { + assert.equal(delta.payload.delta, "hello from mock"); + } + + const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated"); + assert.isDefined(planUpdate); + if (planUpdate?.type === "turn.plan.updated") { + assert.deepStrictEqual(planUpdate.payload, { + explanation: "Mock plan while in code", + plan: [ + { step: "Inspect mock ACP state", status: "completed" }, + { step: "Implement the requested change", status: "inProgress" }, + ], + }); + } + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("rejects startSession when provider mismatches", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const result = yield* adapter + .startSession({ + threadId: ThreadId.makeUnsafe("bad-provider"), + provider: "codex", + cwd: process.cwd(), + runtimeMode: "full-access", + }) + .pipe(Effect.result); + + assert.equal(result._tag, "Failure"); + }), + ); + + it.effect("selects the Cursor model via CLI argv instead of ACP request payloads", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const threadId = ThreadId.makeUnsafe("cursor-model-probe"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + + const dispatchedModel = resolveCursorDispatchModel("composer-2", { fastMode: true }); + const session = yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + model: "composer-2", + modelOptions: { + cursor: { + fastMode: true, + }, + }, + providerOptions: { + cursor: { + binaryPath: wrapperPath, + }, + }, + }); + + assert.equal(session.model, "composer-2"); + + yield* adapter.sendTurn({ + threadId, + input: "probe model selection", + attachments: [], + }); + yield* adapter.stopSession(threadId); + + const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.deepStrictEqual(argvRuns, [["--model", dispatchedModel, "acp"]]); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const methods = requests + .map((entry) => entry.method) + .filter((method): method is string => typeof method === "string"); + assert.includeMembers(methods, [ + "initialize", + "authenticate", + "session/new", + "session/set_mode", + "session/prompt", + ]); + + for (const request of requests) { + const params = request.params; + if (params && typeof params === "object" && !Array.isArray(params)) { + assert.isFalse(Object.prototype.hasOwnProperty.call(params, "model")); + } + } + + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + assert.isDefined(promptRequest); + assert.deepStrictEqual( + Object.keys((promptRequest?.params as Record) ?? {}).toSorted(), + ["prompt", "sessionId"], + ); + + const modeRequest = requests.find((entry) => entry.method === "session/set_mode"); + assert.isDefined(modeRequest); + assert.deepStrictEqual(modeRequest?.params, { + sessionId: "mock-session-1", + modeId: "code", + }); + }), + ); + + it.effect("maps app plan mode onto the ACP plan session mode", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const threadId = ThreadId.makeUnsafe("cursor-plan-mode-probe"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + model: "composer-2", + providerOptions: { + cursor: { + binaryPath: wrapperPath, + }, + }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "plan this change", + attachments: [], + interactionMode: "plan", + }); + yield* adapter.stopSession(threadId); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const modeRequest = requests.find((entry) => entry.method === "session/set_mode"); + assert.isDefined(modeRequest); + assert.deepStrictEqual(modeRequest?.params, { + sessionId: "mock-session-1", + modeId: "architect", + }); + }), + ); + + it.effect("streams ACP tool calls and approvals on the active turn in real time", () => + Effect.gen(function* () { + const previousEmitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS; + process.env.T3_ACP_EMIT_TOOL_CALLS = "1"; + + const adapter = yield* CursorAdapter; + const threadId = ThreadId.makeUnsafe("cursor-tool-call-probe"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + + yield* Stream.runForEach(adapter.streamEvents, (event) => + Effect.gen(function* () { + runtimeEvents.push(event); + if (String(event.threadId) !== String(threadId)) { + return; + } + if (event.type === "request.opened" && event.requestId) { + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(String(event.requestId)), + "accept", + ); + } + if ( + event.type === "turn.completed" || + event.type === "item.completed" || + event.type === "content.delta" + ) { + settledEventTypes.add(event.type); + if (settledEventTypes.size === 3) { + yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); + } + } + }), + ).pipe(Effect.forkChild); + + const program = Effect.gen(function* () { + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + model: "default", + providerOptions: { + cursor: { + binaryPath: process.execPath, + args: [mockAgentPath], + }, + }, + }); + + const turn = yield* adapter.sendTurn({ + threadId, + input: "run a tool call", + attachments: [], + }); + yield* Deferred.await(settledEventsReady); + + const threadEvents = runtimeEvents.filter( + (event) => String(event.threadId) === String(threadId), + ); + assert.includeMembers( + threadEvents.map((event) => event.type), + [ + "session.started", + "session.state.changed", + "thread.started", + "turn.started", + "request.opened", + "request.resolved", + "item.updated", + "item.completed", + "content.delta", + "turn.completed", + ], + ); + + const turnEvents = threadEvents.filter( + (event) => String(event.turnId) === String(turn.turnId), + ); + const toolUpdates = turnEvents.filter((event) => event.type === "item.updated"); + assert.lengthOf(toolUpdates, 2); + for (const toolUpdate of toolUpdates) { + if (toolUpdate.type !== "item.updated") { + continue; + } + assert.equal(toolUpdate.payload.itemType, "command_execution"); + assert.equal(toolUpdate.payload.status, "inProgress"); + assert.equal(toolUpdate.payload.detail, "cat server/package.json"); + assert.equal(String(toolUpdate.itemId), "tool-call-1"); + } + + const requestOpened = turnEvents.find((event) => event.type === "request.opened"); + assert.isDefined(requestOpened); + if (requestOpened?.type === "request.opened") { + assert.equal(String(requestOpened.turnId), String(turn.turnId)); + assert.equal(requestOpened.payload.requestType, "exec_command_approval"); + assert.equal(requestOpened.payload.detail, "cat server/package.json"); + } + + const requestResolved = turnEvents.find((event) => event.type === "request.resolved"); + assert.isDefined(requestResolved); + if (requestResolved?.type === "request.resolved") { + assert.equal(String(requestResolved.turnId), String(turn.turnId)); + assert.equal(requestResolved.payload.requestType, "exec_command_approval"); + assert.equal(requestResolved.payload.decision, "accept"); + } + + const toolCompleted = turnEvents.find((event) => event.type === "item.completed"); + assert.isDefined(toolCompleted); + if (toolCompleted?.type === "item.completed") { + assert.equal(String(toolCompleted.turnId), String(turn.turnId)); + assert.equal(toolCompleted.payload.itemType, "command_execution"); + assert.equal(toolCompleted.payload.status, "completed"); + assert.equal(toolCompleted.payload.detail, "cat server/package.json"); + assert.equal(String(toolCompleted.itemId), "tool-call-1"); + } + + const contentDelta = turnEvents.find((event) => event.type === "content.delta"); + assert.isDefined(contentDelta); + if (contentDelta?.type === "content.delta") { + assert.equal(String(contentDelta.turnId), String(turn.turnId)); + assert.equal(contentDelta.payload.delta, "hello from mock"); + } + }); + + yield* program.pipe( + Effect.ensuring( + Effect.sync(() => { + if (previousEmitToolCalls === undefined) { + delete process.env.T3_ACP_EMIT_TOOL_CALLS; + } else { + process.env.T3_ACP_EMIT_TOOL_CALLS = previousEmitToolCalls; + } + }), + ), + ); + }).pipe( + Effect.provide( + makeCursorAdapterLive().pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), + ); + + it.effect("restarts ACP with session/load when the Cursor model changes mid-thread", () => + Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const threadId = ThreadId.makeUnsafe("cursor-model-restart"); + const tempDir = yield* Effect.promise(() => mkdtemp(path.join(os.tmpdir(), "cursor-acp-"))); + const requestLogPath = path.join(tempDir, "requests.ndjson"); + const argvLogPath = path.join(tempDir, "argv.txt"); + yield* Effect.promise(() => writeFile(requestLogPath, "", "utf8")); + const wrapperPath = yield* Effect.promise(() => + makeProbeWrapper(requestLogPath, argvLogPath), + ); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + model: "composer-2", + providerOptions: { + cursor: { + binaryPath: wrapperPath, + }, + }, + }); + + yield* adapter.sendTurn({ + threadId, + input: "first turn", + attachments: [], + }); + + yield* adapter.sendTurn({ + threadId, + input: "second turn after switching model", + attachments: [], + model: "composer-2", + modelOptions: { + cursor: { + fastMode: true, + }, + }, + }); + + const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.deepStrictEqual(argvRuns, [ + ["--model", "composer-2", "acp"], + ["--model", "composer-2-fast", "acp"], + ]); + + const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); + const loadRequests = requests.filter((entry) => entry.method === "session/load"); + assert.lengthOf(loadRequests, 1); + assert.deepStrictEqual(loadRequests[0]?.params, { + sessionId: "mock-session-1", + cwd: process.cwd(), + mcpServers: [], + }); + + yield* adapter.stopSession(threadId); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts new file mode 100644 index 0000000000..4ad7cb8c1b --- /dev/null +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -0,0 +1,1566 @@ +/** + * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP JSON-RPC. + * + * @module CursorAdapterLive + */ +import * as nodePath from "node:path"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; + +import { + ApprovalRequestId, + EventId, + type ProviderInteractionMode, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + RuntimeItemId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + type ToolLifecycleItemType, + TurnId, + type UserInputQuestion, +} from "@t3tools/contracts"; +import { resolveCursorDispatchModel } from "@t3tools/shared/model"; +import { + Cause, + DateTime, + Deferred, + Effect, + Exit, + Fiber, + FileSystem, + Layer, + Queue, + Random, + Stream, +} from "effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { + attachAcpJsonRpcConnection, + disposeAcpChild, + spawnAcpChildProcess, + type AcpJsonRpcConnection, +} from "../acp/AcpJsonRpcConnection.ts"; +import type { AcpInboundMessage } from "../acp/AcpTypes.ts"; +import { AcpProcessExitedError, AcpRpcError, type AcpError } from "../acp/AcpErrors.ts"; +import { CursorAdapter, type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "cursor" as const; + +const CURSOR_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; +const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; +const ACP_APPROVAL_MODE_ALIASES = ["ask"]; + +export interface CursorAdapterLiveOptions { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface CursorSpawnOptions { + readonly binaryPath?: string | undefined; + readonly args?: ReadonlyArray | undefined; + readonly apiEndpoint?: string | undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseCursorResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== CURSOR_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function buildCursorSpawnInput(cwd: string, opts?: CursorSpawnOptions, model?: string | undefined) { + const command = opts?.binaryPath?.trim() || "agent"; + const hasCustomArgs = opts?.args && opts.args.length > 0; + const args = [ + ...(opts?.apiEndpoint ? (["-e", opts.apiEndpoint] as const) : []), + ...(model && !hasCustomArgs ? (["--model", model] as const) : []), + ...(hasCustomArgs ? opts.args : (["acp"] as const)), + ]; + return { command, args, cwd } as const; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.length > 0) { + return cause.message; + } + return fallback; +} + +function mapAcpToAdapterError( + threadId: ThreadId, + method: string, + error: AcpError, +): ProviderAdapterError { + if (error instanceof AcpProcessExitedError) { + return new ProviderAdapterSessionClosedError({ + provider: PROVIDER, + threadId, + cause: error, + }); + } + if (error instanceof AcpRpcError) { + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: error.message, + cause: error, + }); + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: toMessage(error, `${method} failed`), + cause: error, + }); +} + +function acpPermissionOutcome(decision: ProviderApprovalDecision): string { + switch (decision) { + case "acceptForSession": + return "allow-always"; + case "accept": + return "allow-once"; + case "decline": + case "cancel": + default: + return "reject-once"; + } +} + +interface AcpSessionMode { + readonly id: string; + readonly name: string; + readonly description?: string; +} + +interface AcpSessionModeState { + readonly currentModeId: string; + readonly availableModes: ReadonlyArray; +} + +interface AcpToolCallState { + readonly toolCallId: string; + readonly itemType: ToolLifecycleItemType; + readonly title?: string; + readonly status?: "pending" | "inProgress" | "completed" | "failed"; + readonly command?: string; + readonly detail?: string; + readonly data: Record; +} + +function normalizePlanStepStatus(raw: unknown): "pending" | "inProgress" | "completed" { + switch (raw) { + case "completed": + return "completed"; + case "in_progress": + case "inProgress": + return "inProgress"; + default: + return "pending"; + } +} + +function normalizeToolCallStatus( + raw: unknown, + fallback?: "pending" | "inProgress" | "completed" | "failed", +): "pending" | "inProgress" | "completed" | "failed" | undefined { + switch (raw) { + case "pending": + return "pending"; + case "in_progress": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return fallback; + } +} + +function runtimeItemStatusFromToolCallStatus( + status: "pending" | "inProgress" | "completed" | "failed" | undefined, +): "inProgress" | "completed" | "failed" | undefined { + switch (status) { + case "pending": + case "inProgress": + return "inProgress"; + case "completed": + return "completed"; + case "failed": + return "failed"; + default: + return undefined; + } +} + +function normalizeCommandValue(value: unknown): string | undefined { + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + if (!Array.isArray(value)) { + return undefined; + } + const parts = value + .map((entry) => (typeof entry === "string" && entry.trim().length > 0 ? entry.trim() : null)) + .filter((entry): entry is string => entry !== null); + return parts.length > 0 ? parts.join(" ") : undefined; +} + +function extractCommandFromTitle(title: string | undefined): string | undefined { + if (!title) { + return undefined; + } + const match = /`([^`]+)`/.exec(title); + return match?.[1]?.trim() || undefined; +} + +function extractToolCallCommand(rawInput: unknown, title: string | undefined): string | undefined { + if (isRecord(rawInput)) { + const directCommand = normalizeCommandValue(rawInput.command); + if (directCommand) { + return directCommand; + } + const executable = typeof rawInput.executable === "string" ? rawInput.executable.trim() : ""; + const args = normalizeCommandValue(rawInput.args); + if (executable && args) { + return `${executable} ${args}`; + } + if (executable) { + return executable; + } + } + return extractCommandFromTitle(title); +} + +function extractTextContentFromToolCallContent(content: unknown): string | undefined { + if (!Array.isArray(content)) { + return undefined; + } + const chunks = content + .map((entry) => { + if (!isRecord(entry)) { + return undefined; + } + if (entry.type !== "content") { + return undefined; + } + const nestedContent = entry.content; + if (!isRecord(nestedContent) || nestedContent.type !== "text") { + return undefined; + } + return typeof nestedContent.text === "string" && nestedContent.text.trim().length > 0 + ? nestedContent.text.trim() + : undefined; + }) + .filter((entry): entry is string => entry !== undefined); + return chunks.length > 0 ? chunks.join("\n") : undefined; +} + +function toolLifecycleItemTypeFromKind(kind: unknown): ToolLifecycleItemType { + switch (kind) { + case "execute": + return "command_execution"; + case "edit": + case "delete": + case "move": + return "file_change"; + case "search": + case "fetch": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + +function requestTypeFromToolKind( + kind: unknown, +): "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown" { + switch (kind) { + case "execute": + return "exec_command_approval"; + case "read": + return "file_read_approval"; + case "edit": + case "delete": + case "move": + return "file_change_approval"; + default: + return "unknown"; + } +} + +function parseToolCallState( + raw: unknown, + options?: { + readonly fallbackStatus?: "pending" | "inProgress" | "completed" | "failed"; + }, +): AcpToolCallState | undefined { + if (!isRecord(raw)) { + return undefined; + } + const toolCallId = typeof raw.toolCallId === "string" ? raw.toolCallId.trim() : ""; + if (!toolCallId) { + return undefined; + } + const title = + typeof raw.title === "string" && raw.title.trim().length > 0 ? raw.title.trim() : undefined; + const command = extractToolCallCommand(raw.rawInput, title); + const textContent = extractTextContentFromToolCallContent(raw.content); + const normalizedTitle = + title && title.toLowerCase() !== "terminal" && title.toLowerCase() !== "tool call" + ? title + : undefined; + const detail = command ?? normalizedTitle ?? textContent; + const data: Record = { toolCallId }; + if (typeof raw.kind === "string" && raw.kind.trim().length > 0) { + data.kind = raw.kind.trim(); + } + if (command) { + data.command = command; + } + if (raw.rawInput !== undefined) { + data.rawInput = raw.rawInput; + } + if (raw.rawOutput !== undefined) { + data.rawOutput = raw.rawOutput; + } + if (raw.content !== undefined) { + data.content = raw.content; + } + if (raw.locations !== undefined) { + data.locations = raw.locations; + } + const status = normalizeToolCallStatus(raw.status, options?.fallbackStatus); + return { + toolCallId, + itemType: toolLifecycleItemTypeFromKind(raw.kind), + ...(title ? { title } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(detail ? { detail } : {}), + data, + } satisfies AcpToolCallState; +} + +function mergeToolCallState( + previous: AcpToolCallState | undefined, + next: AcpToolCallState, +): AcpToolCallState { + const nextKind = typeof next.data.kind === "string" ? next.data.kind : undefined; + const title = next.title ?? previous?.title; + const status = next.status ?? previous?.status; + const command = next.command ?? previous?.command; + const detail = next.detail ?? previous?.detail; + return { + toolCallId: next.toolCallId, + itemType: nextKind !== undefined ? next.itemType : (previous?.itemType ?? next.itemType), + ...(title ? { title } : {}), + ...(status ? { status } : {}), + ...(command ? { command } : {}), + ...(detail ? { detail } : {}), + data: { + ...previous?.data, + ...next.data, + }, + } satisfies AcpToolCallState; +} + +function parsePermissionRequest(params: unknown): { + requestType: "exec_command_approval" | "file_read_approval" | "file_change_approval" | "unknown"; + detail?: string; + toolCall?: AcpToolCallState; +} { + if (!isRecord(params)) { + return { requestType: "unknown" }; + } + const toolCall = parseToolCallState(params.toolCall, { fallbackStatus: "pending" }); + const requestType = requestTypeFromToolKind( + isRecord(params.toolCall) ? params.toolCall.kind : undefined, + ); + const detail = + toolCall?.command ?? + toolCall?.title ?? + toolCall?.detail ?? + (typeof params.sessionId === "string" ? `Session ${params.sessionId}` : undefined); + return { + requestType, + ...(detail ? { detail } : {}), + ...(toolCall ? { toolCall } : {}), + }; +} + +function parseSessionModeState(raw: unknown): AcpSessionModeState | undefined { + if (!isRecord(raw)) return undefined; + const modes = isRecord(raw.modes) ? raw.modes : raw; + const currentModeId = + typeof modes.currentModeId === "string" && modes.currentModeId.trim().length > 0 + ? modes.currentModeId.trim() + : undefined; + if (!currentModeId) { + return undefined; + } + const rawModes = modes.availableModes; + if (!Array.isArray(rawModes)) { + return undefined; + } + const availableModes = rawModes + .map((mode) => { + if (!isRecord(mode)) return undefined; + const id = typeof mode.id === "string" ? mode.id.trim() : ""; + const name = typeof mode.name === "string" ? mode.name.trim() : ""; + if (!id || !name) { + return undefined; + } + const description = + typeof mode.description === "string" && mode.description.trim().length > 0 + ? mode.description.trim() + : undefined; + return description !== undefined + ? ({ id, name, description } satisfies AcpSessionMode) + : ({ id, name } satisfies AcpSessionMode); + }) + .filter((mode): mode is AcpSessionMode => mode !== undefined); + if (availableModes.length === 0) { + return undefined; + } + return { + currentModeId, + availableModes, + }; +} + +function normalizeModeSearchText(mode: AcpSessionMode): string { + return [mode.id, mode.name, mode.description] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function findModeByAliases( + modes: ReadonlyArray, + aliases: ReadonlyArray, +): AcpSessionMode | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find((mode) => { + const id = mode.id.toLowerCase(); + const name = mode.name.toLowerCase(); + return id === alias || name === alias; + }); + if (exact) { + return exact; + } + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); + if (partial) { + return partial; + } + } + return undefined; +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; +} + +function resolveRequestedModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; + } + + if (input.runtimeMode === "approval-required") { + return ( + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); + } + + return ( + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +function updateSessionModeState( + modeState: AcpSessionModeState | undefined, + nextModeId: string, +): AcpSessionModeState | undefined { + if (!modeState) { + return undefined; + } + const normalizedModeId = nextModeId.trim(); + if (!normalizedModeId) { + return modeState; + } + return modeState.availableModes.some((mode) => mode.id === normalizedModeId) + ? { + ...modeState, + currentModeId: normalizedModeId, + } + : modeState; +} + +function isMethodNotFoundRpcError(error: AcpError): boolean { + return ( + error instanceof AcpRpcError && + (error.code === -32601 || error.message.toLowerCase().includes("method not found")) + ); +} + +function parseSessionUpdate(params: unknown): { + sessionUpdate?: string; + text?: string; + modeId?: string; + plan?: { + explanation?: string | null; + plan: ReadonlyArray<{ step: string; status: "pending" | "inProgress" | "completed" }>; + }; + toolCall?: AcpToolCallState; +} { + if (!isRecord(params)) return {}; + const upd = params.update; + if (!isRecord(upd)) return {}; + const su = typeof upd.sessionUpdate === "string" ? upd.sessionUpdate : undefined; + const modeId = + typeof upd.modeId === "string" + ? upd.modeId + : typeof upd.currentModeId === "string" + ? upd.currentModeId + : undefined; + if (su === "plan") { + const entries = Array.isArray(upd.entries) ? upd.entries : undefined; + const plan = + entries + ?.map((entry, index) => { + if (!isRecord(entry)) { + return undefined; + } + const step = + typeof entry.content === "string" && entry.content.trim().length > 0 + ? entry.content.trim() + : `Step ${index + 1}`; + return { + step, + status: normalizePlanStepStatus(entry.status), + } as const; + }) + .filter( + ( + entry, + ): entry is { + step: string; + status: "pending" | "inProgress" | "completed"; + } => entry !== undefined, + ) ?? []; + if (plan.length > 0) { + const explanation = + typeof upd.explanation === "string" + ? upd.explanation + : upd.explanation === null + ? null + : undefined; + return { + sessionUpdate: su, + ...(modeId !== undefined ? { modeId } : {}), + plan: { + ...(explanation !== undefined ? { explanation } : {}), + plan, + }, + }; + } + } + if (su === "tool_call" || su === "tool_call_update") { + const toolCall = parseToolCallState( + upd, + su === "tool_call" ? { fallbackStatus: "pending" } : undefined, + ); + if (toolCall) { + return { + sessionUpdate: su, + ...(modeId !== undefined ? { modeId } : {}), + toolCall, + }; + } + } + const content = upd.content; + if (!isRecord(content)) { + return { + ...(su !== undefined ? { sessionUpdate: su } : {}), + ...(modeId !== undefined ? { modeId } : {}), + }; + } + const text = typeof content.text === "string" ? content.text : undefined; + if (su !== undefined && text !== undefined) { + return { + sessionUpdate: su, + text, + ...(modeId !== undefined ? { modeId } : {}), + }; + } + if (su !== undefined) { + return { + sessionUpdate: su, + ...(modeId !== undefined ? { modeId } : {}), + }; + } + if (text !== undefined) { + return { + text, + ...(modeId !== undefined ? { modeId } : {}), + }; + } + return {}; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly requestType: + | "exec_command_approval" + | "file_read_approval" + | "file_change_approval" + | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface CursorSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly spawnOptions?: CursorSpawnOptions | undefined; + readonly child: ChildProcessWithoutNullStreams; + readonly conn: AcpJsonRpcConnection; + acpSessionId: string; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + readonly toolCalls: Map; + modeState: AcpSessionModeState | undefined; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + stopped: boolean; +} + +function makeCursorAdapter(options?: CursorAdapterLiveOptions) { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const serverConfig = yield* Effect.service(ServerConfig); + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + + const sessions = new Map(); + const runtimeEventQueue = yield* Queue.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); + + const emitPlanUpdate = ( + ctx: CursorSessionContext, + payload: { + explanation?: string | null; + plan: ReadonlyArray<{ step: string; status: "pending" | "inProgress" | "completed" }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | "acp.cursor.extension", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${JSON.stringify(payload)}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.plan.updated", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + raw: { + source, + method, + payload: rawPayload, + }, + }); + }); + + const emitToolCallEvent = ( + ctx: CursorSessionContext, + toolCall: AcpToolCallState, + rawPayload: unknown, + ) => + Effect.gen(function* () { + const runtimeStatus = runtimeItemStatusFromToolCallStatus(toolCall.status); + const payload = { + itemType: toolCall.itemType, + ...(runtimeStatus ? { status: runtimeStatus } : {}), + ...(toolCall.title ? { title: toolCall.title } : {}), + ...(toolCall.detail ? { detail: toolCall.detail } : {}), + ...(Object.keys(toolCall.data).length > 0 ? { data: toolCall.data } : {}), + }; + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: + toolCall.status === "completed" || toolCall.status === "failed" + ? "item.completed" + : "item.updated", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: RuntimeItemId.makeUnsafe(toolCall.toolCallId), + payload, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: rawPayload, + }, + }); + if (toolCall.status === "completed" || toolCall.status === "failed") { + ctx.toolCalls.delete(toolCall.toolCallId); + } + }); + + const setSessionMode = (ctx: CursorSessionContext, modeId: string | undefined) => + Effect.gen(function* () { + const normalizedModeId = modeId?.trim(); + if (!normalizedModeId) { + return; + } + if (ctx.modeState?.currentModeId === normalizedModeId) { + return; + } + const setModeParams = { sessionId: ctx.acpSessionId, modeId: normalizedModeId }; + const setModeExit = yield* Effect.exit(ctx.conn.request("session/set_mode", setModeParams)); + if (Exit.isSuccess(setModeExit)) { + ctx.modeState = updateSessionModeState(ctx.modeState, normalizedModeId); + return; + } + const error = Cause.squash(setModeExit.cause) as AcpError; + if (!isMethodNotFoundRpcError(error)) { + return yield* mapAcpToAdapterError(ctx.threadId, "session/set_mode", error); + } + yield* ctx.conn + .request("session/mode/set", { + sessionId: ctx.acpSessionId, + mode: normalizedModeId, + }) + .pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(ctx.threadId, "session/mode/set", cause), + ), + ); + ctx.modeState = updateSessionModeState(ctx.modeState, normalizedModeId); + }); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc" | "acp.cursor.extension", + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = new Date().toISOString(); + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: crypto.randomUUID(), + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: CursorSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + disposeAcpChild(ctx.child); + sessions.delete(ctx.threadId); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.exited", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: CursorAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + const cwd = nodePath.resolve(input.cwd.trim()); + const cursorOpts = input.providerOptions?.cursor; + const initialModel = resolveCursorDispatchModel(input.model, input.modelOptions?.cursor); + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + const spawnInput = buildCursorSpawnInput(cwd, cursorOpts, initialModel); + const child = yield* spawnAcpChildProcess(spawnInput).pipe( + Effect.mapError( + (e) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: e.message, + cause: e, + }), + ), + ); + + const conn = yield* attachAcpJsonRpcConnection(child).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to attach ACP JSON-RPC to child process.", + cause, + }), + ), + ); + + const ctx: CursorSessionContext = { + threadId: input.threadId, + session: {} as ProviderSession, + spawnOptions: cursorOpts, + child, + conn, + acpSessionId: "", + notificationFiber: undefined, + pendingApprovals: new Map(), + pendingUserInputs: new Map(), + turns: [], + toolCalls: new Map(), + modeState: undefined, + lastPlanFingerprint: undefined, + activeTurnId: undefined, + stopped: false, + }; + + const registerHandlers = (ctx: CursorSessionContext) => + Effect.gen(function* () { + yield* conn.registerHandler("session/request_permission", (params, _acpId) => + Effect.gen(function* () { + yield* logNative(ctx.threadId, "session/request_permission", params, "acp.jsonrpc"); + const permissionRequest = parsePermissionRequest(params); + if (permissionRequest.toolCall) { + const previousToolCall = ctx.toolCalls.get(permissionRequest.toolCall.toolCallId); + ctx.toolCalls.set( + permissionRequest.toolCall.toolCallId, + mergeToolCallState(previousToolCall, permissionRequest.toolCall), + ); + } + const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); + const decision = yield* Deferred.make(); + ctx.pendingApprovals.set(requestId, { + decision, + requestType: permissionRequest.requestType, + }); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + requestId: runtimeRequestId, + payload: { + requestType: permissionRequest.requestType, + ...(permissionRequest.detail + ? { detail: permissionRequest.detail } + : { detail: JSON.stringify(params).slice(0, 2000) }), + args: params, + }, + raw: { + source: "acp.jsonrpc", + method: "session/request_permission", + payload: params, + }, + }); + const d = yield* Deferred.await(decision); + ctx.pendingApprovals.delete(requestId); + const stamp2 = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + ...stamp2, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + requestId: runtimeRequestId, + payload: { + requestType: permissionRequest.requestType, + decision: d, + }, + }); + return { + outcome: { outcome: "selected", optionId: acpPermissionOutcome(d) }, + }; + }), + ); + + yield* conn.registerHandler("cursor/ask_question", (params, _acpId) => + Effect.gen(function* () { + yield* logNative( + ctx.threadId, + "cursor/ask_question", + params, + "acp.cursor.extension", + ); + const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); + const answers = yield* Deferred.make(); + ctx.pendingUserInputs.set(requestId, { answers }); + const questions = extractAskQuestions(params); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "user-input.requested", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + requestId: runtimeRequestId, + payload: { questions }, + raw: { + source: "acp.cursor.extension", + method: "cursor/ask_question", + payload: params, + }, + }); + const a = yield* Deferred.await(answers); + ctx.pendingUserInputs.delete(requestId); + const stamp2 = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "user-input.resolved", + ...stamp2, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + requestId: runtimeRequestId, + payload: { answers: a }, + }); + return { answers: a }; + }), + ); + + yield* conn.registerHandler("cursor/create_plan", (params, _acpId) => + Effect.gen(function* () { + yield* logNative( + ctx.threadId, + "cursor/create_plan", + params, + "acp.cursor.extension", + ); + const planMarkdown = extractPlanMarkdown(params); + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.proposed.completed", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { planMarkdown }, + raw: { + source: "acp.cursor.extension", + method: "cursor/create_plan", + payload: params, + }, + }); + return { accepted: true }; + }), + ); + + yield* conn.registerHandler("cursor/update_todos", (params, _acpId) => + Effect.gen(function* () { + yield* logNative( + ctx.threadId, + "cursor/update_todos", + params, + "acp.cursor.extension", + ); + const plan = extractTodosAsPlan(params); + yield* emitPlanUpdate( + ctx, + plan, + params, + "acp.cursor.extension", + "cursor/update_todos", + ); + return {}; + }), + ); + }); + + yield* registerHandlers(ctx); + + const init = yield* conn + .request("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "t3-code", version: "0.0.0" }, + }) + .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "initialize", e))); + + yield* conn + .request("authenticate", { methodId: "cursor_login" }) + .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "authenticate", e))); + + const resume = parseCursorResume(input.resumeCursor); + let acpSessionId: string; + let sessionSetupResult: unknown = undefined; + if (resume) { + const loadExit = yield* Effect.exit( + conn.request("session/load", { + sessionId: resume.sessionId, + cwd, + mcpServers: [], + }), + ); + if (Exit.isSuccess(loadExit)) { + acpSessionId = resume.sessionId; + sessionSetupResult = loadExit.value; + } else { + const created = yield* conn + .request("session/new", { cwd, mcpServers: [] }) + .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "session/new", e))); + const cr = created as { sessionId?: string }; + if (typeof cr.sessionId !== "string") { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/new", + detail: "session/new missing sessionId", + cause: created, + }); + } + acpSessionId = cr.sessionId; + sessionSetupResult = created; + } + } else { + const created = yield* conn + .request("session/new", { cwd, mcpServers: [] }) + .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "session/new", e))); + const cr = created as { sessionId?: string }; + if (typeof cr.sessionId !== "string") { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/new", + detail: "session/new missing sessionId", + cause: created, + }); + } + acpSessionId = cr.sessionId; + sessionSetupResult = created; + } + + const now = yield* nowIso; + const resumeCursor = { + schemaVersion: CURSOR_RESUME_VERSION, + sessionId: acpSessionId, + }; + + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: input.model, + threadId: input.threadId, + resumeCursor, + createdAt: now, + updatedAt: now, + }; + + ctx.session = session; + ctx.acpSessionId = acpSessionId; + ctx.modeState = parseSessionModeState(sessionSetupResult); + + const handleNotification = (msg: AcpInboundMessage) => + Effect.gen(function* () { + if (msg._tag !== "notification" || msg.method !== "session/update") return; + yield* logNative(ctx.threadId, "session/update", msg.params, "acp.jsonrpc"); + const p = parseSessionUpdate(msg.params); + if (p.modeId) { + ctx.modeState = updateSessionModeState(ctx.modeState, p.modeId); + } + if (p.sessionUpdate === "plan" && p.plan) { + yield* emitPlanUpdate(ctx, p.plan, msg.params, "acp.jsonrpc", "session/update"); + } + if ( + (p.sessionUpdate === "tool_call" || p.sessionUpdate === "tool_call_update") && + p.toolCall + ) { + const previousToolCall = ctx.toolCalls.get(p.toolCall.toolCallId); + const mergedToolCall = mergeToolCallState(previousToolCall, p.toolCall); + ctx.toolCalls.set(mergedToolCall.toolCallId, mergedToolCall); + yield* emitToolCallEvent(ctx, mergedToolCall, msg.params); + } + if ( + (p.sessionUpdate === "agent_message_chunk" || + p.sessionUpdate === "assistant_message_chunk") && + p.text + ) { + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "content.delta", + ...stamp, + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { + streamKind: "assistant_text", + delta: p.text, + }, + raw: { + source: "acp.jsonrpc", + method: "session/update", + payload: msg.params, + }, + }); + } + }); + + const nf = yield* Stream.runDrain( + Stream.mapEffect(conn.notifications, handleNotification), + ).pipe(Effect.forkChild); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + + const stamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "session.started", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: init }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Cursor ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: acpSessionId }, + }); + + return session; + }); + + const sendTurn: CursorAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + let ctx = yield* requireSession(input.threadId); + const turnId = TurnId.makeUnsafe(crypto.randomUUID()); + const model = resolveCursorDispatchModel( + input.model ?? ctx.session.model, + input.modelOptions?.cursor, + ); + const activeModel = resolveCursorDispatchModel(ctx.session.model, undefined); + if (model !== activeModel) { + yield* stopSessionInternal(ctx); + yield* startSession({ + threadId: input.threadId, + provider: PROVIDER, + cwd: ctx.session.cwd, + runtimeMode: ctx.session.runtimeMode, + model, + ...(ctx.spawnOptions ? { providerOptions: { cursor: ctx.spawnOptions } } : {}), + ...(ctx.session.resumeCursor !== undefined + ? { resumeCursor: ctx.session.resumeCursor } + : {}), + }); + ctx = yield* requireSession(input.threadId); + } + ctx.activeTurnId = turnId; + ctx.lastPlanFingerprint = undefined; + ctx.toolCalls.clear(); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: ctx.session.runtimeMode, + modeState: ctx.modeState, + }); + yield* Effect.ignore(setSessionMode(ctx, requestedModeId)); + + const stampStart = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + ...stampStart, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model }, + }); + + const promptParts: Array> = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + 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: toMessage(cause, "Failed to read attachment."), + cause, + }), + ), + ); + promptParts.push({ + type: "image", + image: { + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.conn + .request("session/prompt", { + sessionId: ctx.acpSessionId, + prompt: promptParts, + }) + .pipe(Effect.mapError((e) => mapAcpToAdapterError(input.threadId, "session/prompt", e))); + + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + model, + }; + + const pr = result as { stopReason?: string | null }; + const stampEnd = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.completed", + ...stampEnd, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: "completed", + stopReason: pr.stopReason ?? null, + }, + }); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }); + + const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* Effect.ignore(ctx.conn.request("session/cancel", { sessionId: ctx.acpSessionId })); + }); + + const respondToRequest: CursorAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: CursorAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "cursor/ask_question", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: CursorAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { + threadId, + turns: ctx.turns, + }; + }); + + const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: CursorAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }); + + const listSessions: CursorAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: CursorAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: CursorAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.tap(() => Queue.shutdown(runtimeEventQueue)), + ), + ); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "restart-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies CursorAdapterShape; + }); +} + +function extractAskQuestions(params: unknown): ReadonlyArray { + if (!isRecord(params)) return []; + const qs = params.questions ?? params.question; + if (!Array.isArray(qs)) return []; + const out: UserInputQuestion[] = []; + for (const q of qs) { + if (!isRecord(q)) continue; + const id = typeof q.id === "string" ? q.id : "question"; + const header = typeof q.header === "string" ? q.header : "Question"; + const question = typeof q.question === "string" ? q.question : ""; + const rawOpts = q.options; + const options: Array<{ label: string; description: string }> = []; + if (Array.isArray(rawOpts)) { + for (const o of rawOpts) { + if (!isRecord(o)) continue; + const label = typeof o.label === "string" ? o.label : "Option"; + const description = typeof o.description === "string" ? o.description : label; + options.push({ label, description }); + } + } + if (options.length === 0) { + options.push({ label: "OK", description: "Continue" }); + } + out.push({ id, header, question, options }); + } + return out.length > 0 + ? out + : [{ id: "q1", header: "Input", question: "?", options: [{ label: "OK", description: "OK" }] }]; +} + +function extractPlanMarkdown(params: unknown): string { + if (!isRecord(params)) return ""; + const pm = + typeof params.plan === "string" + ? params.plan + : typeof params.planMarkdown === "string" + ? params.planMarkdown + : typeof params.markdown === "string" + ? params.markdown + : ""; + return pm || "# Plan\n\n(Cursor did not supply plan text.)"; +} + +function extractTodosAsPlan(params: unknown): { + explanation?: string; + plan: ReadonlyArray<{ step: string; status: "pending" | "inProgress" | "completed" }>; +} { + if (!isRecord(params)) { + return { plan: [] }; + } + const todos = params.todos ?? params.items; + if (!Array.isArray(todos)) { + return { plan: [] }; + } + const plan = todos.map((t, i) => { + if (!isRecord(t)) { + return { step: `Step ${i + 1}`, status: "pending" as const }; + } + const step = + typeof t.content === "string" + ? t.content + : typeof t.title === "string" + ? t.title + : `Step ${i + 1}`; + const st = t.status; + const status = normalizePlanStepStatus(st); + return { step, status }; + }); + return { plan }; +} + +export const CursorAdapterLive = Layer.effect(CursorAdapter, makeCursorAdapter()); + +export function makeCursorAdapterLive(opts?: CursorAdapterLiveOptions) { + return Layer.effect(CursorAdapter, makeCursorAdapter(opts)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index db0293f0fe..d92293bfa1 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: "in-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 23ef8d1b9b..f953ce5acf 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>; @@ -27,7 +28,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption 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/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index cbb97a807e..a51859202b 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -27,6 +27,7 @@ import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHe const DEFAULT_TIMEOUT_MS = 4_000; const CODEX_PROVIDER = "codex" as const; const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; +const CURSOR_AGENT_PROVIDER = "cursor" as const; // ── Pure helpers ──────────────────────────────────────────────────── @@ -281,6 +282,27 @@ const runClaudeCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); +const runCursorAgentCommand = (args: ReadonlyArray) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make("agent", [...args], { + shell: process.platform === "win32", + }); + + const child = yield* spawner.spawn(command); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + // ── Health check ──────────────────────────────────────────────────── export const checkCodexProviderStatus: Effect.Effect< @@ -587,14 +609,91 @@ export const checkClaudeProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +export const checkCursorProviderStatus: Effect.Effect< + ServerProviderStatus, + never, + ChildProcessSpawner.ChildProcessSpawner +> = Effect.gen(function* () { + const checkedAt = new Date().toISOString(); + + const versionProbe = yield* runCursorAgentCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return { + provider: CURSOR_AGENT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: isCommandMissingCause(error) + ? "Cursor CLI (`agent`) is not installed or not on PATH." + : `Failed to execute Cursor CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }; + } + + if (Option.isNone(versionProbe.success)) { + return { + provider: CURSOR_AGENT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: "Cursor CLI is installed but failed to run. Timed out while running command.", + }; + } + + const version = versionProbe.success.value; + if (version.code !== 0) { + const detail = detailFromResult(version); + return { + provider: CURSOR_AGENT_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: detail + ? `Cursor CLI is installed but failed to run. ${detail}` + : "Cursor CLI is installed but failed to run.", + }; + } + + const lower = `${version.stdout}\n${version.stderr}`.toLowerCase(); + if (lower.includes("not logged in") || lower.includes("login required")) { + return { + provider: CURSOR_AGENT_PROVIDER, + status: "warning" as const, + available: true, + authStatus: "unauthenticated" as const, + checkedAt, + message: "Cursor CLI may need authentication. Run `agent login` or set CURSOR_API_KEY.", + }; + } + + return { + provider: CURSOR_AGENT_PROVIDER, + status: "ready" as const, + available: true, + authStatus: "unknown" as const, + checkedAt, + message: "Cursor CLI is reachable. Use `agent login` or CURSOR_API_KEY before ACP sessions.", + } satisfies ServerProviderStatus; +}); + // ── Layer ─────────────────────────────────────────────────────────── export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const statusesFiber = yield* Effect.all([checkCodexProviderStatus, checkClaudeProviderStatus], { - concurrency: "unbounded", - }).pipe(Effect.forkScoped); + const statusesFiber = yield* Effect.all( + [checkCodexProviderStatus, checkClaudeProviderStatus, checkCursorProviderStatus], + { + concurrency: "unbounded", + }, + ).pipe(Effect.forkScoped); return { getStatuses: Fiber.join(statusesFiber), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index a305c8aa64..63f18b6fc2 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -230,14 +230,17 @@ const sleep = (ms: number) => function makeProviderServiceLayer() { const codex = makeFakeCodexAdapter(); const claude = makeFakeCodexAdapter("claudeAgent"); + const cursor = makeFakeCodexAdapter("cursor"); const registry: typeof ProviderAdapterRegistry.Service = { getByProvider: (provider) => provider === "codex" ? Effect.succeed(codex.adapter) : provider === "claudeAgent" ? Effect.succeed(claude.adapter) - : Effect.fail(new ProviderUnsupportedError({ provider })), - listProviders: () => Effect.succeed(["codex", "claudeAgent"]), + : provider === "cursor" + ? Effect.succeed(cursor.adapter) + : Effect.fail(new ProviderUnsupportedError({ provider })), + listProviders: () => Effect.succeed(["codex", "claudeAgent", "cursor"]), }; const providerAdapterLayer = Layer.succeed(ProviderAdapterRegistry, registry); @@ -263,6 +266,7 @@ function makeProviderServiceLayer() { return { codex, claude, + cursor, layer, }; } diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 961c63d696..b6c51db885 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -22,7 +22,7 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "cursor") { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts new file mode 100644 index 0000000000..8b64238955 --- /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/acp/AcpErrors.ts b/apps/server/src/provider/acp/AcpErrors.ts new file mode 100644 index 0000000000..40b35ca316 --- /dev/null +++ b/apps/server/src/provider/acp/AcpErrors.ts @@ -0,0 +1,24 @@ +import { Data } from "effect"; + +export class AcpSpawnError extends Data.TaggedError("AcpSpawnError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export class AcpParseError extends Data.TaggedError("AcpParseError")<{ + readonly line: string; + readonly cause?: unknown; +}> {} + +export class AcpRpcError extends Data.TaggedError("AcpRpcError")<{ + readonly code: number; + readonly message: string; + readonly data?: unknown; +}> {} + +export class AcpProcessExitedError extends Data.TaggedError("AcpProcessExitedError")<{ + readonly code: number | null; + readonly signal: NodeJS.Signals | null; +}> {} + +export type AcpError = AcpSpawnError | AcpParseError | AcpRpcError | AcpProcessExitedError; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts new file mode 100644 index 0000000000..3ad8bbb711 --- /dev/null +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -0,0 +1,51 @@ +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect, Stream } from "effect"; +import { describe, expect } from "vitest"; + +import { makeAcpJsonRpcConnection } from "./AcpJsonRpcConnection.ts"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); + +describe("AcpJsonRpcConnection", () => { + it.effect("performs initialize → session/new → session/prompt against mock agent", () => + Effect.gen(function* () { + const conn = yield* makeAcpJsonRpcConnection({ + command: process.execPath, + args: [mockAgentPath], + }); + + const initResult = yield* conn.request("initialize", { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: "t3-test", version: "0.0.0" }, + }); + expect(initResult).toMatchObject({ protocolVersion: 1 }); + + yield* conn.request("authenticate", { methodId: "cursor_login" }); + + const newResult = yield* conn.request("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + expect(newResult).toEqual({ sessionId: "mock-session-1" }); + + const promptResult = yield* conn.request("session/prompt", { + sessionId: "mock-session-1", + prompt: [{ type: "text", text: "hi" }], + }); + expect(promptResult).toMatchObject({ stopReason: "end_turn" }); + + const notes = yield* Stream.runCollect(Stream.take(conn.notifications, 1)); + expect(notes.length).toBe(1); + expect(notes[0]?._tag).toBe("notification"); + if (notes[0]?._tag === "notification") { + expect(notes[0].method).toBe("session/update"); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.ts new file mode 100644 index 0000000000..cc44e46af9 --- /dev/null +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.ts @@ -0,0 +1,250 @@ +import { createInterface } from "node:readline"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +import { Cause, Deferred, Effect, Exit, Queue, Ref, Scope, Semaphore, Stream } from "effect"; + +import { + AcpParseError, + AcpProcessExitedError, + AcpRpcError, + AcpSpawnError, + type AcpError, +} from "./AcpErrors.ts"; +import { + decodeAcpInboundFromJsonLine, + type AcpInboundMessage, + type AcpServerRequestHandler, + type AcpSpawnInput, +} from "./AcpTypes.ts"; + +const JSON_RPC_VERSION = "2.0"; + +function parseInboundLine(line: string): Effect.Effect { + const trimmed = line.trim(); + if (!trimmed) { + return Effect.succeed(null); + } + const lineSnippet = trimmed.slice(0, 500); + return decodeAcpInboundFromJsonLine(trimmed).pipe( + Effect.mapError((cause) => new AcpParseError({ line: lineSnippet, cause })), + ); +} + +export interface AcpJsonRpcConnection { + readonly request: (method: string, params?: unknown) => Effect.Effect; + readonly notify: (method: string, params?: unknown) => Effect.Effect; + readonly registerHandler: ( + method: string, + handler: AcpServerRequestHandler, + ) => Effect.Effect; + readonly notifications: Stream.Stream; +} + +export function spawnAcpChildProcess( + input: AcpSpawnInput, +): Effect.Effect { + return Effect.try({ + try: () => { + const c = spawn(input.command, [...input.args], { + cwd: input.cwd, + env: { ...process.env, ...input.env }, + stdio: ["pipe", "pipe", "inherit"], + shell: process.platform === "win32", + }); + if (!c.stdin || !c.stdout) { + throw new Error("Child process missing stdio pipes."); + } + return c as unknown as ChildProcessWithoutNullStreams; + }, + catch: (cause) => + new AcpSpawnError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); +} + +export function disposeAcpChild(child: ChildProcessWithoutNullStreams) { + try { + child.stdin?.end(); + } catch { + /* ignore */ + } + try { + child.kill("SIGTERM"); + } catch { + /* ignore */ + } +} + +/** + * Attach JSON-RPC framing to an existing child process (caller owns spawn/kill). + */ +export const attachAcpJsonRpcConnection = ( + child: ChildProcessWithoutNullStreams, +): Effect.Effect => + Effect.gen(function* () { + const writeLock = yield* Semaphore.make(1); + const pending = yield* Ref.make( + new Map>(), + ); + const handlers = yield* Ref.make(new Map()); + const nextId = yield* Ref.make(1); + const notificationQueue = yield* Queue.unbounded(); + + const failAllPending = (error: AcpError) => + Ref.get(pending).pipe( + Effect.flatMap((map) => + Effect.forEach([...map.values()], (def) => Deferred.fail(def, error), { + discard: true, + }), + ), + Effect.tap(() => Ref.set(pending, new Map())), + ); + + const writeRawLine = (payload: Record) => + Effect.try({ + try: () => { + child.stdin.write(`${JSON.stringify(payload)}\n`); + }, + catch: (cause) => + new AcpSpawnError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + + const writeSerialized = (payload: Record) => + writeLock.withPermits(1)(writeRawLine(payload)); + + const sendRequest = (method: string, params?: unknown) => + Effect.gen(function* () { + const deferred = yield* Deferred.make(); + yield* writeLock.withPermits(1)( + Effect.gen(function* () { + const id = yield* Ref.get(nextId); + yield* Ref.set(nextId, id + 1); + yield* Ref.update(pending, (map) => new Map(map).set(id, deferred)); + yield* writeRawLine({ + jsonrpc: JSON_RPC_VERSION, + id, + method, + ...(params !== undefined ? { params } : {}), + }); + }), + ); + return yield* Deferred.await(deferred); + }); + + const sendNotify = (method: string, params?: unknown) => + writeSerialized({ + jsonrpc: JSON_RPC_VERSION, + method, + ...(params !== undefined ? { params } : {}), + }).pipe(Effect.asVoid); + + const respondResult = (id: number | string, result: unknown) => + writeSerialized({ jsonrpc: JSON_RPC_VERSION, id, result }); + + const respondError = (id: number | string, message: string, code = -32601) => + writeSerialized({ + jsonrpc: JSON_RPC_VERSION, + id, + error: { code, message }, + }); + + const handleOneLine = (line: string): Effect.Effect => + Effect.gen(function* () { + const parseExit = yield* parseInboundLine(line).pipe(Effect.exit); + if (Exit.isFailure(parseExit)) { + return; + } + if (parseExit.value === null) { + return; + } + const msg = parseExit.value; + + if (msg._tag === "response") { + const map = yield* Ref.get(pending); + const def = map.get(msg.id); + if (!def) return; + const next = new Map(map); + next.delete(msg.id); + yield* Ref.set(pending, next); + if (msg.error) { + yield* Deferred.fail( + def, + new AcpRpcError({ + code: msg.error.code, + message: msg.error.message, + ...(msg.error.data !== undefined ? { data: msg.error.data } : {}), + }), + ); + } else { + yield* Deferred.succeed(def, msg.result); + } + return; + } + + if (msg._tag === "notification") { + yield* Queue.offer(notificationQueue, msg); + return; + } + + const handlerMap = yield* Ref.get(handlers); + const handler = handlerMap.get(msg.method); + if (!handler) { + yield* respondError(msg.id, `Method not found: ${msg.method}`); + return; + } + + const exit = yield* Effect.exit(handler(msg.params, msg.id)); + if (Exit.isSuccess(exit)) { + yield* respondResult(msg.id, exit.value); + } else { + const left = Cause.squash(exit.cause); + yield* respondError(msg.id, left instanceof AcpRpcError ? left.message : String(left)); + } + }); + + yield* Effect.sync(() => { + child.once("close", (code: number | null, signal: NodeJS.Signals | null) => { + const err = new AcpProcessExitedError({ code, signal }); + void Effect.runPromise( + failAllPending(err).pipe(Effect.tap(() => Queue.shutdown(notificationQueue))), + ).catch(() => { + /* ignore shutdown races */ + }); + }); + }); + + const rl = createInterface({ input: child.stdout, crlfDelay: Infinity }); + yield* Effect.sync(() => { + rl.on("line", (ln: string) => { + void Effect.runPromise(handleOneLine(ln)).catch(() => { + /* parse/handler errors are non-fatal for the transport */ + }); + }); + }); + + const registerHandler = (method: string, handler: AcpServerRequestHandler) => + Ref.update(handlers, (map) => new Map(map).set(method, handler)); + + return { + request: sendRequest, + notify: sendNotify, + registerHandler, + notifications: Stream.fromQueue(notificationQueue), + } satisfies AcpJsonRpcConnection; + }); + +/** + * Spawns an ACP agent process and exposes NDJSON JSON-RPC over stdio. + * Run under `Effect.scoped` so the child is disposed when the scope ends. + */ +export const makeAcpJsonRpcConnection = ( + input: AcpSpawnInput, +): Effect.Effect => + Effect.acquireRelease(spawnAcpChildProcess(input), (child) => + Effect.sync(() => disposeAcpChild(child)), + ).pipe(Effect.flatMap(attachAcpJsonRpcConnection)); diff --git a/apps/server/src/provider/acp/AcpTypes.ts b/apps/server/src/provider/acp/AcpTypes.ts new file mode 100644 index 0000000000..cc593a4f79 --- /dev/null +++ b/apps/server/src/provider/acp/AcpTypes.ts @@ -0,0 +1,147 @@ +import { Effect, Option, Schema, SchemaIssue, SchemaTransformation } from "effect"; + +import type { AcpError } from "./AcpErrors.ts"; + +/** JSON-RPC 2.0 error object on the wire. */ +export const JsonRpcErrorPayload = Schema.Struct({ + code: Schema.Number, + message: Schema.String, + data: Schema.optional(Schema.Unknown), +}); + +/** Parsed JSON object from one NDJSON line before JSON-RPC classification. */ +export const JsonRpcInboundWire = Schema.Struct({ + jsonrpc: Schema.optional(Schema.String), + id: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + method: Schema.optional(Schema.String), + params: Schema.optional(Schema.Unknown), + result: Schema.optional(Schema.Unknown), + error: Schema.optional(JsonRpcErrorPayload), +}); + +export const AcpInboundResponse = Schema.Struct({ + _tag: Schema.Literal("response"), + id: Schema.Union([Schema.String, Schema.Number]), + result: Schema.optional(Schema.Unknown), + error: Schema.optional(JsonRpcErrorPayload), +}); + +export const AcpInboundRequest = Schema.Struct({ + _tag: Schema.Literal("request"), + id: Schema.Union([Schema.String, Schema.Number]), + method: Schema.String, + params: Schema.optional(Schema.Unknown), +}); + +export const AcpInboundNotification = Schema.Struct({ + _tag: Schema.Literal("notification"), + method: Schema.String, + params: Schema.optional(Schema.Unknown), +}); + +/** + * Inbound JSON-RPC messages from the ACP agent (stdout), after line framing. + */ +export const AcpInboundMessage = Schema.Union([ + AcpInboundResponse, + AcpInboundRequest, + AcpInboundNotification, +]); + +export type AcpInboundMessage = typeof AcpInboundMessage.Type; + +const jsonRpcWireToInbound = SchemaTransformation.transformOrFail({ + decode: (parsed: typeof JsonRpcInboundWire.Type) => { + const id = parsed.id; + const method = parsed.method; + const hasId = id !== undefined && id !== null; + const hasMethod = typeof method === "string"; + + if (hasId && (parsed.result !== undefined || parsed.error !== undefined)) { + const err = parsed.error; + const rpcError = + err !== undefined + ? { + code: err.code, + message: err.message, + ...(err.data !== undefined ? { data: err.data } : {}), + } + : undefined; + return Effect.succeed({ + _tag: "response" as const, + id, + ...(parsed.result !== undefined ? { result: parsed.result } : {}), + ...(rpcError ? { error: rpcError } : {}), + }); + } + + if (hasMethod && hasId) { + return Effect.succeed({ + _tag: "request" as const, + id, + method, + ...(parsed.params !== undefined ? { params: parsed.params } : {}), + }); + } + + if (hasMethod && !hasId) { + return Effect.succeed({ + _tag: "notification" as const, + method, + ...(parsed.params !== undefined ? { params: parsed.params } : {}), + }); + } + + return Effect.fail( + new SchemaIssue.InvalidValue(Option.some(parsed), { + title: "Unrecognized JSON-RPC inbound message shape", + }), + ); + }, + + encode: (msg: AcpInboundMessage) => { + if (msg._tag === "response") { + return Effect.succeed({ + jsonrpc: "2.0" as const, + id: msg.id, + ...(msg.result !== undefined ? { result: msg.result } : {}), + ...(msg.error !== undefined ? { error: msg.error } : {}), + }); + } + if (msg._tag === "request") { + return Effect.succeed({ + jsonrpc: "2.0" as const, + id: msg.id, + method: msg.method, + ...(msg.params !== undefined ? { params: msg.params } : {}), + }); + } + return Effect.succeed({ + jsonrpc: "2.0" as const, + method: msg.method, + ...(msg.params !== undefined ? { params: msg.params } : {}), + }); + }, +}); + +const jsonRpcWireDecodedToInbound = JsonRpcInboundWire.pipe( + Schema.decodeTo(Schema.toType(AcpInboundMessage), jsonRpcWireToInbound), +); + +/** Decode one NDJSON line (JSON string) to a classified inbound message. */ +export const AcpInboundFromJsonLine = Schema.fromJsonString(jsonRpcWireDecodedToInbound); + +export const decodeAcpInboundFromJsonLine = Schema.decodeEffect(AcpInboundFromJsonLine); + +export interface AcpSpawnInput { + readonly command: string; + readonly args: ReadonlyArray; + readonly cwd?: string; + /** Merged with `process.env` for the child. */ + readonly env?: Readonly>; +} + +export type AcpServerRequestHandler = ( + params: unknown, + requestId: number | string, +) => Effect.Effect; diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts new file mode 100644 index 0000000000..9114b88ade --- /dev/null +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -0,0 +1,34 @@ +/** + * Optional integration check against a real `agent acp` install. + * Enable with: T3_CURSOR_ACP_PROBE=1 bun run test --filter CursorAcpCliProbe + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { it } from "@effect/vitest"; +import { Effect } from "effect"; +import { describe, expect } from "vitest"; + +import { makeAcpJsonRpcConnection } from "./AcpJsonRpcConnection.ts"; + +describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", () => { + it.effect("initialize and authenticate against real agent acp", () => + Effect.gen(function* () { + const conn = yield* makeAcpJsonRpcConnection({ + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }); + + const init = yield* conn.request("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }); + expect(init).toBeDefined(); + + yield* conn.request("authenticate", { methodId: "cursor_login" }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/acp/index.ts b/apps/server/src/provider/acp/index.ts new file mode 100644 index 0000000000..ca1d664a70 --- /dev/null +++ b/apps/server/src/provider/acp/index.ts @@ -0,0 +1,3 @@ +export * from "./AcpErrors.ts"; +export * from "./AcpTypes.ts"; +export * from "./AcpJsonRpcConnection.ts"; diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 1cd8edac26..6b8f476d1e 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -19,6 +19,7 @@ import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus" import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; +import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -73,9 +74,13 @@ export function makeServerProviderLayer(): Layer.Layer< const claudeAdapterLayer = makeClaudeAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const cursorAdapterLayer = makeCursorAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), + Layer.provide(cursorAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 26d231537d..2ba9a804db 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import type { ProviderKind } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; import { @@ -15,6 +16,12 @@ import { resolveAppModelSelection, } from "./appSettings"; +const emptyCustomModelsByProvider: Record = { + codex: [], + claudeAgent: [], + cursor: [], +}; + describe("normalizeCustomModelSlugs", () => { it("normalizes aliases, removes built-ins, and deduplicates values", () => { expect( @@ -75,31 +82,31 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: ["galapagos-alpha"], claudeAgent: [] }, + { ...emptyCustomModelsByProvider, codex: ["galapagos-alpha"] }, "galapagos-alpha", ), ).toBe("galapagos-alpha"); }); it("falls back to the provider default when no model is selected", () => { - expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "")).toBe("gpt-5.4"); + expect(resolveAppModelSelection("codex", emptyCustomModelsByProvider, "")).toBe("gpt-5.4"); }); it("resolves display names through the shared resolver", () => { - expect(resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "GPT-5.3 Codex")).toBe( + expect(resolveAppModelSelection("codex", emptyCustomModelsByProvider, "GPT-5.3 Codex")).toBe( "gpt-5.3-codex", ); }); it("resolves aliases through the shared resolver", () => { - expect(resolveAppModelSelection("claudeAgent", { codex: [], claudeAgent: [] }, "sonnet")).toBe( + expect(resolveAppModelSelection("claudeAgent", emptyCustomModelsByProvider, "sonnet")).toBe( "claude-sonnet-4-6", ); }); it("resolves transient selected custom models included in app model options", () => { expect( - resolveAppModelSelection("codex", { codex: [], claudeAgent: [] }, "custom/selected-model"), + resolveAppModelSelection("codex", emptyCustomModelsByProvider, "custom/selected-model"), ).toBe("custom/selected-model"); }); }); @@ -122,12 +129,14 @@ describe("provider-indexed custom model settings", () => { const settings = { customCodexModels: ["custom/codex-model"], customClaudeModels: ["claude/custom-opus"], + customCursorModels: [] as const, } as const; it("exports one provider config per provider", () => { expect(MODEL_PROVIDER_SETTINGS.map((config) => config.provider)).toEqual([ "codex", "claudeAgent", + "cursor", ]); }); @@ -140,12 +149,14 @@ describe("provider-indexed custom model settings", () => { const defaults = { customCodexModels: ["default/codex-model"], customClaudeModels: ["claude/default-opus"], + customCursorModels: [] as const, } as const; expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); expect(getDefaultCustomModelsForProvider(defaults, "claudeAgent")).toEqual([ "claude/default-opus", ]); + expect(getDefaultCustomModelsForProvider(defaults, "cursor")).toEqual([]); }); it("patches custom models for codex", () => { @@ -160,10 +171,17 @@ describe("provider-indexed custom model settings", () => { }); }); + it("patches custom models for cursor", () => { + expect(patchCustomModels("cursor", ["my-cursor-model"])).toEqual({ + customCursorModels: ["my-cursor-model"], + }); + }); + it("builds a complete provider-indexed custom model record", () => { expect(getCustomModelsByProvider(settings)).toEqual({ codex: ["custom/codex-model"], claudeAgent: ["claude/custom-opus"], + cursor: [], }); }); @@ -182,6 +200,7 @@ describe("provider-indexed custom model settings", () => { const modelOptionsByProvider = getCustomModelOptionsByProvider({ customCodexModels: [" custom/codex-model ", "gpt-5.4", "custom/codex-model"], customClaudeModels: [" sonnet ", "claude/custom-opus", "claude/custom-opus"], + customCursorModels: [], }); expect( @@ -194,6 +213,7 @@ describe("provider-indexed custom model settings", () => { expect( modelOptionsByProvider.claudeAgent.some((option) => option.slug === "claude-sonnet-4-6"), ).toBe(true); + expect(modelOptionsByProvider.cursor.some((option) => option.slug === "auto")).toBe(true); }); }); @@ -217,6 +237,7 @@ describe("AppSettingsSchema", () => { timestampFormat: DEFAULT_TIMESTAMP_FORMAT, customCodexModels: [], customClaudeModels: [], + customCursorModels: [], }); }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 14b6a6a92d..0cb2169059 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,6 +1,11 @@ import { useCallback } from "react"; import { Option, Schema } from "effect"; -import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; +import { + CURSOR_MODEL_FAMILY_OPTIONS, + MODEL_OPTIONS_BY_PROVIDER, + TrimmedNonEmptyString, + type ProviderKind, +} from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, @@ -17,7 +22,7 @@ export const MAX_CUSTOM_MODEL_LENGTH = 256; export const TimestampFormat = Schema.Literals(["locale", "12-hour", "24-hour"]); export type TimestampFormat = typeof TimestampFormat.Type; export const DEFAULT_TIMESTAMP_FORMAT: TimestampFormat = "locale"; -type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels"; +type CustomModelSettingsKey = "customCodexModels" | "customClaudeModels" | "customCursorModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; settingsKey: CustomModelSettingsKey; @@ -31,6 +36,10 @@ export type ProviderCustomModelConfig = { const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), claudeAgent: new Set(getModelOptions("claudeAgent").map((option) => option.slug)), + cursor: new Set([ + ...MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug), + ...CURSOR_MODEL_FAMILY_OPTIONS.map((option) => option.slug), + ]), }; const withDefaults = @@ -55,6 +64,7 @@ export const AppSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe(withDefaults(() => DEFAULT_TIMESTAMP_FORMAT)), customCodexModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customClaudeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customCursorModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), }); export type AppSettings = typeof AppSettingsSchema.Type; @@ -84,6 +94,15 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record ({ - slug, - name, - isCustom: false, - })); + const options: AppModelOption[] = + provider === "cursor" + ? CURSOR_MODEL_FAMILY_OPTIONS.map(({ slug, name }) => ({ + slug, + name, + isCustom: false, + })) + : getModelOptions(provider).map(({ slug, name }) => ({ + slug, + name, + isCustom: false, + })); const seen = new Set(options.map((option) => option.slug)); const trimmedSelectedModel = selectedModel?.trim().toLowerCase(); @@ -218,6 +246,7 @@ export function getCustomModelOptionsByProvider( return { codex: getAppModelOptions("codex", customModelsByProvider.codex), claudeAgent: getAppModelOptions("claudeAgent", customModelsByProvider.claudeAgent), + cursor: getAppModelOptions("cursor", customModelsByProvider.cursor), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e628f6ea6a..5b2369a5f9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -24,7 +24,9 @@ import { import { applyClaudePromptEffortPrefix, getDefaultModel, + isCursorModelFamilySlug, normalizeModelSlug, + parseCursorModelSelection, resolveModelSlugForProvider, } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -244,6 +246,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadBranch = useStore((store) => store.setThreadBranch); const { settings } = useAppSettings(); + const setStickyComposerProvider = useComposerDraftStore((store) => store.setStickyProvider); const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); @@ -271,6 +274,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); + const setComposerDraftProviderModelOptions = useComposerDraftStore( + (store) => store.setProviderModelOptions, + ); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, @@ -2548,6 +2554,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: threadIdForSend, projectId: activeProject.id, title, + provider: selectedProvider, model: threadCreateModel, runtimeMode, interactionMode, @@ -3007,6 +3014,7 @@ export default function ChatView({ threadId }: ChatViewProps) { threadId: nextThreadId, projectId: activeProject.id, title: nextThreadTitle, + provider: selectedProvider, model: nextThreadModel, runtimeMode, interactionMode: "default", @@ -3098,18 +3106,48 @@ export default function ChatView({ threadId }: ChatViewProps) { } const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); setComposerDraftProvider(activeThread.id, provider); + if ( + provider === "cursor" && + isCursorModelFamilySlug(resolvedModel) && + activeThread.id.length > 0 + ) { + const prevDraft = useComposerDraftStore.getState().draftsByThreadId[activeThread.id]; + const prevModelRaw = + prevDraft?.model ?? + (typeof activeThread.model === "string" + ? resolveModelSlugForProvider("cursor", activeThread.model) + : null) ?? + getDefaultModel("cursor"); + const prevResolved = resolveAppModelSelection( + "cursor", + customModelsByProvider, + prevModelRaw, + ); + const prevFamily = parseCursorModelSelection( + prevResolved, + prevDraft?.modelOptions?.cursor, + ).family; + if (prevFamily !== resolvedModel) { + setComposerDraftProviderModelOptions(activeThread.id, "cursor", null, { + persistSticky: true, + }); + } + } setComposerDraftModel(activeThread.id, resolvedModel); + setStickyComposerProvider(provider); setStickyComposerModel(resolvedModel); scheduleComposerFocus(); }, [ activeThread, + customModelsByProvider, lockedProvider, scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, + setComposerDraftProviderModelOptions, + setStickyComposerProvider, setStickyComposerModel, - customModelsByProvider, ], ); const setPromptFromTraits = useCallback( @@ -3132,12 +3170,14 @@ export default function ChatView({ threadId }: ChatViewProps) { provider: selectedProvider, threadId, model: selectedModel, + modelOptions: draftModelOptions, onPromptChange: setPromptFromTraits, }); const providerTraitsPicker = renderProviderTraitsPicker({ provider: selectedProvider, threadId, model: selectedModel, + modelOptions: draftModelOptions, onPromptChange: setPromptFromTraits, }); const onEnvModeChange = useCallback( @@ -3773,6 +3813,11 @@ export default function ChatView({ threadId }: ChatViewProps) { model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} modelOptionsByProvider={modelOptionsByProvider} + cursorModelOptions={ + selectedProvider === "cursor" + ? (draftModelOptions?.cursor ?? null) + : null + } {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 83716d619a..79d7a50e9a 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -1,4 +1,4 @@ -import { type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; +import { type ProviderKind, type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; import "../../index.css"; import { page } from "vitest/browser"; @@ -8,12 +8,13 @@ import { render } from "vitest-browser-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ClaudeTraitsMenuContent } from "./ClaudeTraitsPicker"; import { CodexTraitsMenuContent } from "./CodexTraitsPicker"; +import { CursorTraitsMenuContent } from "./CursorTraitsPicker"; import { useComposerDraftStore } from "../../composerDraftStore"; async function mountMenu(props?: { model?: string; prompt?: string; - provider?: "codex" | "claudeAgent"; + provider?: ProviderKind; modelOptions?: ProviderModelOptions | null; }) { const threadId = ThreadId.makeUnsafe("thread-compact-menu"); @@ -50,13 +51,19 @@ async function mountMenu(props?: { traitsMenuContent={ provider === "codex" ? ( - ) : ( + ) : provider === "claudeAgent" ? ( - ) + ) : provider === "cursor" ? ( + + ) : undefined } onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} @@ -159,6 +166,25 @@ describe("CompactComposerControlsMenu", () => { } }); + it("shows Cursor reasoning controls for GPT-5.3 Codex family", async () => { + const mounted = await mountMenu({ + provider: "cursor", + model: "gpt-5.3-codex-high", + }); + + try { + await page.getByLabelText("More composer controls").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Reasoning"); + expect(text).toContain("Fast mode"); + }); + } finally { + await mounted.cleanup(); + } + }); + it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => { const mounted = await mountMenu({ model: "claude-opus-4-6", diff --git a/apps/web/src/components/chat/CursorTraitsPicker.tsx b/apps/web/src/components/chat/CursorTraitsPicker.tsx new file mode 100644 index 0000000000..94ec816b7b --- /dev/null +++ b/apps/web/src/components/chat/CursorTraitsPicker.tsx @@ -0,0 +1,237 @@ +import { + CURSOR_CLAUDE_OPUS_TIER_OPTIONS, + CURSOR_REASONING_OPTIONS, + type CursorReasoningOption, + type ThreadId, +} from "@t3tools/contracts"; +import type { CursorModelOptions } from "@t3tools/contracts"; +import { + cursorFamilySupportsFastWithReasoning, + cursorSelectionToPersistedModelOptions, + getCursorModelCapabilities, + parseCursorModelSelection, +} from "@t3tools/shared/model"; +import { memo, useCallback, useState } from "react"; +import { ChevronDownIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { + Menu, + MenuGroup, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator as MenuDivider, + MenuTrigger, +} from "../ui/menu"; +import { useComposerDraftStore } from "../../composerDraftStore"; + +const CURSOR_REASONING_LABELS: Record = { + low: "Low", + normal: "Normal", + high: "High", + xhigh: "Extra high", +}; + +export const CursorTraitsMenuContent = memo(function CursorTraitsMenuContentImpl({ + threadId, + model, + cursorModelOptions, +}: { + threadId: ThreadId; + model: string | null | undefined; + cursorModelOptions: CursorModelOptions | null; +}) { + const setModel = useComposerDraftStore((s) => s.setModel); + const setStickyModel = useComposerDraftStore((s) => s.setStickyModel); + const setProviderModelOptions = useComposerDraftStore((s) => s.setProviderModelOptions); + + const selection = parseCursorModelSelection(model, cursorModelOptions); + const capability = getCursorModelCapabilities(selection.family); + + const applyNextSelection = useCallback( + (nextSel: typeof selection) => { + const persisted = cursorSelectionToPersistedModelOptions(nextSel); + setModel(threadId, nextSel.family); + setProviderModelOptions(threadId, "cursor", persisted, { persistSticky: true }); + setStickyModel(nextSel.family); + }, + [setModel, setProviderModelOptions, setStickyModel, threadId], + ); + + const showFast = + capability.supportsFast && + cursorFamilySupportsFastWithReasoning(selection.family, selection.reasoning); + + if ( + !capability.supportsReasoning && + !showFast && + !capability.supportsThinking && + !capability.supportsClaudeOpusTier + ) { + return null; + } + + return ( + <> + {capability.supportsClaudeOpusTier ? ( + +
+ Opus tier +
+ { + const nextTier = CURSOR_CLAUDE_OPUS_TIER_OPTIONS.find((t) => t === value); + if (!nextTier) return; + applyNextSelection({ + ...selection, + claudeOpusTier: nextTier, + }); + }} + > + High + Max + +
+ ) : null} + {capability.supportsReasoning ? ( + +
+ Reasoning +
+ { + const nextReasoning = CURSOR_REASONING_OPTIONS.find((o) => o === value); + if (!nextReasoning) return; + applyNextSelection({ + ...selection, + reasoning: nextReasoning, + }); + }} + > + {CURSOR_REASONING_OPTIONS.map((option) => ( + + {CURSOR_REASONING_LABELS[option]} + {option === capability.defaultReasoning ? " (default)" : ""} + + ))} + +
+ ) : null} + {showFast ? ( + <> + {capability.supportsReasoning || capability.supportsClaudeOpusTier ? ( + + ) : null} + +
Fast mode
+ { + applyNextSelection({ + ...selection, + fast: value === "on", + }); + }} + > + Off + On + +
+ + ) : null} + {capability.supportsThinking ? ( + <> + {capability.supportsReasoning || showFast || capability.supportsClaudeOpusTier ? ( + + ) : null} + +
Thinking
+ { + applyNextSelection({ + ...selection, + thinking: value === "on", + }); + }} + > + Off + On (default) + +
+ + ) : null} + + ); +}); + +export const CursorTraitsPicker = memo(function CursorTraitsPicker({ + threadId, + model, + cursorModelOptions, +}: { + threadId: ThreadId; + model: string | null | undefined; + cursorModelOptions: CursorModelOptions | null; +}) { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const selection = parseCursorModelSelection(model, cursorModelOptions); + const capability = getCursorModelCapabilities(selection.family); + + const showFastTrigger = + capability.supportsFast && + cursorFamilySupportsFastWithReasoning(selection.family, selection.reasoning); + + const triggerLabel = [ + capability.supportsClaudeOpusTier + ? selection.claudeOpusTier === "max" + ? "Max" + : "High" + : null, + capability.supportsReasoning ? CURSOR_REASONING_LABELS[selection.reasoning] : null, + showFastTrigger && selection.fast ? "Fast" : null, + capability.supportsThinking ? `Thinking ${selection.thinking ? "on" : "off"}` : null, + ] + .filter(Boolean) + .join(" · "); + + if ( + !capability.supportsReasoning && + !showFastTrigger && + !capability.supportsThinking && + !capability.supportsClaudeOpusTier + ) { + return null; + } + + return ( + { + setIsMenuOpen(open); + }} + > + + } + > + {triggerLabel.length > 0 ? triggerLabel : "Traits"} + + + + + + ); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3e462f7fe..2f380f6b7f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -860,7 +860,13 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); - const preview = workEntryPreview(workEntry); + const rawPreview = workEntryPreview(workEntry); + const preview = + rawPreview && + normalizeCompactToolLabel(rawPreview).toLowerCase() === + normalizeCompactToolLabel(heading).toLowerCase() + ? null + : rawPreview; const displayText = preview ? `${heading} - ${preview}` : heading; const hasChangedFiles = (workEntry.changedFiles?.length ?? 0) > 0; const previewIsChangedFiles = hasChangedFiles && !workEntry.command && !workEntry.detail; diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 1694b374c8..1a78e0d0f0 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,4 +1,4 @@ -import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; +import { CURSOR_MODEL_FAMILY_OPTIONS, type ModelSlug, type ProviderKind } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -15,6 +15,7 @@ const MODEL_OPTIONS_BY_PROVIDER = { { slug: "gpt-5-codex", name: "GPT-5 Codex" }, { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, ], + cursor: [...CURSOR_MODEL_FAMILY_OPTIONS], } as const satisfies Record>; async function mountPicker(props: { @@ -31,6 +32,7 @@ async function mountPicker(props: { model={props.model} lockedProvider={props.lockedProvider} modelOptionsByProvider={MODEL_OPTIONS_BY_PROVIDER} + cursorModelOptions={null} onProviderModelChange={onProviderModelChange} />, { container: host }, @@ -92,6 +94,23 @@ describe("ProviderModelPicker", () => { } }); + it("keeps Cursor submenu values as family keys (traits resolve the CLI slug)", async () => { + const mounted = await mountPicker({ + provider: "cursor", + model: "claude-4.6-opus-high-thinking", + lockedProvider: "cursor", + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Codex 5.3" }).click(); + + expect(mounted.onProviderModelChange).toHaveBeenCalledWith("cursor", "gpt-5.3-codex"); + } finally { + await mounted.cleanup(); + } + }); + it("dispatches the canonical slug when a model is selected", async () => { const mounted = await mountPicker({ provider: "claudeAgent", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 95f27f39cd..5bef3d70f9 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,5 +1,16 @@ -import { type ModelSlug, type ProviderKind } from "@t3tools/contracts"; -import { resolveSelectableModel } from "@t3tools/shared/model"; +import { + CURSOR_MODEL_FAMILY_OPTIONS, + MODEL_OPTIONS_BY_PROVIDER, + type CursorModelOptions, + type ModelSlug, + type ProviderKind, +} from "@t3tools/contracts"; +import { + isCursorModelFamilySlug, + parseCursorModelSelection, + resolveModelSlugForProvider, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { memo, useState } from "react"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; import { ChevronDownIcon } from "lucide-react"; @@ -56,27 +67,57 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { activeProviderIconClassName?: string; compact?: boolean; disabled?: boolean; + disabledReason?: string; + /** Merged with `model` for Cursor trait state (family stays in `model`). */ + cursorModelOptions: CursorModelOptions | null; onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; }) { const [isMenuOpen, setIsMenuOpen] = useState(false); const activeProvider = props.lockedProvider ?? props.provider; const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; + const cursorFamilyLabel = (() => { + if (activeProvider !== "cursor") return null; + const family = parseCursorModelSelection(props.model, props.cursorModelOptions).family; + const entry = CURSOR_MODEL_FAMILY_OPTIONS.find((o) => o.slug === family); + return entry?.name ?? null; + })(); const selectedModelLabel = - selectedProviderOptions.find((option) => option.slug === props.model)?.name ?? props.model; + activeProvider === "cursor" + ? (cursorFamilyLabel ?? + MODEL_OPTIONS_BY_PROVIDER.cursor.find((option) => option.slug === props.model)?.name ?? + 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; if (!value) return; - const resolvedModel = resolveSelectableModel( - provider, - value, - props.modelOptionsByProvider[provider], - ); + let resolvedModel: ModelSlug | null = null; + if (provider === "cursor") { + if (isCursorModelFamilySlug(value)) { + resolvedModel = value as ModelSlug; + } else { + resolvedModel = + resolveSelectableModel(provider, value, props.modelOptionsByProvider[provider]) ?? + resolveModelSlugForProvider(provider, value); + } + } else { + resolvedModel = resolveSelectableModel( + provider, + value, + props.modelOptionsByProvider[provider], + ); + } if (!resolvedModel) return; props.onProviderModelChange(provider, resolvedModel); setIsMenuOpen(false); }; + const cursorRadioValue = + activeProvider === "cursor" + ? parseCursorModelSelection(props.model, props.cursorModelOptions).family + : ""; + return ( } > @@ -123,7 +165,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {props.lockedProvider !== null ? ( handleModelChange(props.lockedProvider!, value)} > {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( @@ -156,7 +198,13 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { handleModelChange(option.value, value)} > {props.modelOptionsByProvider[option.value].map((modelOption) => ( diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 139876d6fa..372ff3cc88 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -127,6 +127,40 @@ describe("getComposerProviderState", () => { }); }); + it("returns minimal state for Cursor without trait controls", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "auto", + prompt: "", + modelOptions: undefined, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: null, + modelOptionsForDispatch: undefined, + }); + }); + + it("dispatches Cursor fast traits separately from the family model key", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "composer-2", + prompt: "", + modelOptions: { + cursor: { fastMode: true }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: null, + modelOptionsForDispatch: { + cursor: { fastMode: true }, + }, + }); + }); + it("ignores Claude options while resolving codex state", () => { const state = getComposerProviderState({ provider: "codex", diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index c1ad0156ad..95262764dd 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -10,12 +10,14 @@ import { isClaudeUltrathinkPrompt, normalizeClaudeModelOptions, normalizeCodexModelOptions, + normalizeCursorModelOptions, resolveReasoningEffortForProvider, supportsClaudeUltrathinkKeyword, } from "@t3tools/shared/model"; import type { ReactNode } from "react"; import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; import { CodexTraitsMenuContent, CodexTraitsPicker } from "./CodexTraitsPicker"; +import { CursorTraitsMenuContent, CursorTraitsPicker } from "./CursorTraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; @@ -38,11 +40,13 @@ type ProviderRegistryEntry = { renderTraitsMenuContent: (input: { threadId: ThreadId; model: ModelSlug; + modelOptions?: ProviderModelOptions | null; onPromptChange: (prompt: string) => void; }) => ReactNode; renderTraitsPicker: (input: { threadId: ThreadId; model: ModelSlug; + modelOptions?: ProviderModelOptions | null; onPromptChange: (prompt: string) => void; }) => ReactNode; }; @@ -104,6 +108,30 @@ const composerProviderRegistry: Record = { ), }, + cursor: { + getState: ({ model, modelOptions }) => { + const normalized = normalizeCursorModelOptions(model, modelOptions?.cursor); + return { + provider: "cursor" as const, + promptEffort: null, + modelOptionsForDispatch: normalized ? { cursor: normalized } : undefined, + }; + }, + renderTraitsMenuContent: ({ threadId, model, modelOptions }) => ( + + ), + renderTraitsPicker: ({ threadId, model, modelOptions }) => ( + + ), + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { @@ -114,11 +142,13 @@ export function renderProviderTraitsMenuContent(input: { provider: ProviderKind; threadId: ThreadId; model: ModelSlug; + modelOptions?: ProviderModelOptions | null; onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsMenuContent({ threadId: input.threadId, model: input.model, + modelOptions: input.modelOptions ?? null, onPromptChange: input.onPromptChange, }); } @@ -127,11 +157,13 @@ export function renderProviderTraitsPicker(input: { provider: ProviderKind; threadId: ThreadId; model: ModelSlug; + modelOptions?: ProviderModelOptions | null; onPromptChange: (prompt: string) => void; }): ReactNode { return composerProviderRegistry[input.provider].renderTraitsPicker({ threadId: input.threadId, model: input.model, + modelOptions: input.modelOptions ?? null, onPromptChange: input.onPromptChange, }); } diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fc62cf0a92..b760e3556d 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,7 +1,10 @@ import { CODEX_REASONING_EFFORT_OPTIONS, + CURSOR_REASONING_OPTIONS, type ClaudeCodeEffort, type CodexReasoningEffort, + type CursorModelOptions, + type CursorReasoningOption, DEFAULT_REASONING_EFFORT_BY_PROVIDER, ProjectId, ProviderInteractionMode, @@ -105,6 +108,7 @@ const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), + stickyProvider: Schema.NullOr(ProviderKind), stickyModel: Schema.NullOr(Schema.String), stickyModelOptions: ProviderModelOptions, }); @@ -146,6 +150,7 @@ interface ComposerDraftStoreState { draftsByThreadId: Record; draftThreadsByThreadId: Record; projectDraftThreadIdByProjectId: Record; + stickyProvider: ProviderKind | null; stickyModel: string | null; stickyModelOptions: ProviderModelOptions; getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; @@ -177,6 +182,7 @@ interface ComposerDraftStoreState { clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; + setStickyProvider: (provider: ProviderKind | null | undefined) => void; setStickyModel: (model: string | null | undefined) => void; setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void; setPrompt: (threadId: ThreadId, prompt: string) => void; @@ -228,6 +234,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze) : null; + const cursorCandidate = + candidate?.cursor && typeof candidate.cursor === "object" + ? (candidate.cursor as Record) + : null; const codexReasoningEffort: CodexReasoningEffort | undefined = codexCandidate?.reasoningEffort === "low" || @@ -407,12 +418,41 @@ function normalizeProviderModelOptions( } : undefined; - if (!codex && !claude) { + const cursorReasoningRaw = cursorCandidate?.reasoning; + const cursorReasoning: CursorReasoningOption | undefined = + typeof cursorReasoningRaw === "string" && + (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorReasoningRaw) + ? (cursorReasoningRaw as CursorReasoningOption) + : undefined; + const cursorFastMode = cursorCandidate?.fastMode === true; + const cursorThinkingFalse = cursorCandidate?.thinking === false; + const cursorClaudeOpusTierRaw = cursorCandidate?.claudeOpusTier; + const cursorClaudeOpusTier = + cursorClaudeOpusTierRaw === "max" || cursorClaudeOpusTierRaw === "high" + ? cursorClaudeOpusTierRaw + : undefined; + const defaultCursorReasoning = + DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor as CursorReasoningOption; + + const cursor: CursorModelOptions | undefined = + cursorCandidate !== null + ? { + ...(cursorReasoning && cursorReasoning !== defaultCursorReasoning + ? { reasoning: cursorReasoning } + : {}), + ...(cursorFastMode ? { fastMode: true } : {}), + ...(cursorThinkingFalse ? { thinking: false } : {}), + ...(cursorClaudeOpusTier === "max" ? { claudeOpusTier: "max" } : {}), + } + : undefined; + + if (!codex && !claude && cursor === undefined) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), + ...(cursor !== undefined ? { cursor } : {}), }; } @@ -711,6 +751,7 @@ function migratePersistedComposerDraftStoreState( const rawDraftMap = candidate.draftsByThreadId; const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; + const stickyProvider = normalizeProviderKind(candidate.stickyProvider); const stickyModel = typeof candidate.stickyModel === "string" ? (normalizeModelSlug(candidate.stickyModel, "codex") ?? null) @@ -734,6 +775,7 @@ function migratePersistedComposerDraftStoreState( draftsByThreadId, draftThreadsByThreadId, projectDraftThreadIdByProjectId, + stickyProvider, stickyModel, stickyModelOptions, }; @@ -789,6 +831,7 @@ function partializeComposerDraftStoreState( draftsByThreadId: persistedDraftsByThreadId, draftThreadsByThreadId: state.draftThreadsByThreadId, projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, + stickyProvider: state.stickyProvider, stickyModel: state.stickyModel, stickyModelOptions: state.stickyModelOptions, }; @@ -806,6 +849,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( normalizedPersistedState.draftThreadsByThreadId, normalizedPersistedState.projectDraftThreadIdByProjectId, ); + const stickyProvider = normalizeProviderKind(normalizedPersistedState.stickyProvider); const stickyModel = typeof normalizedPersistedState.stickyModel === "string" ? (normalizeModelSlug(normalizedPersistedState.stickyModel, "codex") ?? null) @@ -821,6 +865,7 @@ function normalizeCurrentPersistedComposerDraftStoreState( ), draftThreadsByThreadId, projectDraftThreadIdByProjectId, + stickyProvider, stickyModel, stickyModelOptions, }; @@ -973,6 +1018,7 @@ export const useComposerDraftStore = create()( draftsByThreadId: {}, draftThreadsByThreadId: {}, projectDraftThreadIdByProjectId: {}, + stickyProvider: null, stickyModel: null, stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, getDraftThreadByProjectId: (projectId) => { @@ -1210,8 +1256,19 @@ export const useComposerDraftStore = create()( }; }); }, + setStickyProvider: (provider) => { + const normalizedProvider = normalizeProviderKind(provider); + set((state) => { + if (state.stickyProvider === normalizedProvider) { + return state; + } + return { + stickyProvider: normalizedProvider, + }; + }); + }, setStickyModel: (model) => { - const normalizedModel = normalizeModelSlug(model, "codex") ?? null; + const normalizedModel = normalizeModelSlug(model, get().stickyProvider ?? "codex") ?? null; set((state) => { if (state.stickyModel === normalizedModel) { return state; @@ -1794,6 +1851,7 @@ export const useComposerDraftStore = create()( draftsByThreadId, draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, + stickyProvider: normalizedPersisted.stickyProvider, stickyModel: normalizedPersisted.stickyModel, stickyModelOptions: normalizedPersisted.stickyModelOptions, }; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e31809cdd2..d7321217b4 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,7 +1,6 @@ import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; import { useCallback } from "react"; -import { inferProviderForModel } from "@t3tools/shared/model"; import { type DraftThreadEnvMode, type DraftThreadState, @@ -13,6 +12,7 @@ import { useStore } from "../store"; export function useHandleNewThread() { const projects = useStore((store) => store.projects); const threads = useStore((store) => store.threads); + const stickyProvider = useComposerDraftStore((store) => store.stickyProvider); const stickyModel = useComposerDraftStore((store) => store.stickyModel); const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions); const navigate = useNavigate(); @@ -102,8 +102,10 @@ export function useHandleNewThread() { envMode: options?.envMode ?? "local", runtimeMode: DEFAULT_RUNTIME_MODE, }); + if (stickyProvider) { + setProvider(threadId, stickyProvider); + } if (stickyModel) { - setProvider(threadId, inferProviderForModel(stickyModel)); setModel(threadId, stickyModel); } if (Object.keys(stickyModelOptions).length > 0) { @@ -116,7 +118,7 @@ export function useHandleNewThread() { }); })(); }, - [navigate, routeThreadId, stickyModel, stickyModelOptions], + [navigate, routeThreadId, stickyModel, stickyModelOptions, stickyProvider], ); return { diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index acc8763fb4..d4477db787 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -65,6 +65,7 @@ function SettingsRouteView() { >({ codex: "", claudeAgent: "", + cursor: "", }); const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 4a113adebe..11f9470594 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -768,6 +768,164 @@ describe("deriveWorkLogEntries", () => { ]); }); + it("drops duplicated tool detail when it only repeats the title", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "read-file-generic", + kind: "tool.completed", + summary: "Read File", + payload: { + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + }, + }), + ]; + + const [entry] = deriveWorkLogEntries(activities, undefined); + expect(entry?.toolTitle).toBe("Read File"); + expect(entry?.detail).toBeUndefined(); + }); + + it("uses grep raw output summaries instead of repeating the generic tool label", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "grep-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "grep", + payload: { + itemType: "web_search", + title: "grep", + detail: "grep", + data: { + toolCallId: "tool-grep-1", + kind: "search", + rawInput: {}, + }, + }, + }), + makeActivity({ + id: "grep-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "grep", + payload: { + itemType: "web_search", + title: "grep", + detail: "grep", + data: { + toolCallId: "tool-grep-1", + kind: "search", + rawOutput: { + totalFiles: 19, + truncated: false, + }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "grep-complete", + toolTitle: "grep", + detail: "19 files", + itemType: "web_search", + }); + }); + + it("uses completed read-file output previews and still collapses the same tool call", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "read-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Read File", + payload: { + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + data: { + toolCallId: "tool-read-1", + kind: "read", + rawInput: {}, + }, + }, + }), + makeActivity({ + id: "read-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Read File", + payload: { + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + data: { + toolCallId: "tool-read-1", + kind: "read", + rawOutput: { + content: + 'import * as Effect from "effect/Effect"\nimport * as Layer from "effect/Layer"\n', + }, + }, + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "read-complete", + toolTitle: "Read File", + detail: 'import * as Effect from "effect/Effect"', + itemType: "dynamic_tool_call", + }); + }); + + it("collapses legacy completed tool rows that are missing tool metadata", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "legacy-read-update", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "tool.updated", + summary: "Read File", + payload: { + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + data: { + toolCallId: "tool-read-legacy", + kind: "read", + rawInput: {}, + }, + }, + }), + makeActivity({ + id: "legacy-read-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "tool.completed", + summary: "Read File", + payload: { + itemType: "dynamic_tool_call", + title: "Read File", + detail: "Read File", + }, + }), + ]; + + const entries = deriveWorkLogEntries(activities, undefined); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + id: "legacy-read-complete", + toolTitle: "Read File", + itemType: "dynamic_tool_call", + }); + expect(entries[0]?.detail).toBeUndefined(); + }); + it("collapses repeated lifecycle updates for the same tool call into one entry", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1084,13 +1242,13 @@ describe("deriveActiveWorkStartedAt", () => { }); describe("PROVIDER_OPTIONS", () => { - it("advertises Claude as available while keeping Cursor as a placeholder", () => { + it("advertises Codex, Claude, and Cursor as available providers", () => { const claude = PROVIDER_OPTIONS.find((option) => option.value === "claudeAgent"); const cursor = PROVIDER_OPTIONS.find((option) => option.value === "cursor"); expect(PROVIDER_OPTIONS).toEqual([ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, - { value: "cursor", label: "Cursor", available: false }, + { value: "cursor", label: "Cursor", available: true }, ]); expect(claude).toEqual({ value: "claudeAgent", @@ -1100,7 +1258,7 @@ describe("PROVIDER_OPTIONS", () => { expect(cursor).toEqual({ value: "cursor", label: "Cursor", - available: false, + available: true, }); }); }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 7c3ea96e65..902a8bc391 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -20,7 +20,7 @@ import type { TurnDiffSummary, } from "./types"; -export type ProviderPickerKind = ProviderKind | "cursor"; +export type ProviderPickerKind = ProviderKind; export const PROVIDER_OPTIONS: Array<{ value: ProviderPickerKind; @@ -29,7 +29,7 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { 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 { @@ -48,6 +48,7 @@ export interface WorkLogEntry { interface DerivedWorkLogEntry extends WorkLogEntry { activityKind: OrchestrationThreadActivity["kind"]; collapseKey?: string; + toolCallId?: string; } export interface PendingApproval { @@ -491,6 +492,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const command = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); + const detail = extractToolDetail(payload, title ?? activity.summary); + const toolCallId = extractToolCallId(payload); const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, @@ -500,11 +503,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo }; const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); - if (payload && typeof payload.detail === "string" && payload.detail.length > 0) { - const detail = stripTrailingExitCode(payload.detail).output; - if (detail) { - entry.detail = detail; - } + if (detail) { + entry.detail = detail; } if (command) { entry.command = command; @@ -521,6 +521,9 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (requestKind) { entry.requestKind = requestKind; } + if (toolCallId) { + entry.toolCallId = toolCallId; + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; @@ -556,7 +559,16 @@ function shouldCollapseToolLifecycleEntries( if (previous.activityKind === "tool.completed") { return false; } - return previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey; + if (previous.collapseKey !== undefined && previous.collapseKey === next.collapseKey) { + return true; + } + return ( + previous.toolCallId !== undefined && + next.toolCallId === undefined && + previous.itemType === next.itemType && + normalizeCompactToolLabel(previous.toolTitle ?? previous.label) === + normalizeCompactToolLabel(next.toolTitle ?? next.label) + ); } function mergeDerivedWorkLogEntries( @@ -570,6 +582,7 @@ function mergeDerivedWorkLogEntries( const itemType = next.itemType ?? previous.itemType; const requestKind = next.requestKind ?? previous.requestKind; const collapseKey = next.collapseKey ?? previous.collapseKey; + const toolCallId = next.toolCallId ?? previous.toolCallId; return { ...previous, ...next, @@ -580,6 +593,7 @@ function mergeDerivedWorkLogEntries( ...(itemType ? { itemType } : {}), ...(requestKind ? { requestKind } : {}), ...(collapseKey ? { collapseKey } : {}), + ...(toolCallId ? { toolCallId } : {}), }; } @@ -598,6 +612,9 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un if (entry.activityKind !== "tool.updated" && entry.activityKind !== "tool.completed") { return undefined; } + if (entry.toolCallId) { + return `tool:${entry.toolCallId}`; + } const normalizedLabel = normalizeCompactToolLabel(entry.toolTitle ?? entry.label); const detail = entry.detail?.trim() ?? ""; const itemType = entry.itemType ?? ""; @@ -635,6 +652,10 @@ function asTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function asNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + function normalizeCommandValue(value: unknown): string | null { const direct = asTrimmedString(value); if (direct) { @@ -667,6 +688,95 @@ function extractToolTitle(payload: Record | null): string | nul return asTrimmedString(payload?.title); } +function extractToolCallId(payload: Record | null): string | null { + const data = asRecord(payload?.data); + return asTrimmedString(data?.toolCallId); +} + +function normalizeInlinePreview(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function truncateInlinePreview(value: string, maxLength = 84): string { + if (value.length <= maxLength) { + return value; + } + return `${value.slice(0, maxLength - 1).trimEnd()}…`; +} + +function normalizePreviewForComparison(value: string | null | undefined): string | null { + const normalized = asTrimmedString(value); + if (!normalized) { + return null; + } + return normalizeCompactToolLabel(normalizeInlinePreview(normalized)).toLowerCase(); +} + +function summarizeToolTextOutput(value: string): string | null { + const lines = value + .split(/\r?\n/u) + .map((line) => normalizeInlinePreview(line)) + .filter((line) => line.length > 0); + const firstLine = lines.find((line) => line !== "```"); + if (firstLine) { + return truncateInlinePreview(firstLine); + } + if (lines.length > 1) { + return `${lines.length.toLocaleString()} lines`; + } + return null; +} + +function summarizeToolRawOutput(payload: Record | null): string | null { + const data = asRecord(payload?.data); + const rawOutput = asRecord(data?.rawOutput); + if (!rawOutput) { + return null; + } + + const totalFiles = asNumber(rawOutput.totalFiles); + if (totalFiles !== null) { + const suffix = rawOutput.truncated === true ? "+" : ""; + return `${totalFiles.toLocaleString()} file${totalFiles === 1 ? "" : "s"}${suffix}`; + } + + const content = asTrimmedString(rawOutput.content); + if (content) { + return summarizeToolTextOutput(content); + } + + const stdout = asTrimmedString(rawOutput.stdout); + if (stdout) { + return summarizeToolTextOutput(stdout); + } + + return null; +} + +function extractToolDetail( + payload: Record | null, + heading: string, +): string | null { + const rawDetail = asTrimmedString(payload?.detail); + const detail = rawDetail ? stripTrailingExitCode(rawDetail).output : null; + const normalizedHeading = normalizePreviewForComparison(heading); + const normalizedDetail = normalizePreviewForComparison(detail); + + if (detail && normalizedHeading !== normalizedDetail) { + return detail; + } + + const rawOutputSummary = summarizeToolRawOutput(payload); + if (rawOutputSummary) { + const normalizedRawOutputSummary = normalizePreviewForComparison(rawOutputSummary); + if (normalizedRawOutputSummary !== normalizedHeading) { + return rawOutputSummary; + } + } + + return null; +} + function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 8269b30a65..2e1e68917d 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -188,7 +188,7 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex" || providerName === "claudeAgent") { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "cursor") { return providerName; } return "codex"; @@ -196,11 +196,23 @@ function toLegacyProvider(providerName: string | null): ProviderKind { function inferProviderForThreadModel(input: { readonly model: string; + readonly threadProvider: string | null; readonly sessionProviderName: string | null; }): ProviderKind { - if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeAgent") { + if ( + input.sessionProviderName === "codex" || + input.sessionProviderName === "claudeAgent" || + input.sessionProviderName === "cursor" + ) { return input.sessionProviderName; } + if ( + input.threadProvider === "codex" || + input.threadProvider === "claudeAgent" || + input.threadProvider === "cursor" + ) { + return input.threadProvider; + } return inferProviderForModel(input.model); } @@ -248,14 +260,22 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea .filter((thread) => thread.deletedAt === null) .map((thread) => { const existing = existingThreadById.get(thread.id); + const resolvedThreadProvider = + thread.session?.providerName === "codex" || + thread.session?.providerName === "claudeAgent" || + thread.session?.providerName === "cursor" + ? thread.session.providerName + : (thread.provider ?? null); return { id: thread.id, codexThreadId: null, projectId: thread.projectId, title: thread.title, + provider: resolvedThreadProvider, model: resolveModelSlugForProvider( inferProviderForThreadModel({ model: thread.model, + threadProvider: thread.provider ?? null, sessionProviderName: thread.session?.providerName ?? null, }), thread.model, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 32a7fe02b7..7fec967ffb 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -90,6 +90,7 @@ export interface Thread { codexThreadId: string | null; projectId: ProjectId; title: string; + provider?: ProviderKind | null; model: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; diff --git a/packages/contracts/src/cursorCliModels.json b/packages/contracts/src/cursorCliModels.json new file mode 100644 index 0000000000..a4a07e7d33 --- /dev/null +++ b/packages/contracts/src/cursorCliModels.json @@ -0,0 +1,343 @@ +{ + "probeCommand": "agent models", + "generatedAt": "2026-03-24T01:37:58.372Z", + "agentVersion": "2026.02.27-e7d2ef6", + "models": [ + { + "id": "auto", + "label": "Auto" + }, + { + "id": "composer-2-fast", + "label": "Composer 2 Fast" + }, + { + "id": "composer-2", + "label": "Composer 2" + }, + { + "id": "composer-1.5", + "label": "Composer 1.5" + }, + { + "id": "gpt-5.3-codex-low", + "label": "GPT-5.3 Codex Low" + }, + { + "id": "gpt-5.3-codex-low-fast", + "label": "GPT-5.3 Codex Low Fast" + }, + { + "id": "gpt-5.3-codex", + "label": "GPT-5.3 Codex" + }, + { + "id": "gpt-5.3-codex-fast", + "label": "GPT-5.3 Codex Fast" + }, + { + "id": "gpt-5.3-codex-high", + "label": "GPT-5.3 Codex High" + }, + { + "id": "gpt-5.3-codex-high-fast", + "label": "GPT-5.3 Codex High Fast" + }, + { + "id": "gpt-5.3-codex-xhigh", + "label": "GPT-5.3 Codex Extra High" + }, + { + "id": "gpt-5.3-codex-xhigh-fast", + "label": "GPT-5.3 Codex Extra High Fast" + }, + { + "id": "gpt-5.2", + "label": "GPT-5.2" + }, + { + "id": "gpt-5.3-codex-spark-preview-low", + "label": "GPT-5.3 Codex Spark Low" + }, + { + "id": "gpt-5.3-codex-spark-preview", + "label": "GPT-5.3 Codex Spark" + }, + { + "id": "gpt-5.3-codex-spark-preview-high", + "label": "GPT-5.3 Codex Spark High" + }, + { + "id": "gpt-5.3-codex-spark-preview-xhigh", + "label": "GPT-5.3 Codex Spark Extra High" + }, + { + "id": "gpt-5.2-codex-low", + "label": "GPT-5.2 Codex Low" + }, + { + "id": "gpt-5.2-codex-low-fast", + "label": "GPT-5.2 Codex Low Fast" + }, + { + "id": "gpt-5.2-codex", + "label": "GPT-5.2 Codex" + }, + { + "id": "gpt-5.2-codex-fast", + "label": "GPT-5.2 Codex Fast" + }, + { + "id": "gpt-5.2-codex-high", + "label": "GPT-5.2 Codex High" + }, + { + "id": "gpt-5.2-codex-high-fast", + "label": "GPT-5.2 Codex High Fast" + }, + { + "id": "gpt-5.2-codex-xhigh", + "label": "GPT-5.2 Codex Extra High" + }, + { + "id": "gpt-5.2-codex-xhigh-fast", + "label": "GPT-5.2 Codex Extra High Fast" + }, + { + "id": "gpt-5.1-codex-max-low", + "label": "GPT-5.1 Codex Max Low" + }, + { + "id": "gpt-5.1-codex-max-low-fast", + "label": "GPT-5.1 Codex Max Low Fast" + }, + { + "id": "gpt-5.1-codex-max-medium", + "label": "GPT-5.1 Codex Max" + }, + { + "id": "gpt-5.1-codex-max-medium-fast", + "label": "GPT-5.1 Codex Max Medium Fast" + }, + { + "id": "gpt-5.1-codex-max-high", + "label": "GPT-5.1 Codex Max High" + }, + { + "id": "gpt-5.1-codex-max-high-fast", + "label": "GPT-5.1 Codex Max High Fast" + }, + { + "id": "gpt-5.1-codex-max-xhigh", + "label": "GPT-5.1 Codex Max Extra High" + }, + { + "id": "gpt-5.1-codex-max-xhigh-fast", + "label": "GPT-5.1 Codex Max Extra High Fast" + }, + { + "id": "gpt-5.4-high", + "label": "GPT-5.4 1M High" + }, + { + "id": "gpt-5.4-high-fast", + "label": "GPT-5.4 High Fast" + }, + { + "id": "gpt-5.4-xhigh-fast", + "label": "GPT-5.4 Extra High Fast" + }, + { + "id": "claude-4.6-opus-high-thinking", + "label": "Opus 4.6 1M Thinking" + }, + { + "id": "gpt-5.4-low", + "label": "GPT-5.4 1M Low" + }, + { + "id": "gpt-5.4-medium", + "label": "GPT-5.4 1M" + }, + { + "id": "gpt-5.4-medium-fast", + "label": "GPT-5.4 Fast" + }, + { + "id": "gpt-5.4-xhigh", + "label": "GPT-5.4 1M Extra High" + }, + { + "id": "claude-4.6-sonnet-medium", + "label": "Sonnet 4.6 1M" + }, + { + "id": "claude-4.6-sonnet-medium-thinking", + "label": "Sonnet 4.6 1M Thinking" + }, + { + "id": "claude-4.6-opus-high", + "label": "Opus 4.6 1M" + }, + { + "id": "claude-4.6-opus-max", + "label": "Opus 4.6 1M Max" + }, + { + "id": "claude-4.6-opus-max-thinking", + "label": "Opus 4.6 1M Max Thinking" + }, + { + "id": "claude-4.5-opus-high", + "label": "Opus 4.5" + }, + { + "id": "claude-4.5-opus-high-thinking", + "label": "Opus 4.5 Thinking" + }, + { + "id": "gpt-5.2-low", + "label": "GPT-5.2 Low" + }, + { + "id": "gpt-5.2-low-fast", + "label": "GPT-5.2 Low Fast" + }, + { + "id": "gpt-5.2-fast", + "label": "GPT-5.2 Fast" + }, + { + "id": "gpt-5.2-high", + "label": "GPT-5.2 High" + }, + { + "id": "gpt-5.2-high-fast", + "label": "GPT-5.2 High Fast" + }, + { + "id": "gpt-5.2-xhigh", + "label": "GPT-5.2 Extra High" + }, + { + "id": "gpt-5.2-xhigh-fast", + "label": "GPT-5.2 Extra High Fast" + }, + { + "id": "gemini-3.1-pro", + "label": "Gemini 3.1 Pro" + }, + { + "id": "gpt-5.4-mini-none", + "label": "GPT-5.4 Mini None" + }, + { + "id": "gpt-5.4-mini-low", + "label": "GPT-5.4 Mini Low" + }, + { + "id": "gpt-5.4-mini-medium", + "label": "GPT-5.4 Mini" + }, + { + "id": "gpt-5.4-mini-high", + "label": "GPT-5.4 Mini High" + }, + { + "id": "gpt-5.4-mini-xhigh", + "label": "GPT-5.4 Mini Extra High" + }, + { + "id": "gpt-5.4-nano-none", + "label": "GPT-5.4 Nano None" + }, + { + "id": "gpt-5.4-nano-low", + "label": "GPT-5.4 Nano Low" + }, + { + "id": "gpt-5.4-nano-medium", + "label": "GPT-5.4 Nano" + }, + { + "id": "gpt-5.4-nano-high", + "label": "GPT-5.4 Nano High" + }, + { + "id": "gpt-5.4-nano-xhigh", + "label": "GPT-5.4 Nano Extra High" + }, + { + "id": "grok-4-20", + "label": "Grok 4.20" + }, + { + "id": "grok-4-20-thinking", + "label": "Grok 4.20 Thinking" + }, + { + "id": "claude-4.5-sonnet", + "label": "Sonnet 4.5 1M" + }, + { + "id": "claude-4.5-sonnet-thinking", + "label": "Sonnet 4.5 1M Thinking" + }, + { + "id": "gpt-5.1-low", + "label": "GPT-5.1 Low" + }, + { + "id": "gpt-5.1", + "label": "GPT-5.1" + }, + { + "id": "gpt-5.1-high", + "label": "GPT-5.1 High" + }, + { + "id": "gemini-3-pro", + "label": "Gemini 3 Pro" + }, + { + "id": "gemini-3-flash", + "label": "Gemini 3 Flash" + }, + { + "id": "gpt-5.1-codex-mini-low", + "label": "GPT-5.1 Codex Mini Low" + }, + { + "id": "gpt-5.1-codex-mini", + "label": "GPT-5.1 Codex Mini" + }, + { + "id": "gpt-5.1-codex-mini-high", + "label": "GPT-5.1 Codex Mini High" + }, + { + "id": "claude-4-sonnet", + "label": "Sonnet 4" + }, + { + "id": "claude-4-sonnet-1m", + "label": "Sonnet 4 1M" + }, + { + "id": "claude-4-sonnet-thinking", + "label": "Sonnet 4 Thinking" + }, + { + "id": "claude-4-sonnet-1m-thinking", + "label": "Sonnet 4 1M Thinking" + }, + { + "id": "gpt-5-mini", + "label": "GPT-5 Mini" + }, + { + "id": "kimi-k2.5", + "label": "Kimi K2.5" + } + ] +} diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index dac8ce6ae0..0fed799e29 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,11 +1,20 @@ import { Schema } from "effect"; import type { ProviderKind } from "./orchestration"; +import cursorCliModels from "./cursorCliModels.json" with { type: "json" }; export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const; export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number]; export const CLAUDE_CODE_EFFORT_OPTIONS = ["low", "medium", "high", "max", "ultrathink"] as const; export type ClaudeCodeEffort = (typeof CLAUDE_CODE_EFFORT_OPTIONS)[number]; -export type ProviderReasoningEffort = CodexReasoningEffort | ClaudeCodeEffort; + +/** Cursor “reasoning” tier for GPT‑5.3 Codex–style families (encoded in model slug). */ +export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const; +export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number]; + +export type ProviderReasoningEffort = + | CodexReasoningEffort + | ClaudeCodeEffort + | CursorReasoningOption; export const CodexModelOptions = Schema.Struct({ reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)), @@ -20,9 +29,21 @@ export const ClaudeModelOptions = Schema.Struct({ }); export type ClaudeModelOptions = typeof ClaudeModelOptions.Type; +export const CURSOR_CLAUDE_OPUS_TIER_OPTIONS = ["high", "max"] as const; +export type CursorClaudeOpusTier = (typeof CURSOR_CLAUDE_OPUS_TIER_OPTIONS)[number]; + +export const CursorModelOptions = Schema.Struct({ + reasoning: Schema.optional(Schema.Literals(CURSOR_REASONING_OPTIONS)), + fastMode: Schema.optional(Schema.Boolean), + thinking: Schema.optional(Schema.Boolean), + claudeOpusTier: Schema.optional(Schema.Literals(CURSOR_CLAUDE_OPUS_TIER_OPTIONS)), +}); +export type CursorModelOptions = typeof CursorModelOptions.Type; + export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), claudeAgent: Schema.optional(ClaudeModelOptions), + cursor: Schema.optional(CursorModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -31,6 +52,33 @@ type ModelOption = { readonly name: string; }; +type CursorModelFamilyOption = { + readonly slug: string; + readonly name: string; +}; + +/** + * High-level families shown in the Cursor provider submenu (traits refine the concrete slug). + * Slug ids are aligned with `agent models` where possible; synthetic keys (`gpt-5.4-1m`, `claude-4.6-opus`, + * `claude-4.6-sonnet`) are not standalone CLI models — see `packages/shared` resolvers. + * + * Note: `agent models` had no `premium`, `composer-1`, or Claude Haiku 4.5 ids at snapshot time + * (`packages/contracts/src/cursorCliModels.json`). + */ +export const CURSOR_MODEL_FAMILY_OPTIONS = [ + { slug: "auto", name: "Auto" }, + { slug: "composer-2", name: "Composer 2" }, + { slug: "composer-1.5", name: "Composer 1.5" }, + { slug: "gpt-5.3-codex", name: "Codex 5.3" }, + { slug: "gpt-5.3-codex-spark-preview", name: "Codex 5.3 Spark" }, + { slug: "gpt-5.4-1m", name: "GPT 5.4" }, + { slug: "claude-4.6-opus", name: "Claude Opus 4.6" }, + { slug: "claude-4.6-sonnet", name: "Claude Sonnet 4.6" }, + { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" }, +] as const satisfies readonly CursorModelFamilyOption[]; + +export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"]; + export const MODEL_OPTIONS_BY_PROVIDER = { codex: [ { slug: "gpt-5.4", name: "GPT-5.4" }, @@ -45,15 +93,23 @@ export const MODEL_OPTIONS_BY_PROVIDER = { { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, ], + cursor: cursorCliModels.models.map((m) => ({ + slug: m.id, + name: m.label, + })) satisfies ReadonlyArray, } as const satisfies Record; export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; export type ModelSlug = BuiltInModelSlug | (string & {}); +/** Any built-in id returned by the Cursor CLI for `--model` (see `cursorCliModels.json`). */ +export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"]; + export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", + cursor: "claude-4.6-opus-high-thinking", }; // Backward compatibility for existing Codex-only call sites. @@ -83,14 +139,35 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record; export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { codex: "high", claudeAgent: "high", + cursor: "normal", } as const satisfies Record; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 3208adc8bb..0b0f931d09 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } 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,9 +55,17 @@ export const ClaudeProviderStartOptions = Schema.Struct({ maxThinkingTokens: Schema.optional(NonNegativeInt), }); +export const CursorProviderStartOptions = Schema.Struct({ + binaryPath: Schema.optional(TrimmedNonEmptyString), + args: Schema.optional(Schema.Array(TrimmedNonEmptyString)), + /** Passed to Cursor CLI as `-e` when set (e.g. https://api2.cursor.sh). */ + apiEndpoint: Schema.optional(TrimmedNonEmptyString), +}); + export const ProviderStartOptions = Schema.Struct({ codex: Schema.optional(CodexProviderStartOptions), claudeAgent: Schema.optional(ClaudeProviderStartOptions), + cursor: Schema.optional(CursorProviderStartOptions), }); export type ProviderStartOptions = typeof ProviderStartOptions.Type; @@ -273,6 +281,7 @@ export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + provider: Schema.optional(ProviderKind), model: TrimmedNonEmptyString, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -332,6 +341,7 @@ const ThreadCreateCommand = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + provider: Schema.optional(ProviderKind), model: TrimmedNonEmptyString, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode.pipe( @@ -634,6 +644,7 @@ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, title: TrimmedNonEmptyString, + provider: Schema.optional(ProviderKind), model: TrimmedNonEmptyString, runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), interactionMode: ProviderInteractionMode.pipe( diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index 5e034f74f2..b2ddd76427 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -74,6 +74,27 @@ describe("ProviderSessionStartInput", () => { expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000); expect(parsed.runtimeMode).toBe("full-access"); }); + + it("accepts cursor provider options", () => { + const parsed = decodeProviderSessionStartInput({ + threadId: "thread-1", + provider: "cursor", + cwd: "/tmp/workspace", + model: "default", + runtimeMode: "full-access", + providerOptions: { + cursor: { + binaryPath: "/usr/local/bin/agent", + args: ["acp"], + apiEndpoint: "https://api2.cursor.sh", + }, + }, + }); + expect(parsed.provider).toBe("cursor"); + expect(parsed.providerOptions?.cursor?.binaryPath).toBe("/usr/local/bin/agent"); + expect(parsed.providerOptions?.cursor?.args).toEqual(["acp"]); + expect(parsed.providerOptions?.cursor?.apiEndpoint).toBe("https://api2.cursor.sh"); + }); }); describe("ProviderSendTurnInput", () => { diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 2fec889b6d..df2629f994 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -22,6 +22,8 @@ const RuntimeEventRawSource = Schema.Literals([ "claude.sdk.message", "claude.sdk.permission", "codex.sdk.thread-event", + "acp.jsonrpc", + "acp.cursor.extension", ]); export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type; diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 2c8aaf1986..9efbdcc2d3 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -17,6 +17,9 @@ import { getReasoningEffortOptions, inferProviderForModel, isClaudeUltrathinkPrompt, + parseCursorModelSelection, + resolveCursorDispatchModel, + resolveCursorModelFromSelection, normalizeClaudeModelOptions, normalizeCodexModelOptions, normalizeModelSlug, @@ -203,6 +206,65 @@ describe("inferProviderForModel", () => { it("treats claude-prefixed custom slugs as claude", () => { expect(inferProviderForModel("claude-custom-internal")).toBe("claudeAgent"); }); + + it("infers cursor from Cursor-only slugs", () => { + expect(inferProviderForModel("claude-4.6-opus-high-thinking")).toBe("cursor"); + expect(inferProviderForModel("composer-1.5")).toBe("cursor"); + }); + + it("infers cursor from family slugs", () => { + expect(inferProviderForModel("composer-2")).toBe("cursor"); + expect(inferProviderForModel("gpt-5.4-1m")).toBe("cursor"); + expect(inferProviderForModel("claude-4.6-opus")).toBe("cursor"); + expect(inferProviderForModel("claude-4.6-sonnet")).toBe("cursor"); + expect(inferProviderForModel("auto")).toBe("cursor"); + }); +}); + +describe("cursor model selection helpers", () => { + it("parses GPT-5.3 Codex reasoning and fast suffixes from slugs", () => { + expect(parseCursorModelSelection("gpt-5.3-codex-high-fast")).toMatchObject({ + family: "gpt-5.3-codex", + reasoning: "high", + fast: true, + thinking: false, + }); + }); + + it("merges persisted cursor modelOptions over the family model key", () => { + expect(parseCursorModelSelection("composer-2", { fastMode: true })).toMatchObject({ + family: "composer-2", + fast: true, + }); + expect(resolveCursorDispatchModel("composer-2", { fastMode: true })).toBe("composer-2-fast"); + expect(resolveCursorDispatchModel("composer-2", undefined)).toBe("composer-2"); + }); + + it("parses and resolves Claude Opus 4.6 tiers and thinking from CLI slugs", () => { + expect(parseCursorModelSelection("claude-4.6-opus-high-thinking")).toMatchObject({ + family: "claude-4.6-opus", + thinking: true, + claudeOpusTier: "high", + }); + expect(parseCursorModelSelection("claude-4.6-opus-max")).toMatchObject({ + claudeOpusTier: "max", + thinking: false, + }); + expect( + resolveCursorModelFromSelection({ + family: "claude-4.6-opus", + thinking: true, + claudeOpusTier: "high", + }), + ).toBe("claude-4.6-opus-high-thinking"); + expect( + resolveCursorModelFromSelection({ + family: "claude-4.6-opus", + thinking: false, + claudeOpusTier: "max", + }), + ).toBe("claude-4.6-opus-max"); + }); }); describe("getDefaultReasoningEffort", () => { @@ -211,6 +273,7 @@ describe("getDefaultReasoningEffort", () => { expect(getDefaultReasoningEffort("claudeAgent")).toBe( DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent, ); + expect(getDefaultReasoningEffort("cursor")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor); }); }); @@ -224,6 +287,11 @@ describe("resolveReasoningEffortForProvider", () => { expect(resolveReasoningEffortForProvider("codex", "max")).toBeNull(); expect(resolveReasoningEffortForProvider("claudeAgent", "xhigh")).toBeNull(); }); + + it("accepts cursor reasoning tiers", () => { + expect(resolveReasoningEffortForProvider("cursor", "normal")).toBe("normal"); + expect(resolveReasoningEffortForProvider("cursor", "xhigh")).toBe("xhigh"); + }); }); describe("applyClaudePromptEffortPrefix", () => { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 2d46320753..2a4c60478e 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,6 +1,8 @@ import { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS, + CURSOR_MODEL_FAMILY_OPTIONS, + CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_REASONING_EFFORT_BY_PROVIDER, MODEL_OPTIONS_BY_PROVIDER, @@ -10,6 +12,11 @@ import { type ClaudeCodeEffort, type CodexModelOptions, type CodexReasoningEffort, + type CursorClaudeOpusTier, + type CursorModelFamily, + type CursorModelOptions, + type CursorModelSlug, + type CursorReasoningOption, type ModelSlug, type ProviderReasoningEffort, type ProviderKind, @@ -18,8 +25,458 @@ import { const MODEL_SLUG_SET_BY_PROVIDER: Record> = { claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), + cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)), }; +type CursorModelCapability = { + readonly supportsReasoning: boolean; + readonly supportsFast: boolean; + readonly supportsThinking: boolean; + readonly supportsClaudeOpusTier: boolean; + readonly defaultReasoning: CursorReasoningOption; + readonly defaultThinking: boolean; + readonly defaultClaudeOpusTier: CursorClaudeOpusTier; +}; + +const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record = { + auto: { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "composer-2": { + supportsReasoning: false, + supportsFast: true, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "composer-1.5": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "gpt-5.3-codex": { + supportsReasoning: true, + supportsFast: true, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "gpt-5.3-codex-spark-preview": { + supportsReasoning: true, + supportsFast: false, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "gpt-5.4-1m": { + supportsReasoning: true, + supportsFast: true, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "claude-4.6-opus": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: true, + supportsClaudeOpusTier: true, + defaultReasoning: "normal", + defaultThinking: true, + defaultClaudeOpusTier: "high", + }, + "claude-4.6-sonnet": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: true, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, + "gemini-3.1-pro": { + supportsReasoning: false, + supportsFast: false, + supportsThinking: false, + supportsClaudeOpusTier: false, + defaultReasoning: "normal", + defaultThinking: false, + defaultClaudeOpusTier: "high", + }, +}; + +const CURSOR_MODEL_FAMILY_SET = new Set( + CURSOR_MODEL_FAMILY_OPTIONS.map((option) => option.slug), +); + +export interface CursorModelSelection { + readonly family: CursorModelFamily; + readonly reasoning: CursorReasoningOption; + readonly fast: boolean; + readonly thinking: boolean; + readonly claudeOpusTier: CursorClaudeOpusTier; +} + +export function getCursorModelFamilyOptions() { + return CURSOR_MODEL_FAMILY_OPTIONS; +} + +export function getCursorModelCapabilities(family: CursorModelFamily) { + return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; +} + +/** Fast toggles are absent for some GPT‑5.4 1M + reasoning combinations in the live CLI model list. */ +export function cursorFamilySupportsFastWithReasoning( + family: CursorModelFamily, + reasoning: CursorReasoningOption, +): boolean { + if (!getCursorModelCapabilities(family).supportsFast) return false; + if (family === "gpt-5.4-1m" && reasoning === "low") return false; + return true; +} + +function fallbackCursorModelFamily(): CursorModelFamily { + return parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor).family; +} + +function resolveCursorModelFamily(model: string | null | undefined): CursorModelFamily { + const normalized = normalizeModelSlug(model, "cursor"); + if (!normalized) { + return fallbackCursorModelFamily(); + } + + if (normalized === "auto") { + return "auto"; + } + + if (normalized === "composer-2" || normalized === "composer-2-fast") { + return "composer-2"; + } + + if (normalized === "composer-1.5") { + return "composer-1.5"; + } + + if (normalized.startsWith("gpt-5.3-codex-spark-preview")) { + return "gpt-5.3-codex-spark-preview"; + } + + if (normalized.startsWith("gpt-5.3-codex")) { + return "gpt-5.3-codex"; + } + + if ( + normalized === "gpt-5.4-low" || + normalized === "gpt-5.4-medium" || + normalized === "gpt-5.4-medium-fast" || + normalized === "gpt-5.4-high" || + normalized === "gpt-5.4-high-fast" || + normalized === "gpt-5.4-xhigh" || + normalized === "gpt-5.4-xhigh-fast" + ) { + return "gpt-5.4-1m"; + } + + if (normalized.startsWith("claude-4.6-opus-")) { + return "claude-4.6-opus"; + } + + if (normalized.startsWith("claude-4.6-sonnet-")) { + return "claude-4.6-sonnet"; + } + + if (normalized === "gemini-3.1-pro") { + return "gemini-3.1-pro"; + } + + return CURSOR_MODEL_FAMILY_SET.has(normalized as CursorModelFamily) + ? (normalized as CursorModelFamily) + : fallbackCursorModelFamily(); +} + +function resolveCursorReasoningFromSlug(model: CursorModelSlug): CursorReasoningOption { + if (model.includes("-xhigh")) return "xhigh"; + if (model.includes("-high")) return "high"; + if (model.includes("-low")) return "low"; + return "normal"; +} + +function parseClaudeOpusFromSlug(slug: string): { + readonly tier: CursorClaudeOpusTier; + readonly thinking: boolean; +} { + return { + tier: slug.includes("opus-max") ? "max" : "high", + thinking: slug.endsWith("-thinking"), + }; +} + +function mergePersistedCursorOptionsOntoSelection( + sel: CursorModelSelection, + cursorOpts: CursorModelOptions | null | undefined, +): CursorModelSelection { + if (!cursorOpts) return sel; + let next: CursorModelSelection = sel; + if ( + typeof cursorOpts.reasoning === "string" && + (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorOpts.reasoning) + ) { + next = { ...next, reasoning: cursorOpts.reasoning }; + } + if (cursorOpts.fastMode === true) { + next = { ...next, fast: true }; + } + if (cursorOpts.fastMode === false) { + next = { ...next, fast: false }; + } + if (cursorOpts.thinking === true) { + next = { ...next, thinking: true }; + } + if (cursorOpts.thinking === false) { + next = { ...next, thinking: false }; + } + if (cursorOpts.claudeOpusTier === "max" || cursorOpts.claudeOpusTier === "high") { + next = { ...next, claudeOpusTier: cursorOpts.claudeOpusTier }; + } + return next; +} + +function parseCursorModelSelectionFromSlugOnly( + model: string | null | undefined, +): CursorModelSelection { + const family = resolveCursorModelFamily(model); + const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; + const normalized = resolveModelSlugForProvider("cursor", model) as CursorModelSlug; + + const base: Pick = { + reasoning: capability.defaultReasoning, + fast: false, + thinking: capability.defaultThinking, + claudeOpusTier: capability.defaultClaudeOpusTier, + }; + + if (capability.supportsReasoning) { + return { + family, + ...base, + reasoning: resolveCursorReasoningFromSlug(normalized), + fast: normalized.endsWith("-fast"), + thinking: false, + claudeOpusTier: "high", + }; + } + + if (family === "claude-4.6-opus") { + const parsed = parseClaudeOpusFromSlug(normalized); + return { + family, + ...base, + reasoning: capability.defaultReasoning, + fast: false, + claudeOpusTier: parsed.tier, + thinking: parsed.thinking, + }; + } + + if (family === "composer-2") { + return { + family, + ...base, + fast: normalized === "composer-2-fast", + thinking: false, + claudeOpusTier: "high", + }; + } + + if (capability.supportsThinking) { + return { + family, + ...base, + reasoning: capability.defaultReasoning, + fast: false, + thinking: normalized.includes("-thinking"), + claudeOpusTier: "high", + }; + } + + return { family, ...base }; +} + +export function parseCursorModelSelection( + model: string | null | undefined, + cursorOpts?: CursorModelOptions | null, +): CursorModelSelection { + return mergePersistedCursorOptionsOntoSelection( + parseCursorModelSelectionFromSlugOnly(model), + cursorOpts, + ); +} + +/** Minimal `cursor` modelOptions for API dispatch (non-default traits only). */ +export function normalizeCursorModelOptions( + model: string | null | undefined, + persisted: CursorModelOptions | null | undefined, +): CursorModelOptions | undefined { + const sel = parseCursorModelSelection(model, persisted); + const cap = getCursorModelCapabilities(sel.family); + const defaultReasoning = DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor as CursorReasoningOption; + const next: { + reasoning?: CursorReasoningOption; + fastMode?: boolean; + thinking?: boolean; + claudeOpusTier?: CursorClaudeOpusTier; + } = {}; + if (cap.supportsReasoning && sel.reasoning !== defaultReasoning) { + next.reasoning = sel.reasoning; + } + if (cap.supportsFast && sel.fast) { + next.fastMode = true; + } + if (cap.supportsThinking && sel.thinking === false) { + next.thinking = false; + } + if (cap.supportsClaudeOpusTier && sel.claudeOpusTier === "max") { + next.claudeOpusTier = "max"; + } + return Object.keys(next).length > 0 ? (next as CursorModelOptions) : undefined; +} + +/** Persisted options for a trait selection (null = all defaults / omit from draft). */ +export function cursorSelectionToPersistedModelOptions( + sel: CursorModelSelection, +): CursorModelOptions | null { + const cap = getCursorModelCapabilities(sel.family); + const defaultReasoning = DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor as CursorReasoningOption; + const next: { + reasoning?: CursorReasoningOption; + fastMode?: boolean; + thinking?: boolean; + claudeOpusTier?: CursorClaudeOpusTier; + } = {}; + if (cap.supportsReasoning && sel.reasoning !== defaultReasoning) { + next.reasoning = sel.reasoning; + } + if (cap.supportsFast && sel.fast) { + next.fastMode = true; + } + if (cap.supportsThinking && sel.thinking === false) { + next.thinking = false; + } + if (cap.supportsClaudeOpusTier && sel.claudeOpusTier === "max") { + next.claudeOpusTier = "max"; + } + return Object.keys(next).length > 0 ? (next as CursorModelOptions) : null; +} + +/** + * Resolves the concrete Cursor CLI `--model` id from the logical family key (or custom slug) plus + * optional persisted `modelOptions.cursor` traits. + */ +export function resolveCursorDispatchModel( + model: string | null | undefined, + cursorOpts: CursorModelOptions | null | undefined, +): string { + const normalized = normalizeModelSlug(model, "cursor") ?? DEFAULT_MODEL_BY_PROVIDER.cursor; + const hasPersistedTraits = Boolean(cursorOpts && Object.keys(cursorOpts).length > 0); + if (hasPersistedTraits && isCursorModelFamilySlug(normalized)) { + const sel = parseCursorModelSelection(normalized, cursorOpts); + return resolveCursorModelFromSelection(sel); + } + return resolveModelSlugForProvider("cursor", normalized); +} + +export function resolveCursorModelFromSelection(input: { + readonly family: CursorModelFamily; + readonly reasoning?: CursorReasoningOption | null; + readonly fast?: boolean | null; + readonly thinking?: boolean | null; + readonly claudeOpusTier?: CursorClaudeOpusTier | null; +}): CursorModelSlug { + const family = resolveCursorModelFamily(input.family); + const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; + + if (family === "composer-2") { + const slug = input.fast === true ? "composer-2-fast" : "composer-2"; + return resolveModelSlugForProvider("cursor", slug) as CursorModelSlug; + } + + if (family === "gpt-5.4-1m") { + const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") + ? (input.reasoning ?? "normal") + : capability.defaultReasoning; + const tier = reasoning === "normal" ? "medium" : reasoning; + const base = `gpt-5.4-${tier}`; + if (input.fast === true) { + const fastSlug = `${base}-fast`; + const candidate = MODEL_SLUG_SET_BY_PROVIDER.cursor.has(fastSlug) ? fastSlug : base; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + return resolveModelSlugForProvider("cursor", base) as CursorModelSlug; + } + + if (family === "gpt-5.3-codex-spark-preview") { + const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") + ? (input.reasoning ?? "normal") + : capability.defaultReasoning; + const suffix = reasoning === "normal" ? "" : `-${reasoning}`; + const candidate = `gpt-5.3-codex-spark-preview${suffix}`; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + + if (capability.supportsReasoning) { + const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal") + ? (input.reasoning ?? "normal") + : capability.defaultReasoning; + const reasoningSuffix = reasoning === "normal" ? "" : `-${reasoning}`; + const fastSuffix = input.fast === true ? "-fast" : ""; + const candidate = `${family}${reasoningSuffix}${fastSuffix}`; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + + if (family === "claude-4.6-opus") { + const tier = input.claudeOpusTier === "max" ? "max" : "high"; + const thinking = + input.thinking === false + ? false + : input.thinking === true + ? true + : capability.defaultThinking; + const base = `claude-4.6-opus-${tier}`; + const candidate = thinking ? `${base}-thinking` : base; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + + if (family === "claude-4.6-sonnet") { + const thinking = + input.thinking === false + ? false + : input.thinking === true + ? true + : capability.defaultThinking; + const candidate = thinking ? "claude-4.6-sonnet-medium-thinking" : "claude-4.6-sonnet-medium"; + return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug; + } + + return resolveModelSlugForProvider("cursor", family) as CursorModelSlug; +} + const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6"; const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6"; const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5"; @@ -150,6 +607,15 @@ export function inferProviderForModel( return "codex"; } + const normalizedCursor = normalizeModelSlug(model, "cursor"); + if (normalizedCursor && MODEL_SLUG_SET_BY_PROVIDER.cursor.has(normalizedCursor)) { + return "cursor"; + } + + if (typeof model === "string" && CURSOR_MODEL_FAMILY_SET.has(model.trim() as CursorModelFamily)) { + return "cursor"; + } + return typeof model === "string" && model.trim().startsWith("claude-") ? "claudeAgent" : fallback; } @@ -175,11 +641,15 @@ export function getReasoningEffortOptions( } return []; } + if (provider === "cursor") { + return []; + } return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider]; } export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort; export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort; +export function getDefaultReasoningEffort(provider: "cursor"): CursorReasoningOption; export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort; export function getDefaultReasoningEffort( provider: ProviderKind = "codex", @@ -216,6 +686,10 @@ export function resolveReasoningEffortForProvider( return options.includes(trimmed) ? (trimmed as ProviderReasoningEffort) : null; } +export function isCursorModelFamilySlug(slug: string): boolean { + return CURSOR_MODEL_FAMILY_SET.has(slug as CursorModelFamily); +} + export function getEffectiveClaudeCodeEffort( effort: ClaudeCodeEffort | null | undefined, ): Exclude | null { diff --git a/scripts/cursor-agent-models-probe.mjs b/scripts/cursor-agent-models-probe.mjs new file mode 100644 index 0000000000..bf56ed0b71 --- /dev/null +++ b/scripts/cursor-agent-models-probe.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * Probes the local Cursor CLI for the authoritative model id list (`agent models`). + * + * Usage: + * node scripts/cursor-agent-models-probe.mjs # print JSON to stdout + * node scripts/cursor-agent-models-probe.mjs --write # write packages/contracts/src/cursorCliModels.json + * node scripts/cursor-agent-models-probe.mjs --check # fail if snapshot is stale vs live CLI + * + * Requires `agent` on PATH (install: Cursor CLI). Uses the same auth as interactive agent. + */ +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(__dirname, ".."); +const SNAPSHOT_PATH = join(REPO_ROOT, "packages/contracts/src/cursorCliModels.json"); + +const ESC = "\u001B"; +const ANSI = new RegExp(`${ESC}\\[[0-9;]*[a-zA-Z]`, "g"); + +function stripAnsi(text) { + return text.replace(ANSI, ""); +} + +function cleanDisplayLabel(raw) { + return raw + .replace(/\s*\(default\)\s*$/i, "") + .replace(/\s*\(current\)\s*$/i, "") + .trim(); +} + +function parseModelsOutput(text) { + const lines = stripAnsi(text).split("\n"); + const models = []; + for (const line of lines) { + const trimmed = line.trim(); + const m = /^(\S+)\s+-\s+(.+)$/.exec(trimmed); + if (!m) continue; + const id = m[1]; + const label = cleanDisplayLabel(m[2]); + if (id === "Tip:" || id === "Available") continue; + models.push({ id, label }); + } + return models; +} + +function probeLiveModels() { + const r = spawnSync("agent", ["models"], { + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + if (r.error) { + throw r.error; + } + if (r.status !== 0) { + throw new Error(r.stderr || `agent models exited ${r.status}`); + } + return parseModelsOutput(r.stdout ?? ""); +} + +function agentVersion() { + const r = spawnSync("agent", ["-v"], { encoding: "utf8" }); + if (r.status !== 0) return null; + return (r.stdout ?? "").trim() || null; +} + +function main() { + const write = process.argv.includes("--write"); + const check = process.argv.includes("--check"); + + const models = probeLiveModels(); + if (models.length === 0) { + console.error( + "cursor-agent-models-probe: no models parsed (is `agent` installed and logged in?)", + ); + process.exit(1); + } + + const payload = { + probeCommand: "agent models", + generatedAt: new Date().toISOString(), + agentVersion: agentVersion(), + models, + }; + + if (write) { + writeFileSync(SNAPSHOT_PATH, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + console.error(`Wrote ${models.length} models to ${SNAPSHOT_PATH}`); + } + + if (check) { + const existing = JSON.parse(readFileSync(SNAPSHOT_PATH, "utf8")); + const want = new Set(existing.models.map((m) => m.id)); + const got = new Set(models.map((m) => m.id)); + const missing = [...want].filter((id) => !got.has(id)); + const extra = [...got].filter((id) => !want.has(id)); + if (missing.length || extra.length) { + console.error("cursor-agent-models-probe: snapshot drift vs live `agent models`"); + if (missing.length) console.error("missing from live:", missing.join(", ")); + if (extra.length) console.error("extra in live:", extra.join(", ")); + console.error("Re-run: node scripts/cursor-agent-models-probe.mjs --write"); + process.exit(1); + } + console.error(`OK: ${models.length} models match ${SNAPSHOT_PATH}`); + } + + if (!write && !check) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } +} + +main();