From 1561c77c5c303c57ff445c9dad0213391e67ae56 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Mar 2026 00:29:41 -0700 Subject: [PATCH 01/42] Add Cursor provider session and model selection support - Introduce Cursor ACP adapter and model selection probe - Preserve cursor session resume state across model changes - Propagate provider and runtime tool metadata through orchestration and UI Made-with: Cursor --- apps/server/scripts/acp-mock-agent.mjs | 279 +++ .../cursor-acp-model-selection-probe.ts | 135 ++ .../server/src/git/Services/TextGeneration.ts | 2 +- .../Layers/ProviderCommandReactor.test.ts | 151 ++ .../Layers/ProviderCommandReactor.ts | 4 +- .../Layers/ProviderRuntimeIngestion.test.ts | 90 +- .../src/provider/Layers/CursorAdapter.test.ts | 1098 ++--------- .../src/provider/Layers/CursorAdapter.ts | 1698 +++++++++++------ .../Layers/ProviderAdapterRegistry.test.ts | 33 +- .../Layers/ProviderAdapterRegistry.ts | 22 +- .../Layers/ProviderSessionDirectory.ts | 17 +- .../src/provider/Services/CursorAdapter.ts | 4 +- apps/server/src/provider/acp/AcpErrors.ts | 24 + .../provider/acp/AcpJsonRpcConnection.test.ts | 396 +--- .../src/provider/acp/AcpJsonRpcConnection.ts | 250 +++ apps/server/src/provider/acp/AcpTypes.ts | 147 ++ .../provider/acp/CursorAcpCliProbe.test.ts | 152 +- apps/server/src/provider/acp/index.ts | 3 + apps/server/src/serverLayers.ts | 151 ++ apps/server/src/serverSettings.ts | 2 +- apps/web/src/components/ChatView.tsx | 755 +++++++- .../components/KeybindingsToast.browser.tsx | 9 +- .../CompactComposerControlsMenu.browser.tsx | 84 +- .../components/chat/CursorTraitsPicker.tsx | 238 +++ .../src/components/chat/MessagesTimeline.tsx | 3 +- .../chat/ProviderModelPicker.browser.tsx | 109 +- .../components/chat/ProviderModelPicker.tsx | 70 +- .../chat/composerProviderRegistry.test.tsx | 36 +- .../chat/composerProviderRegistry.tsx | 109 +- apps/web/src/composerDraftStore.ts | 72 +- apps/web/src/modelSelection.ts | 15 +- apps/web/src/routes/_chat.settings.tsx | 1332 +++++++++++++ apps/web/src/session-logic.test.ts | 57 +- apps/web/src/session-logic.ts | 157 +- apps/web/src/store.ts | 2 +- packages/contracts/src/cursorCliModels.json | 343 ++++ packages/contracts/src/model.ts | 145 +- packages/contracts/src/orchestration.ts | 23 +- packages/contracts/src/providerRuntime.ts | 19 +- packages/contracts/src/settings.ts | 50 +- packages/shared/src/model.test.ts | 247 ++- packages/shared/src/model.ts | 670 ++++++- scripts/cursor-agent-models-probe.mjs | 115 ++ 43 files changed, 6541 insertions(+), 2777 deletions(-) create mode 100644 apps/server/scripts/acp-mock-agent.mjs create mode 100644 apps/server/scripts/cursor-acp-model-selection-probe.ts create mode 100644 apps/server/src/provider/acp/AcpErrors.ts create mode 100644 apps/server/src/provider/acp/AcpJsonRpcConnection.ts create mode 100644 apps/server/src/provider/acp/AcpTypes.ts create mode 100644 apps/server/src/provider/acp/index.ts create mode 100644 apps/server/src/serverLayers.ts create mode 100644 apps/web/src/components/chat/CursorTraitsPicker.tsx create mode 100644 apps/web/src/routes/_chat.settings.tsx create mode 100644 packages/contracts/src/cursorCliModels.json create mode 100644 scripts/cursor-agent-models-probe.mjs diff --git a/apps/server/scripts/acp-mock-agent.mjs b/apps/server/scripts/acp-mock-agent.mjs new file mode 100644 index 000000000000..a261ed692dab --- /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 000000000000..efcc78affbc9 --- /dev/null +++ b/apps/server/scripts/cursor-acp-model-selection-probe.ts @@ -0,0 +1,135 @@ +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 { ServerSettingsService } from "../src/serverSettings.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(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), +); + +const program = Effect.gen(function* () { + const adapter = yield* CursorAdapter; + const serverSettings = yield* ServerSettingsService; + 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 cursorModelOptions = fastMode ? { fastMode: true as const } : undefined; + const dispatchedModel = resolveCursorDispatchModel(model, cursorModelOptions); + + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + + yield* adapter.startSession({ + threadId, + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { + provider: "cursor", + model, + ...(cursorModelOptions ? { options: cursorModelOptions } : {}), + }, + }); + + 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/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 78d37a010884..f02627008d9f 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -13,7 +13,7 @@ import type { ChatAttachment, ModelSelection } from "@t3tools/contracts"; import type { TextGenerationError } from "@t3tools/contracts"; /** Providers that support git text generation (commit messages, PR content, branch names). */ -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "opencode"; +export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index dfdfab926f85..d2abcffe6cea 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -853,6 +853,102 @@ describe("ProviderCommandReactor", () => { }); }); + it("preserves the active session model when in-session model switching is unsupported", async () => { + const harness = await createHarness({ sessionModelSwitch: "unsupported" }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-1"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-1"), + role: "user", + text: "first", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-2"), + threadId: ThreadId.makeUnsafe("thread-1"), + message: { + messageId: asMessageId("user-message-unsupported-2"), + role: "user", + text: "second", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ + threadId: ThreadId.makeUnsafe("thread-1"), + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + }); + }); + + 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", + modelSelection: { 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-shared-slug"), + threadId: ThreadId.makeUnsafe("thread-shared-slug"), + message: { + messageId: asMessageId("user-message-shared-slug"), + role: "user", + text: "first", + attachments: [], + }, + modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, + 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); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, + }); + }); + it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); @@ -970,6 +1066,61 @@ describe("ProviderCommandReactor", () => { }); }); + it("restarts cursor sessions on model changes while preserving resumeCursor", async () => { + const harness = await createHarness({ + threadModelSelection: { provider: "cursor", model: "composer-2" }, + sessionModelSwitch: "unsupported", + }); + 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: [], + }, + modelSelection: { 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: [], + }, + modelSelection: { 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({ + modelSelection: { 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 8b3321ec3ea8..ce7274b21b57 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -356,9 +356,7 @@ const make = Effect.gen(function* () { return existingSessionThreadId; } - const resumeCursor = shouldRestartForModelChange - ? undefined - : (activeSession?.resumeCursor ?? undefined); + const resumeCursor = providerChanged ? undefined : (activeSession?.resumeCursor ?? undefined); yield* Effect.logInfo("provider command reactor restarting provider session", { threadId, existingSessionThreadId, diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 577c5050ea18..98b9d87925ad 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -738,7 +738,8 @@ describe("ProviderRuntimeIngestion", () => { payload: { itemType: "dynamic_tool_call", status: "completed", - title: "Read file", + title: "Read File", + detail: "Read File", data: { toolCallId: "tool-read-1", kind: "read", @@ -771,98 +772,13 @@ describe("ProviderRuntimeIngestion", () => { : undefined; expect(activity?.kind).toBe("tool.completed"); - expect(activity?.summary).toBe("Read file"); expect(payload?.itemType).toBe("dynamic_tool_call"); - expect(payload?.detail).toBeUndefined(); + 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("normalizes command execution activities to ran-command summaries", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - - harness.emit({ - type: "item.completed", - eventId: asEventId("evt-command-completed"), - provider: "cursor", - createdAt: now, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-command-completed"), - itemId: asItemId("item-command-completed"), - payload: { - itemType: "command_execution", - status: "completed", - title: "Ran command", - detail: "bun run lint", - data: { - toolCallId: "tool-command-1", - kind: "execute", - command: "bun run lint", - }, - }, - }); - - const thread = await waitForThread(harness.engine, (entry) => - entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-command-completed", - ), - ); - const activity = thread.activities.find( - (entry: ProviderRuntimeTestActivity) => entry.id === "evt-command-completed", - ); - const payload = - activity?.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : undefined; - - expect(activity?.summary).toBe("Ran command"); - expect(payload?.detail).toBe("bun run lint"); - }); - - it("uses structured read-file paths when available", async () => { - const harness = await createHarness(); - const now = new Date().toISOString(); - - harness.emit({ - type: "item.completed", - eventId: asEventId("evt-read-path-completed"), - provider: "cursor", - createdAt: now, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-read-path"), - itemId: asItemId("item-read-path"), - payload: { - itemType: "dynamic_tool_call", - status: "completed", - title: "Read file", - detail: "/tmp/app.ts", - data: { - toolCallId: "tool-read-path-1", - kind: "read", - locations: [{ path: "/tmp/app.ts" }], - }, - }, - }); - - const thread = await waitForThread(harness.engine, (entry) => - entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-read-path-completed", - ), - ); - const activity = thread.activities.find( - (entry: ProviderRuntimeTestActivity) => entry.id === "evt-read-path-completed", - ); - const payload = - activity?.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : undefined; - - expect(activity?.summary).toBe("Read file"); - expect(payload?.detail).toBe("/tmp/app.ts"); - }); - 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/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index e6bbd7569a48..fc429ecaa025 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -8,6 +8,7 @@ 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 { ServerSettingsService } from "../../serverSettings.ts"; @@ -15,13 +16,9 @@ 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.ts"); -const bunExe = "bun"; +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); -async function makeMockAgentWrapper( - extraEnv?: Record, - options?: { initialDelaySeconds?: number }, -) { +async function makeMockAgentWrapper(extraEnv?: Record) { const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-mock-")); const wrapperPath = path.join(dir, "fake-agent.sh"); const envExports = Object.entries(extraEnv ?? {}) @@ -29,8 +26,7 @@ async function makeMockAgentWrapper( .join("\n"); const script = `#!/bin/sh ${envExports} -${options?.initialDelaySeconds ? `sleep ${JSON.stringify(String(options.initialDelaySeconds))}` : ""} -exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@" +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" `; await writeFile(wrapperPath, script, "utf8"); await chmod(wrapperPath, 0o755); @@ -52,7 +48,7 @@ printf '%s\t' "$@" >> ${JSON.stringify(argvLogPath)} printf '\n' >> ${JSON.stringify(argvLogPath)} export T3_ACP_REQUEST_LOG_PATH=${JSON.stringify(requestLogPath)} ${envExports} -exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@" +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(mockAgentPath)} "$@" `; await writeFile(wrapperPath, script, "utf8"); await chmod(wrapperPath, 0o755); @@ -77,27 +73,10 @@ async function readJsonLines(filePath: string) { .map((line) => JSON.parse(line) as Record); } -async function waitForFileContent(filePath: string, attempts = 40) { - for (let attempt = 0; attempt < attempts; attempt += 1) { - try { - const raw = await readFile(filePath, "utf8"); - if (raw.trim().length > 0) { - return raw; - } - } catch {} - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(`Timed out waiting for file content at ${filePath}`); -} - const cursorAdapterTestLayer = it.layer( makeCursorAdapterLive().pipe( Layer.provideMerge(ServerSettingsService.layerTest()), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-cursor-adapter-test-", - }), - ), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), Layer.provideMerge(NodeServices.layer), ), ); @@ -107,12 +86,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { Effect.gen(function* () { const adapter = yield* CursorAdapter; const settings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-mock-thread"); + const threadId = ThreadId.makeUnsafe("cursor-mock-thread"); const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 9).pipe( + const runtimeEventsFiber = yield* Stream.take(adapter.streamEvents, 7).pipe( Stream.runCollect, Effect.forkChild, ); @@ -146,135 +125,40 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { "thread.started", "turn.started", "turn.plan.updated", - "item.started", "content.delta", - "item.completed", "turn.completed", ] as const) { assert.include(types, t); } - const assistantStarted = runtimeEvents.find( - (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", - ); - assert.isDefined(assistantStarted); - 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"); - assert.match(String(delta.itemId), /^assistant:mock-session-1:segment:0$/); } - const assistantCompleted = runtimeEvents.find( - (event) => - event.type === "item.completed" && event.payload.itemType === "assistant_message", - ); - assert.isDefined(assistantCompleted); - const planUpdate = runtimeEvents.find((event) => event.type === "turn.plan.updated"); assert.isDefined(planUpdate); if (planUpdate?.type === "turn.plan.updated") { - assert.deepStrictEqual(planUpdate.payload.plan, [ - { step: "Inspect mock ACP state", status: "completed" }, - { step: "Implement the requested change", status: "inProgress" }, - ]); + 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("closes the ACP child process when a session stops", () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const settings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-stop-session-close"); - const tempDir = yield* Effect.promise(() => - mkdtemp(path.join(os.tmpdir(), "cursor-adapter-exit-log-")), - ); - const exitLogPath = path.join(tempDir, "exit.log"); - - const wrapperPath = yield* Effect.promise(() => - makeMockAgentWrapper({ - T3_ACP_EXIT_LOG_PATH: exitLogPath, - }), - ); - yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - - yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, - }); - - yield* adapter.stopSession(threadId); - - const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); - assert.include(exitLog, "SIGTERM"); - }), - ); - - it.effect( - "serializes concurrent startSession calls for the same thread and closes the replaced ACP session", - () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const settings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-concurrent-start-session"); - const tempDir = yield* Effect.promise(() => - mkdtemp(path.join(os.tmpdir(), "cursor-adapter-concurrent-exit-log-")), - ); - const exitLogPath = path.join(tempDir, "exit.log"); - - const wrapperPath = yield* Effect.promise(() => - makeMockAgentWrapper( - { - T3_ACP_EXIT_LOG_PATH: exitLogPath, - }, - { initialDelaySeconds: 0.2 }, - ), - ); - yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - - const [firstSession, secondSession] = yield* Effect.all( - [ - adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, - }), - adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, - }), - ], - { concurrency: "unbounded" }, - ); - - assert.equal(firstSession.threadId, threadId); - assert.equal(secondSession.threadId, threadId); - - yield* adapter.stopSession(threadId); - - const exitLog = yield* Effect.promise(() => waitForFileContent(exitLogPath)); - assert.equal(exitLog.match(/SIGTERM/g)?.length ?? 0, 2); - }), - ); - it.effect("rejects startSession when provider mismatches", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const result = yield* adapter .startSession({ - threadId: ThreadId.make("bad-provider"), + threadId: ThreadId.makeUnsafe("bad-provider"), provider: "codex", cwd: process.cwd(), runtimeMode: "full-access", @@ -285,11 +169,11 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); - it.effect("maps app plan mode onto the ACP plan session mode", () => + it.effect("selects the Cursor model via CLI argv instead of ACP request payloads", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-plan-mode-probe"); + 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"); @@ -299,772 +183,256 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ); yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - yield* adapter.startSession({ + const dispatchedModel = resolveCursorDispatchModel("composer-2", { fastMode: true }); + const session = yield* adapter.startSession({ threadId, provider: "cursor", cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "composer-2" }, + modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, }); + assert.equal(session.model, "composer-2"); + yield* adapter.sendTurn({ threadId, - input: "plan this change", + input: "probe model selection", attachments: [], - interactionMode: "plan", }); yield* adapter.stopSession(threadId); - const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const modeRequest = requests - .toReversed() - .find( - (entry) => - entry.method === "session/set_mode" || - (entry.method === "session/set_config_option" && - (entry.params as Record | undefined)?.configId === "mode"), - ); - assert.isDefined(modeRequest); - assert.equal( - (modeRequest?.params as Record | undefined)?.sessionId, - "mock-session-1", - ); - assert.include( - ["architect", "plan"], - String( - (modeRequest?.params as Record | undefined)?.modeId ?? - (modeRequest?.params as Record | undefined)?.value, - ), - ); - }), - ); - - it.effect( - "applies initial model and mode configuration during startSession and skips repeating it on first send", - () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-initial-config-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* serverSettings.updateSettings({ - providers: { cursor: { binaryPath: wrapperPath } }, - }); - - const modelSelection = { - provider: "cursor" as const, - model: "gpt-5.4", - options: { - reasoning: "xhigh" as const, - contextWindow: "1m", - fastMode: true, - }, - }; - - yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection, - }); - - yield* Effect.promise(() => waitForFileContent(requestLogPath)); - - const requestsAfterStart = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const configIdsAfterStart = requestsAfterStart.flatMap((entry) => - entry.method === "session/set_config_option" && - typeof (entry.params as Record | undefined)?.configId === "string" - ? [String((entry.params as Record).configId)] - : [], - ); - assert.deepStrictEqual(configIdsAfterStart, [ - "model", - "reasoning", - "context", - "fast", - "mode", - ]); - - yield* adapter.sendTurn({ - threadId, - input: "hello mock", - attachments: [], - modelSelection, - interactionMode: "default", - }); - yield* adapter.stopSession(threadId); - - const finalRequests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const finalConfigIds = finalRequests.flatMap((entry) => - entry.method === "session/set_config_option" && - typeof (entry.params as Record | undefined)?.configId === "string" - ? [String((entry.params as Record).configId)] - : [], - ); - assert.deepStrictEqual(finalConfigIds, ["model", "reasoning", "context", "fast", "mode"]); - assert.equal(finalRequests.filter((entry) => entry.method === "session/prompt").length, 1); - }), - ); - - it.effect( - "streams ACP tool calls and approvals on the active turn in approval-required mode", - () => - 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 serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-tool-call-probe"); - const runtimeEvents: Array = []; - const settledEventTypes = new Set(); - const settledEventsReady = yield* Deferred.make(); - - const wrapperPath = yield* Effect.promise(() => - makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), - ); - yield* serverSettings.updateSettings({ - providers: { cursor: { binaryPath: wrapperPath } }, - }); - - 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.make(String(event.requestId)), - "accept", - ); - } - if ( - event.type === "turn.completed" || - (event.type === "item.completed" && event.payload.itemType === "command_execution") || - 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: "approval-required", - modelSelection: { provider: "cursor", model: "default" }, - }); - - 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"); - // ACP updates can arrive either as distinct pending + in-progress events - // or as a single coalesced in-progress update before approval resolves. - assert.isAtLeast(toolUpdates.length, 1); - 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" && event.payload.itemType === "command_execution", - ); - 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"); - assert.equal(String(contentDelta.itemId), "assistant:mock-session-1:segment:0"); - } - }); - - 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(ServerSettingsService.layerTest()), - Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { - prefix: "t3code-cursor-adapter-test-", - }), - ), - Layer.provideMerge(NodeServices.layer), - ), - ), - ), - ); - - it.effect( - "auto-approves ACP tool permissions in full-access mode without approval runtime events", - () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-full-access-auto-approve"); - const runtimeEvents: Array = []; - const settledEventTypes = new Set(); - const settledEventsReady = yield* Deferred.make(); - 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, { T3_ACP_EMIT_TOOL_CALLS: "1" }), - ); - yield* serverSettings.updateSettings({ - providers: { cursor: { binaryPath: wrapperPath } }, - }); - - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.gen(function* () { - runtimeEvents.push(event); - if (String(event.threadId) !== String(threadId)) { - return; - } - if ( - event.type === "turn.completed" || - (event.type === "item.completed" && event.payload.itemType === "command_execution") || - event.type === "content.delta" - ) { - settledEventTypes.add(event.type); - if (settledEventTypes.size === 3) { - yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); - } - } - }), - ).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, - }); - - const turn = yield* adapter.sendTurn({ - threadId, - input: "run a tool call", - attachments: [], - }); - - yield* Deferred.await(settledEventsReady); - yield* Fiber.interrupt(runtimeEventsFiber); + const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); + assert.deepStrictEqual(argvRuns, [["--model", dispatchedModel, "acp"]]); - const turnEvents = runtimeEvents.filter( - (event) => - String(event.threadId) === String(threadId) && - String(event.turnId) === String(turn.turnId), - ); - assert.notInclude( - turnEvents.map((event) => event.type), - "request.opened", - ); - assert.notInclude( - turnEvents.map((event) => event.type), - "request.resolved", - ); - assert.includeMembers( - turnEvents.map((event) => event.type), - ["item.updated", "item.completed", "content.delta", "turn.completed"], - ); + 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 requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const permissionResponse = requests.find( - (entry) => - !("method" in entry) && - typeof entry.result === "object" && - entry.result !== null && - "outcome" in entry.result && - typeof entry.result.outcome === "object" && - entry.result.outcome !== null && - "outcome" in entry.result.outcome && - entry.result.outcome.outcome === "selected" && - "optionId" in entry.result.outcome && - entry.result.outcome.optionId === "allow-always", - ); - assert.isDefined(permissionResponse); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); + assert.isDefined(promptRequest); + assert.deepStrictEqual( + Object.keys((promptRequest?.params as Record) ?? {}).toSorted(), + ["prompt", "sessionId"], + ); - yield* adapter.stopSession(threadId); - }), + 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("segments assistant messages around ACP tool activity in full-access mode", () => + it.effect("maps app plan mode onto the ACP plan session mode", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-assistant-tool-segmentation"); - const runtimeEvents: Array = []; - const settledEventTypes = new Set(); - const settledEventsReady = yield* Deferred.make(); - + 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(() => - makeMockAgentWrapper({ T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1" }), + makeProbeWrapper(requestLogPath, argvLogPath), ); - yield* serverSettings.updateSettings({ - providers: { cursor: { binaryPath: wrapperPath } }, - }); - - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => - Effect.gen(function* () { - runtimeEvents.push(event); - if (String(event.threadId) !== String(threadId)) { - return; - } - if ( - event.type === "content.delta" || - (event.type === "item.completed" && event.payload.itemType === "command_execution") || - event.type === "turn.completed" - ) { - if (event.type === "content.delta") { - settledEventTypes.add(`delta:${event.payload.delta}`); - } else { - settledEventTypes.add(event.type); - } - if ( - settledEventTypes.has("delta:before tool") && - settledEventTypes.has("delta:after tool") && - settledEventTypes.has("item.completed") && - settledEventTypes.has("turn.completed") - ) { - yield* Deferred.succeed(settledEventsReady, undefined).pipe(Effect.orDie); - } - } - }), - ).pipe(Effect.forkChild); + yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); yield* adapter.startSession({ threadId, provider: "cursor", cwd: process.cwd(), runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + modelSelection: { provider: "cursor", model: "composer-2" }, }); - const turn = yield* adapter.sendTurn({ + yield* adapter.sendTurn({ threadId, - input: "run an interleaved tool call", + input: "plan this change", attachments: [], + interactionMode: "plan", }); - - yield* Deferred.await(settledEventsReady); - yield* Fiber.interrupt(runtimeEventsFiber); - - const turnEvents = runtimeEvents.filter( - (event) => - String(event.threadId) === String(threadId) && - String(event.turnId) === String(turn.turnId), - ); - const firstAssistantStartIndex = turnEvents.findIndex( - (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", - ); - const firstAssistantDeltaIndex = turnEvents.findIndex( - (event) => event.type === "content.delta" && event.payload.delta === "before tool", - ); - const assistantBoundaryIndex = turnEvents.findIndex( - (event) => - event.type === "item.completed" && event.payload.itemType === "assistant_message", - ); - const toolUpdateIndex = turnEvents.findIndex( - (event) => event.type === "item.updated" && event.payload.itemType === "command_execution", - ); - const toolCompletedIndex = turnEvents.findIndex( - (event) => - event.type === "item.completed" && event.payload.itemType === "command_execution", - ); - const secondAssistantStartIndex = turnEvents.findIndex( - (event, index) => - index > toolCompletedIndex && - event.type === "item.started" && - event.payload.itemType === "assistant_message", - ); - const secondAssistantDeltaIndex = turnEvents.findIndex( - (event) => event.type === "content.delta" && event.payload.delta === "after tool", - ); - - assert.isAtLeast(firstAssistantStartIndex, 0); - assert.isAtLeast(firstAssistantDeltaIndex, 0); - assert.isAtLeast(assistantBoundaryIndex, 0); - assert.isAtLeast(toolUpdateIndex, 0); - assert.isAtLeast(toolCompletedIndex, 0); - assert.isAtLeast(secondAssistantStartIndex, 0); - assert.isAtLeast(secondAssistantDeltaIndex, 0); - assert.isBelow(firstAssistantStartIndex, firstAssistantDeltaIndex); - assert.isBelow(firstAssistantDeltaIndex, assistantBoundaryIndex); - assert.isBelow(assistantBoundaryIndex, toolUpdateIndex); - assert.isBelow(toolUpdateIndex, toolCompletedIndex); - assert.isBelow(toolCompletedIndex, secondAssistantStartIndex); - assert.isBelow(secondAssistantStartIndex, secondAssistantDeltaIndex); - - const assistantStarts = turnEvents.filter( - (event) => event.type === "item.started" && event.payload.itemType === "assistant_message", - ); - const assistantDeltas = turnEvents.filter((event) => event.type === "content.delta"); - assert.lengthOf(assistantStarts, 2); - assert.lengthOf(assistantDeltas, 2); - if ( - assistantStarts[0]?.type === "item.started" && - assistantStarts[1]?.type === "item.started" && - assistantDeltas[0]?.type === "content.delta" && - assistantDeltas[1]?.type === "content.delta" - ) { - assert.notEqual(String(assistantStarts[0].itemId), String(assistantStarts[1].itemId)); - assert.equal(String(assistantDeltas[0].itemId), String(assistantStarts[0].itemId)); - assert.equal(String(assistantDeltas[1].itemId), String(assistantStarts[1].itemId)); - } - 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("cancels pending ACP approvals and marks the turn cancelled when interrupted", () => + 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 serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-cancel-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 threadId = ThreadId.makeUnsafe("cursor-tool-call-probe"); + const runtimeEvents: Array = []; + const settledEventTypes = new Set(); + const settledEventsReady = yield* Deferred.make(); + const wrapperPath = yield* Effect.promise(() => - makeProbeWrapper(requestLogPath, argvLogPath, { T3_ACP_EMIT_TOOL_CALLS: "1" }), + makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), ); yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - const requestResolvedReady = yield* Deferred.make(); - const turnCompletedReady = yield* Deferred.make(); - let interrupted = false; - - const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) => + yield* Stream.runForEach(adapter.streamEvents, (event) => Effect.gen(function* () { + runtimeEvents.push(event); if (String(event.threadId) !== String(threadId)) { return; } - if (event.type === "request.opened" && !interrupted) { - interrupted = true; - yield* adapter.interruptTurn(threadId); - return; - } - if (event.type === "request.resolved") { - yield* Deferred.succeed(requestResolvedReady, event).pipe(Effect.ignore); - return; + if (event.type === "request.opened" && event.requestId) { + yield* adapter.respondToRequest( + threadId, + ApprovalRequestId.makeUnsafe(String(event.requestId)), + "accept", + ); } - if (event.type === "turn.completed") { - yield* Deferred.succeed(turnCompletedReady, event).pipe(Effect.ignore); + 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); - yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "approval-required", - modelSelection: { provider: "cursor", model: "default" }, - }); - - const sendTurnFiber = yield* adapter - .sendTurn({ + const program = Effect.gen(function* () { + yield* adapter.startSession({ threadId, - input: "cancel this turn", - attachments: [], - }) - .pipe(Effect.forkChild); - - const requestResolved = yield* Deferred.await(requestResolvedReady); - const turnCompleted = yield* Deferred.await(turnCompletedReady); - yield* Fiber.join(sendTurnFiber); - yield* Fiber.interrupt(runtimeEventsFiber); - - assert.equal(requestResolved.type, "request.resolved"); - if (requestResolved.type === "request.resolved") { - assert.equal(requestResolved.payload.decision, "cancel"); - } - - assert.equal(turnCompleted.type, "turn.completed"); - if (turnCompleted.type === "turn.completed") { - assert.equal(turnCompleted.payload.state, "cancelled"); - assert.equal(turnCompleted.payload.stopReason, "cancelled"); - } - - const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); - assert.isTrue( - requests.some( - (entry) => - !("method" in entry) && - typeof entry.result === "object" && - entry.result !== null && - "outcome" in entry.result && - typeof entry.result.outcome === "object" && - entry.result.outcome !== null && - "outcome" in entry.result.outcome && - entry.result.outcome.outcome === "cancelled", - ), - ); - - yield* adapter.stopSession(threadId); - }), - ); - it.effect("stopping a session settles pending approval waits", () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-stop-pending-approval"); - const approvalRequested = yield* Deferred.make(); - - const wrapperPath = yield* Effect.promise(() => - makeMockAgentWrapper({ T3_ACP_EMIT_TOOL_CALLS: "1" }), - ); - yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - - yield* Stream.runForEach(adapter.streamEvents, (event) => { - if (String(event.threadId) !== String(threadId) || event.type !== "request.opened") { - return Effect.void; - } - return Deferred.succeed(approvalRequested, undefined).pipe(Effect.ignore); - }).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "approval-required", - modelSelection: { provider: "cursor", model: "default" }, - }); + provider: "cursor", + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: { provider: "cursor", model: "default" }, + }); - const sendTurnFiber = yield* adapter - .sendTurn({ + const turn = yield* adapter.sendTurn({ threadId, - input: "run a tool call and then stop", + input: "run a tool call", attachments: [], - }) - .pipe(Effect.forkChild); - - yield* Deferred.await(approvalRequested); - yield* adapter.stopSession(threadId); - yield* Fiber.await(sendTurnFiber); - - assert.equal(yield* adapter.hasSession(threadId), false); - }), - ); - - it.effect("stopping a session settles pending user-input waits", () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-stop-pending-user-input"); - const userInputRequested = yield* Deferred.make(); + }); + yield* Deferred.await(settledEventsReady); - const wrapperPath = yield* Effect.promise(() => - makeMockAgentWrapper({ T3_ACP_EMIT_ASK_QUESTION: "1" }), - ); - yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + 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", + ], + ); - yield* Stream.runForEach(adapter.streamEvents, (event) => { - if (String(event.threadId) !== String(threadId) || event.type !== "user-input.requested") { - return Effect.void; + 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"); } - return Deferred.succeed(userInputRequested, undefined).pipe(Effect.ignore); - }).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, - }); - const sendTurnFiber = yield* adapter - .sendTurn({ - threadId, - input: "ask me a question and then stop", - attachments: [], - }) - .pipe(Effect.forkChild); - - yield* Deferred.await(userInputRequested); - yield* adapter.stopSession(threadId); - yield* Fiber.await(sendTurnFiber); - - assert.equal(yield* adapter.hasSession(threadId), false); - }), - ); - - it.effect("interrupting a session settles pending user-input waits", () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-interrupt-pending-user-input"); - const userInputRequested = yield* Deferred.make(); - - const wrapperPath = yield* Effect.promise(() => - makeMockAgentWrapper({ T3_ACP_EMIT_ASK_QUESTION: "1" }), - ); - yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - - yield* Stream.runForEach(adapter.streamEvents, (event) => { - if (String(event.threadId) !== String(threadId) || event.type !== "user-input.requested") { - return Effect.void; + 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"); } - return Deferred.succeed(userInputRequested, undefined).pipe(Effect.ignore); - }).pipe(Effect.forkChild); - - yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, - }); - - const sendTurnFiber = yield* adapter - .sendTurn({ - threadId, - input: "ask me a question and then interrupt", - attachments: [], - }) - .pipe(Effect.forkChild); - - yield* Deferred.await(userInputRequested); - yield* adapter.interruptTurn(threadId); - yield* Fiber.await(sendTurnFiber); - - assert.equal(yield* adapter.hasSession(threadId), true); - yield* adapter.stopSession(threadId); - }), - ); - it.effect("broadcasts runtime events to multiple stream consumers", () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const settings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-runtime-event-broadcast"); - - const wrapperPath = yield* Effect.promise(() => makeMockAgentWrapper()); - yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); + 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 firstConsumer = yield* Stream.take(adapter.streamEvents, 3).pipe( - Stream.runCollect, - Effect.forkChild, - ); - const secondConsumer = yield* Stream.take(adapter.streamEvents, 3).pipe( - Stream.runCollect, - Effect.forkChild, - ); + 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"); + } - yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "default" }, + 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"); + } }); - const firstEvents = Array.from(yield* Fiber.join(firstConsumer)); - const secondEvents = Array.from(yield* Fiber.join(secondConsumer)); - - assert.deepStrictEqual( - firstEvents.map((event) => event.type), - ["session.started", "session.state.changed", "thread.started"], - ); - assert.deepStrictEqual( - secondEvents.map((event) => event.type), - ["session.started", "session.state.changed", "thread.started"], + 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; + } + }), + ), ); - - yield* adapter.stopSession(threadId); - }), + }).pipe( + Effect.provide( + makeCursorAdapterLive().pipe( + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(NodeServices.layer), + ), + ), + ), ); - it.effect("switches model in-session via session/set_config_option", () => + it.effect("restarts ACP with session/load when the Cursor model changes mid-thread", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-model-switch"); + 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"); @@ -1096,78 +464,20 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }); const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); - assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); - assert.deepStrictEqual(argvRuns[0], ["acp"]); + assert.deepStrictEqual(argvRuns, [ + ["--model", "composer-2", "acp"], + ["--model", "composer-2-fast", "acp"], + ]); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const setConfigRequests = requests.filter( - (entry) => - entry.method === "session/set_config_option" && - (entry.params as Record | undefined)?.configId === "model", - ); - assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); - assert.equal((setConfigRequests[0]?.params as Record)?.value, "composer-2"); - - const fastConfigRequests = requests.filter( - (entry) => - entry.method === "session/set_config_option" && - (entry.params as Record | undefined)?.configId === "fast", - ); - assert.isAbove(fastConfigRequests.length, 0, "should apply fast mode as a separate config"); - const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1]; - assert.equal((lastFastConfig?.params as Record)?.value, "true"); - - yield* adapter.stopSession(threadId); - }), - ); - - it.effect("clears prior fast mode in-session when the next turn sets fastMode: false", () => - Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.make("cursor-fast-mode-reset"); - 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* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - - yield* adapter.startSession({ - threadId, - provider: "cursor", + 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(), - runtimeMode: "full-access", - modelSelection: { provider: "cursor", model: "composer-2" }, + mcpServers: [], }); - yield* adapter.sendTurn({ - threadId, - input: "first turn with fast mode", - attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, - }); - - yield* adapter.sendTurn({ - threadId, - input: "second turn without fast mode", - attachments: [], - modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: false } }, - }); - - const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); - const fastConfigRequests = requests.filter( - (entry) => - entry.method === "session/set_config_option" && - (entry.params as Record | undefined)?.configId === "fast", - ); - assert.isAtLeast(fastConfigRequests.length, 2, "should set fast mode on and then off"); - - const lastFastConfig = fastConfigRequests[fastConfigRequests.length - 1]; - assert.equal((lastFastConfig?.params as Record)?.value, "false"); - yield* adapter.stopSession(threadId); }), ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index b09e0356bfb4..121a46000e39 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1,25 +1,30 @@ /** - * CursorAdapterLive — Cursor CLI (`agent acp`) via ACP. + * 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, - type CursorModelOptions, EventId, - type ProviderApprovalDecision, 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, @@ -27,16 +32,10 @@ import { Fiber, FileSystem, Layer, - Option, - PubSub, + Queue, Random, - Scope, - Semaphore, Stream, - SynchronizedRef, } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; -import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -44,39 +43,24 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, ProviderAdapterSessionNotFoundError, ProviderAdapterValidationError, + type ProviderAdapterError, } from "../Errors.ts"; -import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; -import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; -import { - makeAcpAssistantItemEvent, - makeAcpContentDeltaEvent, - makeAcpPlanUpdatedEvent, - makeAcpRequestOpenedEvent, - makeAcpRequestResolvedEvent, - makeAcpToolCallEvent, -} from "../acp/AcpCoreRuntimeEvents.ts"; -import { - type AcpSessionMode, - type AcpSessionModeState, - parsePermissionRequest, -} from "../acp/AcpRuntimeModel.ts"; -import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; -import { applyCursorAcpModelSelection, makeCursorAcpRuntime } from "../acp/CursorAcpSupport.ts"; import { - CursorAskQuestionRequest, - CursorCreatePlanRequest, - CursorUpdateTodosRequest, - extractAskQuestions, - extractPlanMarkdown, - extractTodosAsPlan, -} from "../acp/CursorAcpExtension.ts"; + 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 { resolveCursorAcpBaseModelId } from "./CursorProvider.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"]; @@ -87,64 +71,384 @@ export interface CursorAdapterLiveOptions { readonly nativeEventLogger?: EventNdjsonLogger; } -interface PendingApproval { - readonly decision: Deferred.Deferred; - readonly kind: string | "unknown"; +interface CursorSpawnOptions { + readonly binaryPath?: string | undefined; + readonly args?: ReadonlyArray | undefined; + readonly apiEndpoint?: string | undefined; } -interface PendingUserInput { - readonly answers: Deferred.Deferred; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } -interface CursorSessionContext { - readonly threadId: ThreadId; - session: ProviderSession; - readonly scope: Scope.Closeable; - readonly acp: AcpSessionRuntimeShape; - notificationFiber: Fiber.Fiber | undefined; - readonly pendingApprovals: Map; - readonly pendingUserInputs: Map; - readonly turns: Array<{ id: TurnId; items: Array }>; - lastPlanFingerprint: string | undefined; - activeTurnId: TurnId | undefined; - stopped: boolean; +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 settlePendingApprovalsAsCancelled( - pendingApprovals: ReadonlyMap, -): Effect.Effect { - const pendingEntries = Array.from(pendingApprovals.values()); - return Effect.forEach( - pendingEntries, - (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), - { - discard: true, - }, - ); +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 settlePendingUserInputsAsEmptyAnswers( - pendingUserInputs: ReadonlyMap, -): Effect.Effect { - const pendingEntries = Array.from(pendingUserInputs.values()); - return Effect.forEach( - pendingEntries, - (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), - { - discard: true, +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 isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +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 parseCursorResume(raw: unknown): { sessionId: string } | undefined { +function parseSessionModeState(raw: unknown): AcpSessionModeState | 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() }; + 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 { @@ -215,75 +519,171 @@ function resolveRequestedModeId(input: { ); } -function applyRequestedSessionConfiguration(input: { - readonly runtime: AcpSessionRuntimeShape; - readonly runtimeMode: RuntimeMode; - readonly interactionMode: ProviderInteractionMode | undefined; - readonly modelSelection: - | { - readonly model: string; - readonly options?: CursorModelOptions | null | undefined; +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, } - | undefined; - readonly mapError: (context: { - readonly cause: import("effect-acp/errors").AcpError; - readonly method: "session/set_config_option" | "session/set_mode"; - }) => E; -}): Effect.Effect { - return Effect.gen(function* () { - if (input.modelSelection) { - yield* applyCursorAcpModelSelection({ - runtime: input.runtime, - model: input.modelSelection.model, - modelOptions: input.modelSelection.options, - mapError: ({ cause }) => - input.mapError({ - cause, - method: "session/set_config_option", - }), - }); - } + : modeState; +} - const requestedModeId = resolveRequestedModeId({ - interactionMode: input.interactionMode, - runtimeMode: input.runtimeMode, - modeState: yield* input.runtime.getModeState, - }); - if (!requestedModeId) { - return; - } +function isMethodNotFoundRpcError(error: AcpError): boolean { + return ( + error instanceof AcpRpcError && + (error.code === -32601 || error.message.toLowerCase().includes("method not found")) + ); +} - yield* input.runtime.setMode(requestedModeId).pipe( - Effect.mapError((cause) => - input.mapError({ - cause, - method: "session/set_mode", - }), - ), +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 {}; } -function selectAutoApprovedPermissionOption( - request: EffectAcpSchema.RequestPermissionRequest, -): string | undefined { - const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); - if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { - return allowAlwaysOption.optionId.trim(); - } +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly requestType: + | "exec_command_approval" + | "file_read_approval" + | "file_change_approval" + | "unknown"; +} - const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); - if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { - return allowOnceOption.optionId.trim(); - } +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} - return undefined; +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 childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* Effect.service(ServerConfig); const serverSettingsService = yield* ServerSettingsService; const nativeEventLogger = @@ -293,40 +693,117 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { stream: "native", }) : undefined); - const managedNativeEventLogger = - options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; const sessions = new Map(); - const threadLocksRef = yield* SynchronizedRef.make(new Map()); - const runtimeEventPubSub = yield* PubSub.unbounded(); + const runtimeEventQueue = yield* Queue.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); - const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.makeUnsafe(id)); const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); const offerRuntimeEvent = (event: ProviderRuntimeEvent) => - PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); - const getThreadSemaphore = (threadId: string) => - SynchronizedRef.modifyEffect(threadLocksRef, (current) => { - const existing: Option.Option = Option.fromNullishOr( - current.get(threadId), - ); - return Option.match(existing, { - onNone: () => - Semaphore.make(1).pipe( - Effect.map((semaphore) => { - const next = new Map(current); - next.set(threadId, semaphore); - return [semaphore, next] as const; - }), - ), - onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + 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 withThreadLock = (threadId: string, effect: Effect.Effect) => - Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + 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, @@ -354,46 +831,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ); }); - const emitPlanUpdate = ( - ctx: CursorSessionContext, - payload: { - readonly explanation?: string | null; - readonly plan: ReadonlyArray<{ - readonly step: string; - readonly 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; - yield* offerRuntimeEvent( - makeAcpPlanUpdatedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - payload, - source, - method, - rawPayload, - }), - ); - }); - const requireSession = ( threadId: ThreadId, ): Effect.Effect => { const ctx = sessions.get(threadId); if (!ctx || ctx.stopped) { return Effect.fail( - new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }), ); } return Effect.succeed(ctx); @@ -403,16 +850,15 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { Effect.gen(function* () { if (ctx.stopped) return; ctx.stopped = true; - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); if (ctx.notificationFiber) { yield* Fiber.interrupt(ctx.notificationFiber); } - yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + disposeAcpChild(ctx.child); sessions.delete(ctx.threadId); + const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "session.exited", - ...(yield* makeEventStamp()), + ...stamp, provider: PROVIDER, threadId: ctx.threadId, payload: { exitKind: "graceful" }, @@ -420,436 +866,461 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); const startSession: CursorAdapterShape["startSession"] = (input) => - withThreadLock( - input.threadId, - 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 cursorModelSelection = - input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; - const existing = sessions.get(input.threadId); - if (existing && !existing.stopped) { - yield* stopSessionInternal(existing); - } - - const cursorSettings = yield* serverSettingsService.getSettings.pipe( - Effect.map((settings) => settings.providers.cursor), - Effect.mapError( - (error) => - new ProviderAdapterProcessError({ - provider: PROVIDER, - threadId: input.threadId, - detail: error.message, - cause: error, - }), - ), - ); - - const pendingApprovals = new Map(); - const pendingUserInputs = new Map(); - const sessionScope = yield* Scope.make("sequential"); - let sessionScopeTransferred = false; - yield* Effect.addFinalizer(() => - sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), - ); - let ctx!: CursorSessionContext; - - const resumeSessionId = parseCursorResume(input.resumeCursor)?.sessionId; - const acpNativeLoggers = makeAcpNativeLoggers({ - nativeEventLogger, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ provider: PROVIDER, - threadId: input.threadId, + 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 cursorSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.cursor), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + const cursorOpts: CursorSpawnOptions = { + binaryPath: cursorSettings.binaryPath, + apiEndpoint: cursorSettings.apiEndpoint || undefined, + }; + const cursorModelSelection = + input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; + const initialModel = resolveCursorDispatchModel( + cursorModelSelection?.model, + cursorModelSelection?.options, + ); + 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 acp = yield* makeCursorAcpRuntime({ - cursorSettings, - childProcessSpawner, - cwd, - ...(resumeSessionId ? { resumeSessionId } : {}), - clientInfo: { name: "t3-code", version: "0.0.0" }, - ...acpNativeLoggers, - }).pipe( - Effect.provideService(Scope.Scope, sessionScope), - Effect.mapError( - (cause) => - new ProviderAdapterProcessError({ + 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: input.threadId, - detail: cause.message, - cause, - }), - ), - ); - const started = yield* Effect.gen(function* () { - yield* acp.handleExtRequest("cursor/ask_question", CursorAskQuestionRequest, (params) => + 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( - input.threadId, + ctx.threadId, "cursor/ask_question", params, "acp.cursor.extension", ); - const requestId = ApprovalRequestId.make(crypto.randomUUID()); - const runtimeRequestId = RuntimeRequestId.make(requestId); + const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.makeUnsafe(requestId); const answers = yield* Deferred.make(); - pendingUserInputs.set(requestId, { answers }); + ctx.pendingUserInputs.set(requestId, { answers }); + const questions = extractAskQuestions(params); + const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "user-input.requested", - ...(yield* makeEventStamp()), + ...stamp, provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, requestId: runtimeRequestId, - payload: { questions: extractAskQuestions(params) }, + payload: { questions }, raw: { source: "acp.cursor.extension", method: "cursor/ask_question", payload: params, }, }); - const resolved = yield* Deferred.await(answers); - pendingUserInputs.delete(requestId); + const a = yield* Deferred.await(answers); + ctx.pendingUserInputs.delete(requestId); + const stamp2 = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "user-input.resolved", - ...(yield* makeEventStamp()), + ...stamp2, provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, requestId: runtimeRequestId, - payload: { answers: resolved }, + payload: { answers: a }, }); - return { answers: resolved }; + return { answers: a }; }), ); - yield* acp.handleExtRequest("cursor/create_plan", CursorCreatePlanRequest, (params) => + + yield* conn.registerHandler("cursor/create_plan", (params, _acpId) => Effect.gen(function* () { yield* logNative( - input.threadId, + ctx.threadId, "cursor/create_plan", params, "acp.cursor.extension", ); + const planMarkdown = extractPlanMarkdown(params); + const stamp = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.proposed.completed", - ...(yield* makeEventStamp()), + ...stamp, provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - payload: { planMarkdown: extractPlanMarkdown(params) }, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload: { planMarkdown }, raw: { source: "acp.cursor.extension", method: "cursor/create_plan", payload: params, }, }); - return { accepted: true } as const; + return { accepted: true }; }), ); - yield* acp.handleExtNotification( - "cursor/update_todos", - CursorUpdateTodosRequest, - (params) => - Effect.gen(function* () { - yield* logNative( - input.threadId, - "cursor/update_todos", - params, - "acp.cursor.extension", - ); - if (ctx) { - yield* emitPlanUpdate( - ctx, - extractTodosAsPlan(params), - params, - "acp.cursor.extension", - "cursor/update_todos", - ); - } - }), - ); - yield* acp.handleRequestPermission((params) => + + yield* conn.registerHandler("cursor/update_todos", (params, _acpId) => Effect.gen(function* () { yield* logNative( - input.threadId, - "session/request_permission", + ctx.threadId, + "cursor/update_todos", params, - "acp.jsonrpc", - ); - if (input.runtimeMode === "full-access") { - const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); - if (autoApprovedOptionId !== undefined) { - return { - outcome: { - outcome: "selected" as const, - optionId: autoApprovedOptionId, - }, - }; - } - } - const permissionRequest = parsePermissionRequest(params); - const requestId = ApprovalRequestId.make(crypto.randomUUID()); - const runtimeRequestId = RuntimeRequestId.make(requestId); - const decision = yield* Deferred.make(); - pendingApprovals.set(requestId, { - decision, - kind: permissionRequest.kind, - }); - yield* offerRuntimeEvent( - makeAcpRequestOpenedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - detail: permissionRequest.detail ?? JSON.stringify(params).slice(0, 2000), - args: params, - source: "acp.jsonrpc", - method: "session/request_permission", - rawPayload: params, - }), + "acp.cursor.extension", ); - const resolved = yield* Deferred.await(decision); - pendingApprovals.delete(requestId); - yield* offerRuntimeEvent( - makeAcpRequestResolvedEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: input.threadId, - turnId: ctx?.activeTurnId, - requestId: runtimeRequestId, - permissionRequest, - decision: resolved, - }), + const plan = extractTodosAsPlan(params); + yield* emitPlanUpdate( + ctx, + plan, + params, + "acp.cursor.extension", + "cursor/update_todos", ); - return { - outcome: - resolved === "cancel" - ? ({ outcome: "cancelled" } as const) - : { - outcome: "selected" as const, - optionId: acpPermissionOutcome(resolved), - }, - }; + return {}; }), ); - return yield* acp.start(); - }).pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), - ), - ); - - yield* applyRequestedSessionConfiguration({ - runtime: acp, - runtimeMode: input.runtimeMode, - interactionMode: undefined, - modelSelection: cursorModelSelection, - mapError: ({ cause, method }) => - mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), }); - const now = yield* nowIso; - const session: ProviderSession = { - provider: PROVIDER, - status: "ready", - runtimeMode: input.runtimeMode, - cwd, - model: cursorModelSelection?.model, - threadId: input.threadId, - resumeCursor: { - schemaVersion: CURSOR_RESUME_VERSION, - sessionId: started.sessionId, + yield* registerHandlers(ctx); + + const init = yield* conn + .request("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, }, - createdAt: now, - updatedAt: now, - }; + 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; + } - ctx = { - threadId: input.threadId, - session, - scope: sessionScope, - acp, - notificationFiber: undefined, - pendingApprovals, - pendingUserInputs, - turns: [], - lastPlanFingerprint: undefined, - activeTurnId: undefined, - stopped: false, - }; - - const nf = yield* Stream.runDrain( - Stream.mapEffect(acp.getEvents(), (event) => - Effect.gen(function* () { - switch (event._tag) { - case "ModeChanged": - return; - case "AssistantItemStarted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.started", - }), - ); - return; - case "AssistantItemCompleted": - yield* offerRuntimeEvent( - makeAcpAssistantItemEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - itemId: event.itemId, - lifecycle: "item.completed", - }), - ); - return; - case "PlanUpdated": - yield* logNative( - ctx.threadId, - "session/update", - event.rawPayload, - "acp.jsonrpc", - ); - yield* emitPlanUpdate( - ctx, - event.payload, - event.rawPayload, - "acp.jsonrpc", - "session/update", - ); - return; - case "ToolCallUpdated": - yield* logNative( - ctx.threadId, - "session/update", - event.rawPayload, - "acp.jsonrpc", - ); - yield* offerRuntimeEvent( - makeAcpToolCallEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - toolCall: event.toolCall, - rawPayload: event.rawPayload, - }), - ); - return; - case "ContentDelta": - yield* logNative( - ctx.threadId, - "session/update", - event.rawPayload, - "acp.jsonrpc", - ); - yield* offerRuntimeEvent( - makeAcpContentDeltaEvent({ - stamp: yield* makeEventStamp(), - provider: PROVIDER, - threadId: ctx.threadId, - turnId: ctx.activeTurnId, - ...(event.itemId ? { itemId: event.itemId } : {}), - text: event.text, - rawPayload: event.rawPayload, - }), - ); - return; - } - }), - ), - ).pipe(Effect.forkChild); + const now = yield* nowIso; + const resumeCursor = { + schemaVersion: CURSOR_RESUME_VERSION, + sessionId: acpSessionId, + }; - ctx.notificationFiber = nf; - sessions.set(input.threadId, ctx); - sessionScopeTransferred = true; + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: cursorModelSelection?.model, + threadId: input.threadId, + resumeCursor, + createdAt: now, + updatedAt: now, + }; - yield* offerRuntimeEvent({ - type: "session.started", - ...(yield* makeEventStamp()), - provider: PROVIDER, - threadId: input.threadId, - payload: { resume: started.initializeResult }, - }); - 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: started.sessionId }, + 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, + }, + }); + } }); - return session; - }).pipe(Effect.scoped), - ); + 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* () { - const ctx = yield* requireSession(input.threadId); - const turnId = TurnId.make(crypto.randomUUID()); + let ctx = yield* requireSession(input.threadId); + const turnId = TurnId.makeUnsafe(crypto.randomUUID()); const turnModelSelection = input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; - const model = turnModelSelection?.model ?? ctx.session.model; - const resolvedModel = resolveCursorAcpBaseModelId(model); - yield* applyRequestedSessionConfiguration({ - runtime: ctx.acp, - runtimeMode: ctx.session.runtimeMode, - interactionMode: input.interactionMode, - modelSelection: - model === undefined - ? undefined - : { - model, - options: turnModelSelection?.options, - }, - mapError: ({ cause, method }) => - mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), - }); + const model = resolveCursorDispatchModel( + turnModelSelection?.model ?? ctx.session.model, + turnModelSelection?.options, + ); + 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, + modelSelection: turnModelSelection ?? { provider: PROVIDER, model }, + ...(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", - ...(yield* makeEventStamp()), + ...stampStart, provider: PROVIDER, threadId: input.threadId, turnId, - payload: { model: resolvedModel }, + payload: { model }, }); - const promptParts: Array = []; + const promptParts: Array> = []; if (input.input?.trim()) { promptParts.push({ type: "text", text: input.input.trim() }); } @@ -872,15 +1343,17 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { new ProviderAdapterRequestError({ provider: PROVIDER, method: "session/prompt", - detail: cause.message, + detail: toMessage(cause, "Failed to read attachment."), cause, }), ), ); promptParts.push({ type: "image", - data: Buffer.from(bytes).toString("base64"), - mimeType: attachment.mimeType, + image: { + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }, }); } } @@ -893,33 +1366,32 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); } - const result = yield* ctx.acp - .prompt({ + const result = yield* ctx.conn + .request("session/prompt", { + sessionId: ctx.acpSessionId, prompt: promptParts, }) - .pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), - ), - ); + .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: resolvedModel, + model, }; + const pr = result as { stopReason?: string | null }; + const stampEnd = yield* makeEventStamp(); yield* offerRuntimeEvent({ type: "turn.completed", - ...(yield* makeEventStamp()), + ...stampEnd, provider: PROVIDER, threadId: input.threadId, turnId, payload: { - state: result.stopReason === "cancelled" ? "cancelled" : "completed", - stopReason: result.stopReason ?? null, + state: "completed", + stopReason: pr.stopReason ?? null, }, }); @@ -933,15 +1405,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const interruptTurn: CursorAdapterShape["interruptTurn"] = (threadId) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); - yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); - yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); - yield* Effect.ignore( - ctx.acp.cancel.pipe( - Effect.mapError((error) => - mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), - ), - ), - ); + yield* Effect.ignore(ctx.conn.request("session/cancel", { sessionId: ctx.acpSessionId })); }); const respondToRequest: CursorAdapterShape["respondToRequest"] = ( @@ -983,7 +1447,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const readThread: CursorAdapterShape["readThread"] = (threadId) => Effect.gen(function* () { const ctx = yield* requireSession(threadId); - return { threadId, turns: ctx.turns }; + return { + threadId, + turns: ctx.turns, + }; }); const rollbackThread: CursorAdapterShape["rollbackThread"] = (threadId, numTurns) => @@ -1002,13 +1469,10 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); const stopSession: CursorAdapterShape["stopSession"] = (threadId) => - withThreadLock( - threadId, - Effect.gen(function* () { - const ctx = yield* requireSession(threadId); - yield* stopSessionInternal(ctx); - }), - ); + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }); const listSessions: CursorAdapterShape["listSessions"] = () => Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); @@ -1024,16 +1488,13 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { yield* Effect.addFinalizer(() => Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( - Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), - Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + Effect.tap(() => Queue.shutdown(runtimeEventQueue)), ), ); - const streamEvents = Stream.fromPubSub(runtimeEventPubSub); - return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "in-session" }, + capabilities: { sessionModelSwitch: "unsupported" }, startSession, sendTurn, interruptTurn, @@ -1045,11 +1506,82 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { listSessions, hasSession, stopAll, - streamEvents, + 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) { diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index e0a68f68b634..d92293bfa1eb 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,14 +4,9 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; -import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; -import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import { CodexAdapter } from "../Services/CodexAdapter.ts"; -import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; -import { CursorAdapter } from "../Services/CursorAdapter.ts"; -import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; -import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; -import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +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"; @@ -51,23 +46,6 @@ const fakeClaudeAdapter: ClaudeAdapterShape = { streamEvents: Stream.empty, }; -const fakeOpenCodeAdapter: OpenCodeAdapterShape = { - provider: "opencode", - 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 fakeCursorAdapter: CursorAdapterShape = { provider: "cursor", capabilities: { sessionModelSwitch: "in-session" }, @@ -92,7 +70,6 @@ const layer = it.layer( Layer.mergeAll( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), - Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), Layer.succeed(CursorAdapter, fakeCursorAdapter), ), ), @@ -106,15 +83,13 @@ layer("ProviderAdapterRegistryLive", (it) => { const registry = yield* ProviderAdapterRegistry; const codex = yield* registry.getByProvider("codex"); const claude = yield* registry.getByProvider("claudeAgent"); - const openCode = yield* registry.getByProvider("opencode"); const cursor = yield* registry.getByProvider("cursor"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); - assert.equal(openCode, fakeOpenCodeAdapter); assert.equal(cursor, fakeCursorAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent", "opencode", "cursor"]); + 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 22fc0b7fda56..78ba916461da 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -18,26 +18,18 @@ import { import { ClaudeAdapter } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; -import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; } -const makeProviderAdapterRegistry = Effect.fn("makeProviderAdapterRegistry")(function* ( - options?: ProviderAdapterRegistryLiveOptions, -) { - const cursorAdapterOption = yield* Effect.serviceOption(CursorAdapter); - const adapters = - options?.adapters !== undefined - ? options.adapters - : [ - yield* CodexAdapter, - yield* ClaudeAdapter, - yield* OpenCodeAdapter, - ...(cursorAdapterOption._tag === "Some" ? [cursorAdapterOption.value] : []), - ]; - const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); +const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOptions) => + Effect.gen(function* () { + const adapters = + options?.adapters !== undefined + ? options.adapters + : [yield* CodexAdapter, yield* ClaudeAdapter, yield* CursorAdapter]; + const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { const adapter = byProvider.get(provider); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 4cb7147180ad..79fb4d2a271f 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -24,15 +24,14 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - return Schema.decodeUnknownEffect(ProviderKind)(providerName).pipe( - Effect.mapError( - (cause) => - new ProviderSessionDirectoryPersistenceError({ - operation, - detail: `Unknown persisted provider '${providerName}'.`, - cause, - }), - ), + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "cursor") { + return Effect.succeed(providerName); + } + return Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation, + detail: `Unknown persisted provider '${providerName}'.`, + }), ); } diff --git a/apps/server/src/provider/Services/CursorAdapter.ts b/apps/server/src/provider/Services/CursorAdapter.ts index f1edb316198d..8b6423895568 100644 --- a/apps/server/src/provider/Services/CursorAdapter.ts +++ b/apps/server/src/provider/Services/CursorAdapter.ts @@ -1,4 +1,4 @@ -import { Context } from "effect"; +import { ServiceMap } from "effect"; import type { ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; @@ -7,6 +7,6 @@ export interface CursorAdapterShape extends ProviderAdapterShape()( +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 000000000000..40b35ca3167b --- /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 index 4820d5c2e582..3ad8bbb7111e 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -1,395 +1,51 @@ import * as path from "node:path"; -import * as os from "node:os"; import { fileURLToPath } from "node:url"; -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect, Stream } from "effect"; import { describe, expect } from "vitest"; -import { AcpSessionRuntime, type AcpSessionRequestLogEvent } from "./AcpSessionRuntime.ts"; -import type * as EffectAcpProtocol from "effect-acp/protocol"; +import { makeAcpJsonRpcConnection } from "./AcpJsonRpcConnection.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); -const bunExe = "bun"; +const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); -describe("AcpSessionRuntime", () => { - it.effect("merges custom initialize client capabilities into the ACP handshake", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; - yield* runtime.start(); - - const initializeStarted = requestEvents.find( - (event) => event.method === "initialize" && event.status === "started", - ); - expect(initializeStarted?.payload).toMatchObject({ - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - _meta: { parameterizedModelPicker: true }, - }, - }); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: bunExe, - args: [mockAgentPath], - }, - cwd: process.cwd(), - clientCapabilities: { - _meta: { - parameterizedModelPicker: true, - }, - }, - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - - it.effect("starts a session, prompts, and emits normalized events against the mock agent", () => +describe("AcpJsonRpcConnection", () => { + it.effect("performs initialize → session/new → session/prompt against mock agent", () => Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; - const started = yield* runtime.start(); - - expect(started.initializeResult).toMatchObject({ protocolVersion: 1 }); - expect(started.sessionId).toBe("mock-session-1"); - - const promptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "hi" }], + const conn = yield* makeAcpJsonRpcConnection({ + command: process.execPath, + args: [mockAgentPath], }); - expect(promptResult).toMatchObject({ stopReason: "end_turn" }); - - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); - expect(notes).toHaveLength(4); - expect(notes.map((note) => note._tag)).toEqual([ - "PlanUpdated", - "AssistantItemStarted", - "ContentDelta", - "AssistantItemCompleted", - ]); - const planUpdate = notes.find((note) => note._tag === "PlanUpdated"); - expect(planUpdate?._tag).toBe("PlanUpdated"); - if (planUpdate?._tag === "PlanUpdated") { - expect(planUpdate.payload.plan).toHaveLength(2); - } - const assistantStart = notes[1]; - const assistantDelta = notes[2]; - if ( - assistantStart?._tag === "AssistantItemStarted" && - assistantDelta?._tag === "ContentDelta" - ) { - expect(assistantDelta.itemId).toBe(assistantStart.itemId); - } - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: bunExe, - args: [mockAgentPath], - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ), - ); - it.effect("segments assistant text around ACP tool calls", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; - yield* runtime.start(); - - const promptResult = yield* runtime.prompt({ - prompt: [{ type: "text", text: "hi" }], + 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(promptResult).toMatchObject({ stopReason: "end_turn" }); - - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 7))); - expect(notes.map((note) => note._tag)).toEqual([ - "AssistantItemStarted", - "ContentDelta", - "AssistantItemCompleted", - "ToolCallUpdated", - "ToolCallUpdated", - "AssistantItemStarted", - "ContentDelta", - ]); + expect(initResult).toMatchObject({ protocolVersion: 1 }); - const firstStarted = notes[0]; - const firstDelta = notes[1]; - const firstCompleted = notes[2]; - const secondStarted = notes[5]; - const secondDelta = notes[6]; - expect(firstStarted?._tag).toBe("AssistantItemStarted"); - expect(firstCompleted?._tag).toBe("AssistantItemCompleted"); - expect(secondStarted?._tag).toBe("AssistantItemStarted"); - if ( - firstStarted?._tag === "AssistantItemStarted" && - firstDelta?._tag === "ContentDelta" && - firstCompleted?._tag === "AssistantItemCompleted" && - secondStarted?._tag === "AssistantItemStarted" && - secondDelta?._tag === "ContentDelta" - ) { - expect(firstDelta.itemId).toBe(firstStarted.itemId); - expect(firstCompleted.itemId).toBe(firstStarted.itemId); - expect(secondStarted.itemId).not.toBe(firstStarted.itemId); - expect(secondDelta.itemId).toBe(secondStarted.itemId); - } - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: bunExe, - args: [mockAgentPath], - env: { - T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS: "1", - }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ), - ); + yield* conn.request("authenticate", { methodId: "cursor_login" }); - it.effect("suppresses generic placeholder tool updates until completion", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; - yield* runtime.start(); + const newResult = yield* conn.request("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + expect(newResult).toEqual({ sessionId: "mock-session-1" }); - const promptResult = yield* runtime.prompt({ + const promptResult = yield* conn.request("session/prompt", { + sessionId: "mock-session-1", prompt: [{ type: "text", text: "hi" }], }); expect(promptResult).toMatchObject({ stopReason: "end_turn" }); - const notes = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 1))); - expect(notes.map((note) => note._tag)).toEqual(["ToolCallUpdated"]); - const toolCall = notes[0]; - expect(toolCall?._tag).toBe("ToolCallUpdated"); - if (toolCall?._tag === "ToolCallUpdated") { - expect(toolCall.toolCall.status).toBe("completed"); - expect(toolCall.toolCall.title).toBe("Read file"); + 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.provide( - AcpSessionRuntime.layer({ - spawn: { - command: bunExe, - args: [mockAgentPath], - env: { - T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1", - }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - authMethodId: "test", - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ), + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - - it.effect("logs ACP requests from the shared runtime", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; - yield* runtime.start(); - - yield* runtime.setModel("composer-2"); - yield* runtime.prompt({ - prompt: [{ type: "text", text: "hi" }], - }); - - expect( - requestEvents.some( - (event) => event.method === "session/set_config_option" && event.status === "started", - ), - ).toBe(true); - expect( - requestEvents.some( - (event) => event.method === "session/set_config_option" && event.status === "succeeded", - ), - ).toBe(true); - expect( - requestEvents.some( - (event) => event.method === "session/prompt" && event.status === "started", - ), - ).toBe(true); - expect( - requestEvents.some( - (event) => event.method === "session/prompt" && event.status === "succeeded", - ), - ).toBe(true); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: bunExe, - args: [mockAgentPath], - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - - it.effect("skips no-op session config writes when the requested value is already active", () => { - const requestEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; - yield* runtime.start(); - - yield* runtime.setConfigOption("model", "default"); - yield* runtime.setMode("ask"); - - expect( - requestEvents.some( - (event) => event.method === "session/set_config_option" && event.status === "started", - ), - ).toBe(false); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: bunExe, - args: [mockAgentPath], - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - requestLogger: (event) => - Effect.sync(() => { - requestEvents.push(event); - }), - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - - it.effect("emits low-level ACP protocol logs for raw and decoded messages", () => { - const protocolEvents: Array = []; - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; - yield* runtime.start(); - - yield* runtime.prompt({ - prompt: [{ type: "text", text: "hi" }], - }); - - expect( - protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "raw"), - ).toBe(true); - expect( - protocolEvents.some((event) => event.direction === "outgoing" && event.stage === "decoded"), - ).toBe(true); - expect( - protocolEvents.some((event) => event.direction === "incoming" && event.stage === "raw"), - ).toBe(true); - expect( - protocolEvents.some((event) => event.direction === "incoming" && event.stage === "decoded"), - ).toBe(true); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: bunExe, - args: [mockAgentPath], - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - protocolLogging: { - logIncoming: true, - logOutgoing: true, - logger: (event) => - Effect.sync(() => { - protocolEvents.push(event); - }), - }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ); - }); - - it.effect("rejects invalid config option values before sending session/set_config_option", () => { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "acp-runtime-")); - const requestLogPath = path.join(tempDir, "requests.ndjson"); - return Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; - yield* runtime.start(); - - const error = yield* runtime.setModel("composer-2[fast=false]").pipe(Effect.flip); - expect(error._tag).toBe("AcpRequestError"); - if (error._tag === "AcpRequestError") { - expect(error.code).toBe(-32602); - expect(error.message).toContain( - 'Invalid value "composer-2[fast=false]" for session config option "model"', - ); - expect(error.message).toContain("composer-2[fast=true]"); - } - - const recordedRequests = readFileSync(requestLogPath, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as { method?: string; params?: { value?: unknown } }); - expect( - recordedRequests.some( - (message) => - message.method === "session/set_config_option" && - message.params?.value === "composer-2[fast=false]", - ), - ).toBe(false); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "test", - spawn: { - command: bunExe, - args: [mockAgentPath], - env: { - T3_ACP_REQUEST_LOG_PATH: requestLogPath, - }, - }, - cwd: process.cwd(), - clientInfo: { name: "t3-test", version: "0.0.0" }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - Effect.ensuring(Effect.sync(() => rmSync(tempDir, { recursive: true, force: true }))), - ); - }); }); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.ts new file mode 100644 index 000000000000..cc44e46af961 --- /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 000000000000..cc593a4f796d --- /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 index 7744e24ac97e..9114b88ade27 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -6,143 +6,29 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { Effect } from "effect"; import { describe, expect } from "vitest"; -import type * as EffectAcpSchema from "effect-acp/schema"; -import { AcpSessionRuntime } from "./AcpSessionRuntime.ts"; +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 runtime = yield* AcpSessionRuntime; - const started = yield* runtime.start(); - expect(started.initializeResult).toBeDefined(); - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - spawn: { - command: "agent", - args: ["acp"], - cwd: process.cwd(), - }, - cwd: process.cwd(), - clientCapabilities: { - _meta: { - parameterizedModelPicker: true, - }, - }, - clientInfo: { name: "t3-probe", version: "0.0.0" }, - authMethodId: "cursor_login", - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ), - ); - - it.effect("session/new returns configOptions with a model selector", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; - const started = yield* runtime.start(); - const result = started.sessionSetupResult; - console.log("session/new result:", JSON.stringify(result, null, 2)); - - expect(typeof started.sessionId).toBe("string"); - - const configOptions = result.configOptions; - console.log("session/new configOptions:", JSON.stringify(configOptions, null, 2)); - - if (Array.isArray(configOptions)) { - const modelConfig = configOptions.find((opt) => opt.category === "model"); - const parameterizedOptions = configOptions.filter( - (opt) => - opt.category === "thought_level" || - opt.category === "model_option" || - opt.category === "model_config", - ); - console.log("Model config option:", JSON.stringify(modelConfig, null, 2)); - console.log( - "Parameterized model config options:", - JSON.stringify(parameterizedOptions, null, 2), - ); - expect(modelConfig).toBeDefined(); - expect(typeof modelConfig?.id).toBe("string"); - } - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "cursor_login", - spawn: { - command: "agent", - args: ["acp"], - cwd: process.cwd(), - }, - cwd: process.cwd(), - clientCapabilities: { - _meta: { - parameterizedModelPicker: true, - }, - }, - clientInfo: { name: "t3-probe", version: "0.0.0" }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ), - ); - - it.effect("session/set_config_option switches the model in-session", () => - Effect.gen(function* () { - const runtime = yield* AcpSessionRuntime; - const started = yield* runtime.start(); - const newResult = started.sessionSetupResult; - - const configOptions = newResult.configOptions; - let modelConfigId = "model"; - if (Array.isArray(configOptions)) { - const modelConfig = configOptions.find((opt) => opt.category === "model"); - if (typeof modelConfig?.id === "string") { - modelConfigId = modelConfig.id; - } - } - - const setResult: EffectAcpSchema.SetSessionConfigOptionResponse = - yield* runtime.setConfigOption(modelConfigId, "gpt-5.4"); - - console.log("session/set_config_option result:", JSON.stringify(setResult, null, 2)); - - if (Array.isArray(setResult.configOptions)) { - const modelConfig = setResult.configOptions.find((opt) => opt.category === "model"); - const parameterizedOptions = setResult.configOptions.filter( - (opt) => - opt.category === "thought_level" || - opt.category === "model_option" || - opt.category === "model_config", - ); - if (modelConfig?.type === "select") { - expect(modelConfig.currentValue).toBe("gpt-5.4"); - } - expect(parameterizedOptions.length).toBeGreaterThan(0); - } - }).pipe( - Effect.provide( - AcpSessionRuntime.layer({ - authMethodId: "cursor_login", - spawn: { - command: "agent", - args: ["acp"], - cwd: process.cwd(), - }, - cwd: process.cwd(), - clientCapabilities: { - _meta: { - parameterizedModelPicker: true, - }, - }, - clientInfo: { name: "t3-probe", version: "0.0.0" }, - }), - ), - Effect.scoped, - Effect.provide(NodeServices.layer), - ), + 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 000000000000..ca1d664a7022 --- /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 new file mode 100644 index 000000000000..6537511e730b --- /dev/null +++ b/apps/server/src/serverLayers.ts @@ -0,0 +1,151 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Layer, Path } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; +import { CheckpointStoreLive } from "./checkpointing/Layers/CheckpointStore"; +import { ServerConfig } from "./config"; +import { OrchestrationCommandReceiptRepositoryLive } from "./persistence/Layers/OrchestrationCommandReceipts"; +import { OrchestrationEventStoreLive } from "./persistence/Layers/OrchestrationEventStore"; +import { ProviderSessionRuntimeRepositoryLive } from "./persistence/Layers/ProviderSessionRuntime"; +import { OrchestrationEngineLive } from "./orchestration/Layers/OrchestrationEngine"; +import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor"; +import { OrchestrationReactorLive } from "./orchestration/Layers/OrchestrationReactor"; +import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderCommandReactor"; +import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; +import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; +import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; +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"; +import { ProviderService } from "./provider/Services/ProviderService"; +import { makeEventNdjsonLogger } from "./provider/Layers/EventNdjsonLogger"; +import { ServerSettingsService } from "./serverSettings"; + +import { TerminalManagerLive } from "./terminal/Layers/Manager"; +import { KeybindingsLive } from "./keybindings"; +import { GitManagerLive } from "./git/Layers/GitManager"; +import { GitCoreLive } from "./git/Layers/GitCore"; +import { GitHubCliLive } from "./git/Layers/GitHubCli"; +import { RoutingTextGenerationLive } from "./git/Layers/RoutingTextGeneration"; +import { PtyAdapter } from "./terminal/Services/PTY"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; + +type RuntimePtyAdapterLoader = { + layer: Layer.Layer; +}; + +const runtimePtyAdapterLoaders = { + bun: () => import("./terminal/Layers/BunPTY"), + node: () => import("./terminal/Layers/NodePTY"), +} satisfies Record Promise>; + +const makeRuntimePtyAdapterLayer = () => + Effect.gen(function* () { + const runtime = process.versions.bun !== undefined ? "bun" : "node"; + const loader = runtimePtyAdapterLoaders[runtime]; + const ptyAdapterModule = yield* Effect.promise(loader); + return ptyAdapterModule.layer; + }).pipe(Layer.unwrap); + +export function makeServerProviderLayer(): Layer.Layer< + ProviderService, + ProviderUnsupportedError, + | SqlClient.SqlClient + | ServerConfig + | ServerSettingsService + | FileSystem.FileSystem + | AnalyticsService +> { + return Effect.gen(function* () { + const { providerEventLogPath } = yield* ServerConfig; + const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { + stream: "native", + }); + const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { + stream: "canonical", + }); + const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( + Layer.provide(ProviderSessionRuntimeRepositoryLive), + ); + const codexAdapterLayer = makeCodexAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ); + 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( + canonicalEventLogger ? { canonicalEventLogger } : undefined, + ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); + }).pipe(Layer.unwrap); +} + +export function makeServerRuntimeServicesLayer() { + const textGenerationLayer = RoutingTextGenerationLive; + const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(GitCoreLive)); + + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + ); + + const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( + Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), + Layer.provideMerge(checkpointStoreLayer), + ); + + const runtimeServicesLayer = Layer.mergeAll( + orchestrationLayer, + OrchestrationProjectionSnapshotQueryLive, + checkpointStoreLayer, + checkpointDiffQueryLayer, + RuntimeReceiptBusLive, + ); + const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const providerCommandReactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(GitCoreLive), + Layer.provideMerge(textGenerationLayer), + ); + const checkpointReactorLayer = CheckpointReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); + const orchestrationReactorLayer = OrchestrationReactorLive.pipe( + Layer.provideMerge(runtimeIngestionLayer), + Layer.provideMerge(providerCommandReactorLayer), + Layer.provideMerge(checkpointReactorLayer), + ); + + const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); + + const gitManagerLayer = GitManagerLive.pipe( + Layer.provideMerge(GitCoreLive), + Layer.provideMerge(GitHubCliLive), + Layer.provideMerge(textGenerationLayer), + ); + + return Layer.mergeAll( + orchestrationReactorLayer, + GitCoreLive, + gitManagerLayer, + terminalLayer, + KeybindingsLive, + ).pipe(Layer.provideMerge(NodeServices.layer)); +} diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index c47c442a86f6..dba567ede1d6 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -105,7 +105,7 @@ export class ServerSettingsService extends Context.Service< const ServerSettingsJson = fromLenientJson(ServerSettings); -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "opencode", "cursor"]; +const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "cursor"]; /** * Ensure the `textGenerationModelSelection` points to an enabled provider. diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 47dad09ea29c..01732dfd6591 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,8 +1,8 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, - type ClaudeAgentEffort, - type EnvironmentId, + type ClaudeCodeEffort, + type CursorModelOptions, type MessageId, type ModelSelection, type ProjectScript, @@ -21,16 +21,16 @@ import { TerminalOpenInput, } from "@t3tools/contracts"; import { - parseScopedThreadKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime"; -import { applyClaudePromptEffortPrefix, createModelSelection } from "@t3tools/shared/model"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import { truncate } from "@t3tools/shared/String"; -import { Debouncer } from "@tanstack/react-pacer"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; + applyClaudePromptEffortPrefix, + getDefaultModel, + isCursorModelFamilySlug, + normalizeModelSlug, + parseCursorModelSelection, + resolveModelSlugForProvider, +} from "@t3tools/shared/model"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; @@ -637,6 +637,9 @@ export default function ChatView(props: ChatViewProps) { (store) => store.setTerminalContexts, ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); + const setComposerDraftProviderModelOptions = useComposerDraftStore( + (store) => store.setProviderModelOptions, + ); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, @@ -1420,9 +1423,137 @@ export default function ChatView(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; - const gitStatusQuery = useGitStatus({ environmentId, cwd: gitCwd }); - const keybindings = useServerKeybindings(); - const availableEditors = useServerAvailableEditors(); + const composerTriggerKind = composerTrigger?.kind ?? null; + const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; + const isPathTrigger = composerTriggerKind === "path"; + const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( + pathTriggerQuery, + { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, + (debouncerState) => ({ isPending: debouncerState.isPending }), + ); + const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; + const branchesQuery = useQuery(gitBranchesQueryOptions(gitCwd)); + const keybindings = serverConfigQuery.data?.keybindings ?? EMPTY_KEYBINDINGS; + const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const modelOptionsByProvider = useMemo( + () => ({ + codex: providerStatuses.find((provider) => provider.provider === "codex")?.models ?? [], + claudeAgent: + providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], + cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [], + }), + [providerStatuses], + ); + const selectedModelForPickerWithCustomFallback = useMemo(() => { + const currentOptions = modelOptionsByProvider[selectedProvider]; + return currentOptions.some((option) => option.slug === selectedModelForPicker) + ? selectedModelForPicker + : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); + }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); + const searchableModelOptions = useMemo( + () => + AVAILABLE_PROVIDER_OPTIONS.filter( + (option) => lockedProvider === null || option.value === lockedProvider, + ).flatMap((option) => + modelOptionsByProvider[option.value].map(({ slug, name }) => ({ + provider: option.value, + providerLabel: option.label, + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), + searchProvider: option.label.toLowerCase(), + })), + ), + [lockedProvider, modelOptionsByProvider], + ); + const workspaceEntriesQuery = useQuery( + projectSearchEntriesQueryOptions({ + cwd: gitCwd, + query: effectivePathQuery, + enabled: isPathTrigger, + limit: 80, + }), + ); + const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const composerMenuItems = useMemo(() => { + if (!composerTrigger) return []; + if (composerTrigger.kind === "path") { + return workspaceEntries.map((entry) => ({ + id: `path:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOfPath(entry.path), + description: entry.parentPath ?? "", + })); + } + + if (composerTrigger.kind === "slash-command") { + const slashCommandItems = [ + { + id: "slash:model", + type: "slash-command", + command: "model", + label: "/model", + description: "Switch response model for this thread", + }, + { + id: "slash:plan", + type: "slash-command", + command: "plan", + label: "/plan", + description: "Switch this thread into plan mode", + }, + { + id: "slash:default", + type: "slash-command", + command: "default", + label: "/default", + description: "Switch this thread back to normal chat mode", + }, + ] satisfies ReadonlyArray>; + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) { + return [...slashCommandItems]; + } + return slashCommandItems.filter( + (item) => item.command.includes(query) || item.label.slice(1).includes(query), + ); + } + + return searchableModelOptions + .filter(({ searchSlug, searchName, searchProvider }) => { + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) return true; + return ( + searchSlug.includes(query) || searchName.includes(query) || searchProvider.includes(query) + ); + }) + .map(({ provider, providerLabel, slug, name }) => ({ + id: `model:${provider}:${slug}`, + type: "model", + provider, + model: slug, + label: name, + description: `${providerLabel} · ${slug}`, + })); + }, [composerTrigger, searchableModelOptions, workspaceEntries]); + const composerMenuOpen = Boolean(composerTrigger); + const activeComposerMenuItem = useMemo( + () => + composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? + composerMenuItems[0] ?? + null, + [composerHighlightedItemId, composerMenuItems], + ); + composerMenuOpenRef.current = composerMenuOpen; + composerMenuItemsRef.current = composerMenuItems; + activeComposerMenuItemRef.current = activeComposerMenuItem; + const nonPersistedComposerImageIdSet = useMemo( + () => new Set(nonPersistedComposerImageIds), + [nonPersistedComposerImageIds], + ); const activeProviderStatus = useMemo( () => providerStatuses.find((status) => status.provider === selectedProvider) ?? null, [selectedProvider, providerStatuses], @@ -3111,6 +3242,33 @@ export default function ChatView(props: ChatViewProps) { providerStatuses, model, ); + if ( + resolvedProvider === "cursor" && + isCursorModelFamilySlug(resolvedModel) && + activeThread.id.length > 0 + ) { + const prevDraft = useComposerDraftStore.getState().draftsByThreadId[activeThread.id]; + const prevCursorSelection = prevDraft?.modelSelectionByProvider?.cursor; + const prevModelRaw = + prevCursorSelection?.model ?? + (typeof activeThread.modelSelection?.model === "string" + ? resolveModelSlugForProvider("cursor", activeThread.modelSelection.model) + : null) ?? + getDefaultModel("cursor"); + const prevResolved = resolveAppModelSelection( + "cursor", + settings, + providerStatuses, + prevModelRaw, + ); + const prevCursorOptions = prevCursorSelection?.options as CursorModelOptions | undefined; + const prevFamily = parseCursorModelSelection(prevResolved, prevCursorOptions).family; + if (prevFamily !== resolvedModel) { + setComposerDraftProviderModelOptions(activeThread.id, "cursor", null, { + persistSticky: true, + }); + } + } const nextModelSelection: ModelSelection = { provider: resolvedProvider, model: resolvedModel, @@ -3127,6 +3285,7 @@ export default function ChatView(props: ChatViewProps) { lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, + setComposerDraftProviderModelOptions, setStickyComposerModelSelection, providerStatuses, settings, @@ -3301,72 +3460,506 @@ export default function ChatView(props: ChatViewProps) { {/* Input bar */}
- +
+
+
+ {activePendingApproval ? ( +
+ +
+ ) : pendingUserInputs.length > 0 ? ( +
+ +
+ ) : showPlanFollowUpPrompt && activeProposedPlan ? ( +
+ +
+ ) : null} +
+ {composerMenuOpen && !isComposerApprovalState && ( +
+ +
+ )} + + {!isComposerApprovalState && + pendingUserInputs.length === 0 && + composerImages.length > 0 && ( +
+ {composerImages.map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))} +
+ )} + +
+ + {/* Bottom toolbar */} + {activePendingApproval ? ( +
+ +
+ ) : ( +
+
+ {/* Provider/model picker */} + + + {isComposerFooterCompact ? ( + + ) : ( + <> + {providerTraitsPicker ? ( + <> + + {providerTraitsPicker} + + ) : null} + + + + + + + + + + {activePlan || sidebarProposedPlan || planSidebarOpen ? ( + <> + + + + ) : null} + + )} +
+ + {/* Right side: send / stop button */} +
+ {activeContextWindow ? ( + + ) : null} + {isPreparingWorktree ? ( + + Preparing worktree... + + ) : null} + {activePendingProgress ? ( +
+ {activePendingProgress.questionIndex > 0 ? ( + + ) : null} + +
+ ) : phase === "running" ? ( + + ) : pendingUserInputs.length === 0 ? ( + showPlanFollowUpPrompt ? ( + prompt.trim().length > 0 ? ( + + ) : ( +
+ + + + } + > + + + + void onImplementPlanInNewThread()} + > + Implement in a new thread + + + +
+ ) + ) : ( + + ) + ) : null} +
+
+ )} +
+
+
{isGitRepo && ( diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 7eadd4e41be6..4d5653596bb7 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -98,15 +98,8 @@ function createBaseServerConfig(): ServerConfig { textGenerationModelSelection: { provider: "codex" as const, model: "gpt-5.4-mini" }, providers: { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, - claudeAgent: { enabled: true, binaryPath: "", customModels: [], launchArgs: "" }, + claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, - opencode: { - enabled: true, - binaryPath: "", - serverUrl: "", - serverPassword: "", - customModels: [], - }, }, }, }; diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 7619a635545f..0e753f368c5d 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -1,10 +1,4 @@ -import { - DEFAULT_MODEL_BY_PROVIDER, - EnvironmentId, - ModelSelection, - ThreadId, -} from "@t3tools/contracts"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime"; +import { DEFAULT_MODEL_BY_PROVIDER, type ModelSelection, ThreadId } from "@t3tools/contracts"; import "../../index.css"; import { page } from "vitest/browser"; @@ -12,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; +import { CursorTraitsMenuContent } from "./CursorTraitsPicker"; import { TraitsMenuContent } from "./TraitsPicker"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -102,23 +97,24 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str }, }, ] - : [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "xhigh", label: "Extra High" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], + : provider === "codex" + ? [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "xhigh", label: "Extra High" }, + { value: "high", label: "High", isDefault: true }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, }, - }, - ]; + ] + : []; const screen = await render( + provider === "cursor" ? ( + + ) : ( + + ) } onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} @@ -230,7 +230,25 @@ describe("CompactComposerControlsMenu", () => { }); }); - it("shows prompt-controlled Ultrathink state with selectable effort controls", async () => { + it("shows Cursor reasoning controls for GPT-5.3 Codex family", async () => { + const mounted = await mountMenu({ + modelSelection: { 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 () => { await using _ = await mountMenu({ modelSelection: { provider: "claudeAgent", diff --git a/apps/web/src/components/chat/CursorTraitsPicker.tsx b/apps/web/src/components/chat/CursorTraitsPicker.tsx new file mode 100644 index 000000000000..fe589049fd82 --- /dev/null +++ b/apps/web/src/components/chat/CursorTraitsPicker.tsx @@ -0,0 +1,238 @@ +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 setModelSelection = useComposerDraftStore((s) => s.setModelSelection); + const setStickyModelSelection = useComposerDraftStore((s) => s.setStickyModelSelection); + 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); + const nextModelSelection = { provider: "cursor" as const, model: nextSel.family }; + setModelSelection(threadId, nextModelSelection); + setProviderModelOptions(threadId, "cursor", persisted, { persistSticky: true }); + setStickyModelSelection(nextModelSelection); + }, + [setModelSelection, setProviderModelOptions, setStickyModelSelection, 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 e4b683592ede..8c9ac8d6431b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -938,14 +938,13 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { const iconConfig = workToneIcon(workEntry.tone); const EntryIcon = workEntryIcon(workEntry); const heading = toolWorkEntryHeading(workEntry); - const rawPreview = workEntryPreview(workEntry, workspaceRoot); + const rawPreview = workEntryPreview(workEntry); const preview = rawPreview && normalizeCompactToolLabel(rawPreview).toLowerCase() === normalizeCompactToolLabel(heading).toLowerCase() ? null : rawPreview; - const rawCommand = workEntryRawCommand(workEntry); 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 abedcd6eeb36..65c3168b0b2d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,4 +1,9 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { + CURSOR_MODEL_FAMILY_OPTIONS, + type ModelSlug, + type ProviderKind, + type ServerProvider, +} from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -140,12 +145,15 @@ async function mountPicker(props: { document.body.append(host); const onProviderModelChange = vi.fn(); const providers = props.providers ?? TEST_PROVIDERS; - const modelOptionsByProvider = getCustomModelOptionsByProvider( - DEFAULT_UNIFIED_SETTINGS, - providers, - props.provider, - props.model, - ); + const modelOptionsByProvider = { + ...getCustomModelOptionsByProvider( + DEFAULT_UNIFIED_SETTINGS, + providers, + props.provider, + props.model, + ), + cursor: [...CURSOR_MODEL_FAMILY_OPTIONS], + }; const screen = await render( , @@ -260,90 +269,20 @@ describe("ProviderModelPicker", () => { } }); - it("only shows codex spark when the server reports it for the account", async () => { - const providersWithoutSpark: ReadonlyArray = [ - buildCodexProvider([ - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ]), - TEST_PROVIDERS[1]!, - ]; - const providersWithSpark: ReadonlyArray = [ - buildCodexProvider([ - { - slug: "gpt-5.3-codex", - name: "GPT-5.3 Codex", - isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex-spark", - name: "GPT-5.3 Codex Spark", - isCustom: false, - capabilities: { - reasoningEffortLevels: [effort("low"), effort("medium", true), effort("high")], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ]), - TEST_PROVIDERS[1]!, - ]; - - const hidden = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", - lockedProvider: null, - providers: providersWithoutSpark, - }); - - try { - await page.getByRole("button").click(); - await page.getByRole("menuitem", { name: "Codex" }).hover(); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("GPT-5.3 Codex"); - expect(text).not.toContain("GPT-5.3 Codex Spark"); - }); - } finally { - await hidden.cleanup(); - } - - const visible = await mountPicker({ - provider: "claudeAgent", - model: "claude-opus-4-6", - lockedProvider: null, - providers: providersWithSpark, + 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("menuitem", { name: "Codex" }).hover(); + await page.getByRole("menuitemradio", { name: "Codex 5.3" }).click(); - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("GPT-5.3 Codex Spark"); - }); + expect(mounted.onProviderModelChange).toHaveBeenCalledWith("cursor", "gpt-5.3-codex"); } finally { - await visible.cleanup(); + await mounted.cleanup(); } }); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 8b20237a83da..1042672ccdb8 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,5 +1,17 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; -import { resolveSelectableModel } from "@t3tools/shared/model"; +import { + CURSOR_MODEL_FAMILY_OPTIONS, + MODEL_OPTIONS_BY_PROVIDER, + type CursorModelOptions, + type ModelSlug, + type ProviderKind, + type ServerProvider, +} from "@t3tools/contracts"; +import { + isCursorModelFamilySlug, + parseCursorModelSelection, + resolveModelSlugForProvider, + resolveSelectableModel, +} from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; @@ -59,27 +71,56 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { disabled?: boolean; triggerVariant?: VariantProps["variant"]; triggerClassName?: string; - onProviderModelChange: (provider: ProviderKind, model: string) => void; + disabledReason?: string; + 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 ( } > @@ -128,7 +170,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {props.lockedProvider !== null ? ( handleModelChange(props.lockedProvider!, value)} > {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( @@ -186,7 +228,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 c4dd2cbb6ee1..803db6bbc826 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -313,7 +313,41 @@ describe("getComposerProviderState", () => { }); }); - it("preserves Claude default effort explicitly in dispatch options", () => { + it("returns minimal state for Cursor without trait controls", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "auto", + models: [], + 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", + models: [], + prompt: "", + modelOptions: { + cursor: { fastMode: true }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: null, + modelOptionsForDispatch: { fastMode: true }, + }); + }); + + it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index af03c99a5e7e..c87a7c8c1382 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -6,15 +6,18 @@ import { } from "@t3tools/contracts"; import { isClaudeUltrathinkPrompt, - normalizeProviderModelOptionsWithCapabilities, - resolveEffort, + normalizeCursorModelOptions, trimOrNull, } from "@t3tools/shared/model"; +import type { CursorModelOptions } from "@t3tools/contracts"; import type { ReactNode } from "react"; - -import type { DraftId } from "../../composerDraftStore"; -import { getProviderModelCapabilities } from "../../providerModels"; -import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; +import { + getProviderModelCapabilities, + normalizeClaudeModelOptionsWithCapabilities, + normalizeCodexModelOptionsWithCapabilities, +} from "../../providerModels"; +import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; +import { CursorTraitsMenuContent, CursorTraitsPicker } from "./CursorTraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; @@ -157,12 +160,94 @@ function createProviderRegistryEntry( } const composerProviderRegistry: Record = { - codex: createProviderRegistryEntry("codex"), - claudeAgent: createProviderRegistryEntry("claudeAgent"), - cursor: createProviderRegistryEntry("cursor"), - opencode: createProviderRegistryEntry("opencode", { - showInteractionModeToggle: false, - }), + codex: { + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( + + ), + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( + + ), + }, + claudeAgent: { + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( + + ), + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( + + ), + }, + cursor: { + getState: ({ model, modelOptions }) => { + const normalized = normalizeCursorModelOptions(model, modelOptions?.cursor); + return { + provider: "cursor" as const, + promptEffort: null, + modelOptionsForDispatch: normalized ?? undefined, + }; + }, + renderTraitsMenuContent: ({ threadId, model, modelOptions }) => ( + + ), + renderTraitsPicker: ({ threadId, model, modelOptions }) => ( + + ), + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 36304fc02365..0c5411d2f1a3 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,11 +1,12 @@ import { + CODEX_REASONING_EFFORT_OPTIONS, CURSOR_REASONING_OPTIONS, - DEFAULT_MODEL_BY_PROVIDER, + type ClaudeCodeEffort, + type CodexReasoningEffort, type CursorModelOptions, type CursorReasoningOption, - ClaudeAgentEffort, - CodexReasoningEffort, - type EnvironmentId, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, + type ModelSlug, ModelSelection, ProjectId, ProviderInteractionMode, @@ -531,9 +532,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { } function normalizeProviderKind(value: unknown): ProviderKind | null { - return value === "codex" || value === "claudeAgent" || value === "cursor" || value === "opencode" - ? value - : null; + return value === "codex" || value === "claudeAgent" || value === "cursor" ? value : null; } function normalizeProviderModelOptions( @@ -554,10 +553,6 @@ function normalizeProviderModelOptions( candidate?.cursor && typeof candidate.cursor === "object" ? (candidate.cursor as Record) : null; - const openCodeCandidate = - candidate?.opencode && typeof candidate.opencode === "object" - ? (candidate.opencode as Record) - : null; const isCodexReasoningEffort = Schema.is(CodexReasoningEffort); const isClaudeAgentEffort = Schema.is(ClaudeAgentEffort); @@ -624,60 +619,35 @@ function normalizeProviderModelOptions( (CURSOR_REASONING_OPTIONS as readonly string[]).includes(cursorReasoningRaw) ? (cursorReasoningRaw as CursorReasoningOption) : undefined; - const cursorFastMode = - cursorCandidate?.fastMode === true - ? true - : cursorCandidate?.fastMode === false - ? false - : undefined; - const cursorThinking = - cursorCandidate?.thinking === true - ? true - : cursorCandidate?.thinking === false - ? false - : undefined; - const cursorContextWindow = - typeof cursorCandidate?.contextWindow === "string" && cursorCandidate.contextWindow.length > 0 - ? cursorCandidate.contextWindow + 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 - ? (() => { - const nextCursor = { - ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), - ...(cursorFastMode !== undefined ? { fastMode: cursorFastMode } : {}), - ...(cursorThinking !== undefined ? { thinking: cursorThinking } : {}), - ...(cursorContextWindow !== undefined ? { contextWindow: cursorContextWindow } : {}), - } satisfies CursorModelOptions; - return Object.keys(nextCursor).length > 0 ? nextCursor : undefined; - })() - : undefined; - - const openCodeVariant = - typeof openCodeCandidate?.variant === "string" && openCodeCandidate.variant.length > 0 - ? openCodeCandidate.variant - : undefined; - const openCodeAgent = - typeof openCodeCandidate?.agent === "string" && openCodeCandidate.agent.length > 0 - ? openCodeCandidate.agent - : undefined; - const opencode = - openCodeVariant !== undefined || openCodeAgent !== undefined ? { - ...(openCodeVariant !== undefined ? { variant: openCodeVariant } : {}), - ...(openCodeAgent !== undefined ? { agent: openCodeAgent } : {}), + ...(cursorReasoning && cursorReasoning !== defaultCursorReasoning + ? { reasoning: cursorReasoning } + : {}), + ...(cursorFastMode ? { fastMode: true } : {}), + ...(cursorThinkingFalse ? { thinking: false } : {}), + ...(cursorClaudeOpusTier === "max" ? { claudeOpusTier: "max" } : {}), } : undefined; - if (!codex && !claude && cursor === undefined && !opencode) { + if (!codex && !claude && cursor === undefined) { return null; } return { ...(codex ? { codex } : {}), ...(claude ? { claudeAgent: claude } : {}), ...(cursor !== undefined ? { cursor } : {}), - ...(opencode ? { opencode } : {}), }; } diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 60ab386b8395..0ab6dbaf8829 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -54,14 +54,7 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record = []; + +type InstallProviderSettings = { + provider: ProviderKind; + title: string; + binaryPlaceholder: string; + binaryDescription: ReactNode; + homePathKey?: "codexHomePath"; + homePlaceholder?: string; + homeDescription?: ReactNode; +}; + +const PROVIDER_SETTINGS: readonly InstallProviderSettings[] = [ + { + provider: "codex", + title: "Codex", + binaryPlaceholder: "Codex binary path", + binaryDescription: "Path to the Codex binary", + homePathKey: "codexHomePath", + homePlaceholder: "CODEX_HOME", + homeDescription: "Optional custom Codex home and config directory.", + }, + { + provider: "claudeAgent", + title: "Claude", + binaryPlaceholder: "Claude binary path", + binaryDescription: "Path to the Claude binary", + }, + { + provider: "cursor", + title: "Cursor", + binaryPlaceholder: "Cursor agent binary path", + binaryDescription: "Path to the Cursor agent binary", + }, +]; + +const PROVIDER_STATUS_STYLES = { + disabled: { + dot: "bg-amber-400", + badge: "warning" as const, + }, + error: { + dot: "bg-destructive", + badge: "error" as const, + }, + ready: { + dot: "bg-success", + badge: "success" as const, + }, + warning: { + dot: "bg-warning", + badge: "warning" as const, + }, +} as const; + +function getProviderSummary(provider: ServerProvider | undefined): { + readonly headline: string; + readonly detail: string | null; +} { + if (!provider) { + return { + headline: "Checking provider status", + detail: "Waiting for the server to report installation and authentication details.", + }; + } + if (!provider.enabled) { + return { + headline: "Disabled", + detail: + provider.message ?? "This provider is installed but disabled for new sessions in T3 Code.", + }; + } + if (!provider.installed) { + return { + headline: "Not found", + detail: provider.message ?? "CLI not detected on PATH.", + }; + } + if (provider.authStatus === "authenticated") { + return { + headline: "Authenticated", + detail: provider.message ?? null, + }; + } + if (provider.authStatus === "unauthenticated") { + return { + headline: "Not authenticated", + detail: provider.message ?? null, + }; + } + if (provider.status === "warning") { + return { + headline: "Needs attention", + detail: + provider.message ?? "The provider is installed, but the server could not fully verify it.", + }; + } + if (provider.status === "error") { + return { + headline: "Unavailable", + detail: provider.message ?? "The provider failed its startup checks.", + }; + } + return { + headline: "Available", + detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + }; +} + +function getProviderVersionLabel(version: string | null | undefined): string | null { + if (!version) return null; + return version.startsWith("v") ? version : `v${version}`; +} + +/** Returns a timestamp that updates on an interval, forcing re-renders to keep relative times fresh. */ +function useRelativeTimeTick(intervalMs = 1_000): number { + const [tick, setTick] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setTick(Date.now()), intervalMs); + return () => clearInterval(id); + }, [intervalMs]); + return tick; +} + +function SettingsSection({ + title, + headerAction, + children, +}: { + title: string; + headerAction?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+

+ {title} +

+ {headerAction} +
+
+ {children} +
+
+ ); +} + +function SettingsRow({ + title, + description, + status, + resetAction, + control, + children, +}: { + title: string; + description: string; + status?: ReactNode; + resetAction?: ReactNode; + control?: ReactNode; + children?: ReactNode; +}) { + return ( +
+
+
+
+

{title}

+ + {resetAction} + +
+

{description}

+ {status ?
{status}
: null} +
+ {control ? ( +
+ {control} +
+ ) : null} +
+ {children} +
+ ); +} + +function SettingResetButton({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + { + event.stopPropagation(); + onClick(); + }} + > + + + } + /> + Reset to default + + ); +} + +function SettingsRouteView() { + const { theme, setTheme } = useTheme(); + const settings = useSettings(); + const { updateSettings, resetSettings } = useUpdateSettings(); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); + const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [openProviderDetails, setOpenProviderDetails] = useState>({ + codex: Boolean( + settings.providers.codex.binaryPath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.binaryPath || + settings.providers.codex.homePath !== DEFAULT_UNIFIED_SETTINGS.providers.codex.homePath || + settings.providers.codex.customModels.length > 0, + ), + claudeAgent: Boolean( + settings.providers.claudeAgent.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.claudeAgent.binaryPath || + settings.providers.claudeAgent.customModels.length > 0, + ), + cursor: Boolean( + settings.providers.cursor.binaryPath !== + DEFAULT_UNIFIED_SETTINGS.providers.cursor.binaryPath || + settings.providers.cursor.apiEndpoint !== + DEFAULT_UNIFIED_SETTINGS.providers.cursor.apiEndpoint || + settings.providers.cursor.customModels.length > 0, + ), + }); + const [customModelInputByProvider, setCustomModelInputByProvider] = useState< + Record + >({ + codex: "", + claudeAgent: "", + cursor: "", + }); + const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< + Partial> + >({}); + const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); + const refreshingRef = useRef(false); + const queryClient = useQueryClient(); + useRelativeTimeTick(); + + const refreshProviders = useCallback(() => { + if (refreshingRef.current) return; + refreshingRef.current = true; + setIsRefreshingProviders(true); + const api = ensureNativeApi(); + api.server + .refreshProviders() + .then(() => queryClient.invalidateQueries({ queryKey: serverQueryKeys.config() })) + .catch((error: unknown) => { + console.warn("Failed to refresh providers", error); + }) + .finally(() => { + refreshingRef.current = false; + setIsRefreshingProviders(false); + }); + }, [queryClient]); + + const modelListRefs = useRef>>({}); + + const codexHomePath = settings.providers.codex.homePath; + const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; + const availableEditors = serverConfigQuery.data?.availableEditors; + const serverProviders = serverConfigQuery.data?.providers ?? EMPTY_SERVER_PROVIDERS; + + const textGenerationModelSelection = resolveAppModelSelectionState(settings, serverProviders); + const textGenProvider = textGenerationModelSelection.provider; + const textGenModel = textGenerationModelSelection.model; + const textGenModelOptions = textGenerationModelSelection.options; + const gitModelOptionsByProvider = getCustomModelOptionsByProvider( + settings, + serverProviders, + textGenProvider, + textGenModel, + ); + const areProviderSettingsDirty = PROVIDER_SETTINGS.some((providerSettings) => { + const currentSettings = settings.providers[providerSettings.provider]; + const defaultSettings = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; + return !Equal.equals(currentSettings, defaultSettings); + }); + const isGitWritingModelDirty = !Equal.equals( + settings.textGenerationModelSelection ?? null, + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection ?? null, + ); + const changedSettingLabels = [ + ...(theme !== "system" ? ["Theme"] : []), + ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat + ? ["Time format"] + : []), + ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap + ? ["Diff line wrapping"] + : []), + ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming + ? ["Assistant output"] + : []), + ...(settings.defaultThreadEnvMode !== DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode + ? ["New thread mode"] + : []), + ...(settings.confirmThreadDelete !== DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete + ? ["Delete confirmation"] + : []), + ...(isGitWritingModelDirty ? ["Git writing model"] : []), + ...(areProviderSettingsDirty ? ["Providers"] : []), + ]; + + const openKeybindingsFile = useCallback(() => { + if (!keybindingsConfigPath) return; + setOpenKeybindingsError(null); + setIsOpeningKeybindings(true); + const api = ensureNativeApi(); + const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); + if (!editor) { + setOpenKeybindingsError("No available editors found."); + setIsOpeningKeybindings(false); + return; + } + void api.shell + .openInEditor(keybindingsConfigPath, editor) + .catch((error) => { + setOpenKeybindingsError( + error instanceof Error ? error.message : "Unable to open keybindings file.", + ); + }) + .finally(() => { + setIsOpeningKeybindings(false); + }); + }, [availableEditors, keybindingsConfigPath]); + + const addCustomModel = useCallback( + (provider: ProviderKind) => { + const customModelInput = customModelInputByProvider[provider]; + const customModels = settings.providers[provider].customModels; + const normalized = normalizeModelSlug(customModelInput, provider); + if (!normalized) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "Enter a model slug.", + })); + return; + } + if ( + serverProviders + .find((candidate) => candidate.provider === provider) + ?.models.some((option) => !option.isCustom && option.slug === normalized) + ) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That model is already built in.", + })); + return; + } + if (normalized.length > MAX_CUSTOM_MODEL_LENGTH) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: `Model slugs must be ${MAX_CUSTOM_MODEL_LENGTH} characters or less.`, + })); + return; + } + if (customModels.includes(normalized)) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: "That custom model is already saved.", + })); + return; + } + + updateSettings({ + providers: { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: [...customModels, normalized], + }, + }, + }); + setCustomModelInputByProvider((existing) => ({ + ...existing, + [provider]: "", + })); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: null, + })); + // Watch for DOM changes (server may push updated model list) and scroll to bottom + const el = modelListRefs.current[provider]; + if (el) { + const scrollToEnd = () => el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + // Immediate scroll for the optimistic update + requestAnimationFrame(scrollToEnd); + // Also observe mutations for when the server pushes an updated list + const observer = new MutationObserver(() => { + scrollToEnd(); + observer.disconnect(); + }); + observer.observe(el, { childList: true, subtree: true }); + // Clean up observer after a reasonable window + setTimeout(() => observer.disconnect(), 2000); + } + }, + [customModelInputByProvider, serverProviders, settings, updateSettings], + ); + + const removeCustomModel = useCallback( + (provider: ProviderKind, slug: string) => { + const customModels = settings.providers[provider].customModels; + updateSettings({ + providers: { + ...settings.providers, + [provider]: { + ...settings.providers[provider], + customModels: customModels.filter((model) => model !== slug), + }, + }, + }); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [provider]: null, + })); + }, + [settings, updateSettings], + ); + + const providerCards = PROVIDER_SETTINGS.map((providerSettings) => { + const liveProvider = serverProviders.find( + (candidate) => candidate.provider === providerSettings.provider, + ); + const providerConfig = settings.providers[providerSettings.provider]; + const defaultProviderConfig = DEFAULT_UNIFIED_SETTINGS.providers[providerSettings.provider]; + const statusKey = liveProvider?.status ?? (providerConfig.enabled ? "warning" : "disabled"); + const statusStyle = PROVIDER_STATUS_STYLES[statusKey]; + const summary = getProviderSummary(liveProvider); + const models: ReadonlyArray = + liveProvider?.models ?? + providerConfig.customModels.map((slug) => ({ + slug, + name: slug, + isCustom: true, + capabilities: null, + })); + const binaryPathValue = providerConfig.binaryPath; + const isDirty = !Equal.equals(providerConfig, defaultProviderConfig); + + return { + provider: providerSettings.provider, + title: providerSettings.title, + binaryPlaceholder: providerSettings.binaryPlaceholder, + binaryDescription: providerSettings.binaryDescription, + homePathKey: providerSettings.homePathKey, + homePlaceholder: providerSettings.homePlaceholder, + homeDescription: providerSettings.homeDescription, + binaryPathValue, + isDirty, + liveProvider, + models, + providerConfig, + statusKey, + statusStyle, + summary, + versionLabel: getProviderVersionLabel(liveProvider?.version), + }; + }); + + async function restoreDefaults() { + if (changedSettingLabels.length === 0) return; + + const api = readNativeApi(); + const confirmed = await (api ?? ensureNativeApi()).dialogs.confirm( + ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join( + "\n", + ), + ); + if (!confirmed) return; + + setTheme("system"); + resetSettings(); + setOpenProviderDetails({ + codex: false, + claudeAgent: false, + cursor: false, + }); + setCustomModelInputByProvider({ + codex: "", + claudeAgent: "", + cursor: "", + }); + setCustomModelErrorByProvider({}); + } + + return ( + +
+ {!isElectron && ( +
+
+ + Settings +
+ +
+
+
+ )} + + {isElectron && ( +
+ + Settings + +
+ +
+
+ )} + +
+
+ + setTheme("system")} /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + timestampFormat: DEFAULT_UNIFIED_SETTINGS.timestampFormat, + }) + } + /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + diffWordWrap: DEFAULT_UNIFIED_SETTINGS.diffWordWrap, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + diffWordWrap: Boolean(checked), + }) + } + aria-label="Wrap diff lines by default" + /> + } + /> + + + updateSettings({ + enableAssistantStreaming: + DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + enableAssistantStreaming: Boolean(checked), + }) + } + aria-label="Stream assistant messages" + /> + } + /> + + + updateSettings({ + defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, + }) + } + /> + ) : null + } + control={ + + } + /> + + + updateSettings({ + confirmThreadDelete: DEFAULT_UNIFIED_SETTINGS.confirmThreadDelete, + }) + } + /> + ) : null + } + control={ + + updateSettings({ + confirmThreadDelete: Boolean(checked), + }) + } + aria-label="Confirm thread deletion" + /> + } + /> + { + updateSettings({ + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + }); + }} + /> + ) : null + } + control={ +
+ { + updateSettings({ + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: { provider, model }, + }, + serverProviders, + ), + }); + }} + /> + provider.provider === textGenProvider) + ?.models ?? [] + } + model={textGenModel} + prompt="" + onPromptChange={() => {}} + modelOptions={textGenModelOptions} + allowPromptInjectedEffort={false} + triggerVariant="outline" + triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" + onModelOptionsChange={(nextOptions) => { + updateSettings({ + textGenerationModelSelection: resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: { + provider: textGenProvider, + model: textGenModel, + ...(nextOptions ? { options: nextOptions } : {}), + }, + }, + serverProviders, + ), + }); + }} + /> +
+ } + /> +
+ + + {serverProviders.length > 0 ? ( + + {(() => { + const rel = formatRelativeTime( + serverProviders.reduce( + (latest, provider) => + provider.checkedAt > latest ? provider.checkedAt : latest, + serverProviders[0]!.checkedAt, + ), + ); + return rel.suffix ? ( + <> + Checked {rel.value}{" "} + {rel.suffix} + + ) : ( + <>Checked {rel.value} + ); + })()} + + ) : null} + + void refreshProviders()} + aria-label="Refresh provider status" + > + {isRefreshingProviders ? ( + + ) : ( + + )} + + } + /> + Refresh provider status + +
+ } + > + {providerCards.map((providerCard) => { + const customModelInput = customModelInputByProvider[providerCard.provider]; + const customModelError = customModelErrorByProvider[providerCard.provider] ?? null; + const providerDisplayName = + PROVIDER_DISPLAY_NAMES[providerCard.provider] ?? providerCard.title; + + return ( +
+
+
+
+
+ +

+ {providerDisplayName} +

+ {providerCard.versionLabel ? ( + + {providerCard.versionLabel} + + ) : null} + + {providerCard.isDirty ? ( + { + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: + DEFAULT_UNIFIED_SETTINGS.providers[providerCard.provider], + }, + }); + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [providerCard.provider]: null, + })); + }} + /> + ) : null} + +
+

+ {providerCard.summary.headline} + {providerCard.summary.detail + ? ` — ${providerCard.summary.detail}` + : null} +

+
+
+ + { + const isDisabling = !checked; + // The resolved provider accounts for both explicit + // selection and the implicit default (codex). + const resolvedProvider = textGenProvider; + // When disabling the provider that's currently used for + // text generation, clear the selection so it falls back to + // the next available provider's default model. + const shouldClearModelSelection = + isDisabling && resolvedProvider === providerCard.provider; + updateSettings({ + providers: { + ...settings.providers, + [providerCard.provider]: { + ...settings.providers[providerCard.provider], + enabled: Boolean(checked), + }, + }, + ...(shouldClearModelSelection + ? { + textGenerationModelSelection: + DEFAULT_UNIFIED_SETTINGS.textGenerationModelSelection, + } + : {}), + }); + }} + aria-label={`Enable ${providerDisplayName}`} + /> +
+
+
+ + + setOpenProviderDetails((existing) => ({ + ...existing, + [providerCard.provider]: open, + })) + } + > + +
+ {/* Binary path */} +
+ +
+ + {/* Home path (Codex only) */} + {providerCard.homePathKey ? ( +
+ +
+ ) : null} + + {/* Models */} +
+
Models
+
+ {providerCard.models.length} model + {providerCard.models.length === 1 ? "" : "s"} available. +
+
{ + modelListRefs.current[providerCard.provider] = el; + }} + className="mt-2 max-h-40 overflow-y-auto pb-1" + > + {providerCard.models.map((model) => { + const caps = model.capabilities; + const capLabels: string[] = []; + if (caps?.supportsFastMode) capLabels.push("Fast mode"); + if (caps?.supportsThinkingToggle) capLabels.push("Thinking"); + if ( + caps?.reasoningEffortLevels && + caps.reasoningEffortLevels.length > 0 + ) + capLabels.push("Reasoning"); + const hasDetails = + capLabels.length > 0 || model.name !== model.slug; + + return ( +
+ + {model.name} + + {hasDetails ? ( + + + } + > + + + +
+ + {model.slug} + + {capLabels.length > 0 ? ( +
+ {capLabels.map((label) => ( + + {label} + + ))} +
+ ) : null} +
+
+
+ ) : null} + {model.isCustom ? ( +
+ + custom + + +
+ ) : null} +
+ ); + })} +
+
+ { + const value = event.target.value; + setCustomModelInputByProvider((existing) => ({ + ...existing, + [providerCard.provider]: value, + })); + if (customModelError) { + setCustomModelErrorByProvider((existing) => ({ + ...existing, + [providerCard.provider]: null, + })); + } + }} + onKeyDown={(event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + addCustomModel(providerCard.provider); + }} + placeholder={ + providerCard.provider === "codex" + ? "gpt-6.7-codex-ultra-preview" + : providerCard.provider === "cursor" + ? "claude-4.6-sonnet-medium-thinking" + : "claude-sonnet-5-0" + } + spellCheck={false} + /> + +
+ {customModelError ? ( +

{customModelError}

+ ) : null} +
+
+
+
+
+ ); + })} + + + + + + {keybindingsConfigPath ?? "Resolving keybindings path..."} + + {openKeybindingsError ? ( + {openKeybindingsError} + ) : ( + Opens in your preferred editor. + )} + + } + control={ + + } + /> + + {APP_VERSION} + } + /> + +
+
+ +
+ ); +} + +export const Route = createFileRoute("/_chat/settings")({ + component: SettingsRouteView, +}); diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index bee0fd0029f9..10eb19926cd8 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1038,41 +1038,6 @@ describe("deriveWorkLogEntries", () => { }); }); - it("does not use command stdout as the detail when Cursor omits the command input", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "cursor-command-complete", - createdAt: "2026-04-16T22:40:42.221Z", - kind: "tool.completed", - summary: "Ran command", - payload: { - itemType: "command_execution", - title: "Ran command", - data: { - toolCallId: "toolu_vrtx_01WypXgRM8PPygBtrVAZwzy5", - kind: "execute", - rawInput: {}, - rawOutput: { - exitCode: 0, - stdout: "total 960\napps\npackages\n", - stderr: "", - }, - }, - }, - }), - ]; - - const [entry] = deriveWorkLogEntries(activities, undefined); - expect(entry).toMatchObject({ - id: "cursor-command-complete", - label: "Ran command", - itemType: "command_execution", - toolTitle: "Ran command", - }); - expect(entry?.detail).toBeUndefined(); - expect(entry?.command).toBeUndefined(); - }); - it("collapses legacy completed tool rows that are missing tool metadata", () => { const activities: OrchestrationThreadActivity[] = [ makeActivity({ @@ -1517,3 +1482,25 @@ describe("deriveActiveWorkStartedAt", () => { ).toBe("2026-02-27T21:11:00.000Z"); }); }); + +describe("PROVIDER_OPTIONS", () => { + 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: true }, + ]); + expect(claude).toEqual({ + value: "claudeAgent", + label: "Claude", + available: true, + }); + expect(cursor).toEqual({ + value: "cursor", + label: "Cursor", + available: true, + }); + }); +}); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 477bc57bea01..0ba7c07b061b 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -31,7 +31,6 @@ export const PROVIDER_OPTIONS: Array<{ }> = [ { value: "codex", label: "Codex", available: true }, { value: "claudeAgent", label: "Claude", available: true }, - { value: "opencode", label: "OpenCode", available: true }, { value: "cursor", label: "Cursor", available: true }, ]; @@ -505,28 +504,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const commandPreview = extractToolCommand(payload); const changedFiles = extractChangedFiles(payload); const title = extractToolTitle(payload); - const isTaskActivity = activity.kind === "task.progress" || activity.kind === "task.completed"; - const taskSummary = - isTaskActivity && typeof payload?.summary === "string" && payload.summary.length > 0 - ? payload.summary - : null; - const taskDetailAsLabel = - isTaskActivity && - !taskSummary && - typeof payload?.detail === "string" && - payload.detail.length > 0 - ? payload.detail - : null; - const taskLabel = taskSummary || taskDetailAsLabel; - const detail = isTaskActivity - ? !taskDetailAsLabel && - payload && - typeof payload.detail === "string" && - payload.detail.length > 0 - ? stripTrailingExitCode(payload.detail).output - : null - : extractToolDetail(payload, title ?? activity.summary); - const toolCallId = isTaskActivity ? null : extractToolCallId(payload); + const detail = extractToolDetail(payload, title ?? activity.summary); + const toolCallId = extractToolCallId(payload); const entry: DerivedWorkLogEntry = { id: activity.id, createdAt: activity.createdAt, @@ -699,121 +678,7 @@ function asNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } -function trimMatchingOuterQuotes(value: string): string { - const trimmed = value.trim(); - if ( - (trimmed.startsWith("'") && trimmed.endsWith("'")) || - (trimmed.startsWith('"') && trimmed.endsWith('"')) - ) { - const unquoted = trimmed.slice(1, -1).trim(); - return unquoted.length > 0 ? unquoted : trimmed; - } - return trimmed; -} - -function executableBasename(value: string): string | null { - const trimmed = trimMatchingOuterQuotes(value); - if (trimmed.length === 0) { - return null; - } - const normalized = trimmed.replace(/\\/g, "/"); - const segments = normalized.split("/"); - const last = segments.at(-1)?.trim() ?? ""; - return last.length > 0 ? last.toLowerCase() : null; -} - -function splitExecutableAndRest(value: string): { executable: string; rest: string } | null { - const trimmed = value.trim(); - if (trimmed.length === 0) { - return null; - } - - if (trimmed.startsWith('"') || trimmed.startsWith("'")) { - const quote = trimmed.charAt(0); - const closeIndex = trimmed.indexOf(quote, 1); - if (closeIndex <= 0) { - return null; - } - return { - executable: trimmed.slice(0, closeIndex + 1), - rest: trimmed.slice(closeIndex + 1).trim(), - }; - } - - const firstWhitespace = trimmed.search(/\s/); - if (firstWhitespace < 0) { - return { - executable: trimmed, - rest: "", - }; - } - - return { - executable: trimmed.slice(0, firstWhitespace), - rest: trimmed.slice(firstWhitespace).trim(), - }; -} - -const SHELL_WRAPPER_SPECS = [ - { - executables: ["pwsh", "pwsh.exe", "powershell", "powershell.exe"], - wrapperFlagPattern: /(?:^|\s)-command\s+/i, - }, - { - executables: ["cmd", "cmd.exe"], - wrapperFlagPattern: /(?:^|\s)\/c\s+/i, - }, - { - executables: ["bash", "sh", "zsh"], - wrapperFlagPattern: /(?:^|\s)-(?:l)?c\s+/i, - }, -] as const; - -function findShellWrapperSpec(shell: string) { - return SHELL_WRAPPER_SPECS.find((spec) => - (spec.executables as ReadonlyArray).includes(shell), - ); -} - -function unwrapCommandRemainder(value: string, wrapperFlagPattern: RegExp): string | null { - const match = wrapperFlagPattern.exec(value); - if (!match) { - return null; - } - - const command = value.slice(match.index + match[0].length).trim(); - if (command.length === 0) { - return null; - } - - const unwrapped = trimMatchingOuterQuotes(command); - return unwrapped.length > 0 ? unwrapped : null; -} - -function unwrapKnownShellCommandWrapper(value: string): string { - const split = splitExecutableAndRest(value); - if (!split || split.rest.length === 0) { - return value; - } - - const shell = executableBasename(split.executable); - if (!shell) { - return value; - } - - const spec = findShellWrapperSpec(shell); - if (!spec) { - return value; - } - - return unwrapCommandRemainder(split.rest, spec.wrapperFlagPattern) ?? value; -} - -function formatCommandArrayPart(value: string): string { - return /[\s"'`]/.test(value) ? `"${value.replace(/"/g, '\\"')}"` : value; -} - -function formatCommandValue(value: unknown): string | null { +function normalizeCommandValue(value: unknown): string | null { const direct = asTrimmedString(value); if (direct) { return direct; @@ -947,18 +812,6 @@ function summarizeToolRawOutput(payload: Record | null): string return null; } -function isCommandToolDetail(payload: Record | null, heading: string): boolean { - const data = asRecord(payload?.data); - const kind = asTrimmedString(data?.kind)?.toLowerCase(); - const title = asTrimmedString(payload?.title ?? heading)?.toLowerCase(); - return ( - extractWorkLogItemType(payload) === "command_execution" || - kind === "execute" || - title === "terminal" || - title === "ran command" - ); -} - function extractToolDetail( payload: Record | null, heading: string, @@ -972,10 +825,6 @@ function extractToolDetail( return detail; } - if (isCommandToolDetail(payload, heading)) { - return null; - } - const rawOutputSummary = summarizeToolRawOutput(payload); if (rawOutputSummary) { const normalizedRawOutputSummary = normalizePreviewForComparison(rawOutputSummary); diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 3b1976bf9aba..67076e4cb59d 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1001,7 +1001,7 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (Schema.is(ProviderKind)(providerName)) { + if (providerName === "codex" || providerName === "claudeAgent" || providerName === "cursor") { return providerName; } return "codex"; diff --git a/packages/contracts/src/cursorCliModels.json b/packages/contracts/src/cursorCliModels.json new file mode 100644 index 000000000000..a4a07e7d3385 --- /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 5bb82caf4218..1205f890db02 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,28 +1,20 @@ import { Schema } from "effect"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; -import type { ProviderKind } from "./orchestration.ts"; +import { TrimmedNonEmptyString } from "./baseSchemas"; +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 const CodexReasoningEffort = Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS); -export type CodexReasoningEffort = typeof CodexReasoningEffort.Type; -export const CLAUDE_AGENT_EFFORT_OPTIONS = [ - "low", - "medium", - "high", - "xhigh", - "max", - "ultrathink", -] as const; -export const ClaudeAgentEffort = Schema.Literals(CLAUDE_AGENT_EFFORT_OPTIONS); -export type ClaudeAgentEffort = typeof ClaudeAgentEffort.Type; -export type ClaudeCodeEffort = ClaudeAgentEffort; -export const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "max", "xhigh"] as const; -export const CursorReasoningOption = Schema.Literals(CURSOR_REASONING_OPTIONS); -export type CursorReasoningOption = typeof CursorReasoningOption.Type; +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]; + +/** 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 - | ClaudeAgentEffort + | ClaudeCodeEffort | CursorReasoningOption; export const CodexModelOptions = Schema.Struct({ @@ -39,24 +31,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(CursorReasoningOption), + reasoning: Schema.optional(Schema.Literals(CURSOR_REASONING_OPTIONS)), fastMode: Schema.optional(Schema.Boolean), thinking: Schema.optional(Schema.Boolean), - contextWindow: Schema.optional(Schema.String), + claudeOpusTier: Schema.optional(Schema.Literals(CURSOR_CLAUDE_OPUS_TIER_OPTIONS)), }); export type CursorModelOptions = typeof CursorModelOptions.Type; -export const OpenCodeModelOptions = Schema.Struct({ - variant: Schema.optional(TrimmedNonEmptyString), - agent: Schema.optional(TrimmedNonEmptyString), -}); -export type OpenCodeModelOptions = typeof OpenCodeModelOptions.Type; export const ProviderModelOptions = Schema.Struct({ codex: Schema.optional(CodexModelOptions), claudeAgent: Schema.optional(ClaudeModelOptions), cursor: Schema.optional(CursorModelOptions), - opencode: Schema.optional(OpenCodeModelOptions), }); export type ProviderModelOptions = typeof ProviderModelOptions.Type; @@ -85,11 +74,68 @@ export const ModelCapabilities = Schema.Struct({ }); export type ModelCapabilities = typeof ModelCapabilities.Type; -export const DEFAULT_MODEL_BY_PROVIDER: Record = { +export type ModelOption = { + readonly slug: string; + 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" }, + { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, + { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, + { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, + { slug: "gpt-5.2", name: "GPT-5.2" }, + ], + claudeAgent: [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, + { 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; + +export type ModelSlug = 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: "auto", - opencode: "openai/gpt-5", + cursor: "claude-4.6-opus-high-thinking", }; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; @@ -98,8 +144,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4-mini", claudeAgent: "claude-haiku-4-5", - cursor: "composer-2", - opencode: "openai/gpt-5", + cursor: "composer-2-fast", }; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { @@ -128,17 +173,24 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { codex: "Codex", claudeAgent: "Claude", cursor: "Cursor", - opencode: "OpenCode", }; + +export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { + codex: CODEX_REASONING_EFFORT_OPTIONS, + claudeAgent: CLAUDE_CODE_EFFORT_OPTIONS, + cursor: CURSOR_REASONING_OPTIONS, +} as const satisfies 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 087a66709011..678ca56683b0 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,11 +1,5 @@ -import { Effect, Option, Schema, SchemaIssue, Struct } from "effect"; -import { - ClaudeModelOptions, - CodexModelOptions, - CursorModelOptions, - OpenCodeModelOptions, -} from "./model.ts"; -import { RepositoryIdentity } from "./environment.ts"; +import { Option, Schema, SchemaIssue, Struct } from "effect"; +import { ClaudeModelOptions, CodexModelOptions, CursorModelOptions } from "./model"; import { ApprovalRequestId, CheckpointRef, @@ -30,7 +24,11 @@ export const ORCHESTRATION_WS_METHODS = { subscribeThread: "orchestration.subscribeThread", } as const; -export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor", "opencode"]); +export const ORCHESTRATION_WS_CHANNELS = { + domainEvent: "orchestration.domainEvent", +} as const; + +export const ProviderKind = Schema.Literals(["codex", "claudeAgent", "cursor"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ "untrusted", @@ -68,18 +66,11 @@ export const CursorModelSelection = Schema.Struct({ options: Schema.optionalKey(CursorModelOptions), }); export type CursorModelSelection = typeof CursorModelSelection.Type; -export const OpenCodeModelSelection = Schema.Struct({ - provider: Schema.Literal("opencode"), - model: TrimmedNonEmptyString, - options: Schema.optionalKey(OpenCodeModelOptions), -}); -export type OpenCodeModelSelection = typeof OpenCodeModelSelection.Type; export const ModelSelection = Schema.Union([ CodexModelSelection, ClaudeModelSelection, CursorModelSelection, - OpenCodeModelSelection, ]); export type ModelSelection = typeof ModelSelection.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 8387880ea350..ebcd99262fa3 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -17,16 +17,15 @@ import { ProviderKind } from "./orchestration.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); -const RuntimeEventRawSource = Schema.Union([ - Schema.Literal("codex.app-server.notification"), - Schema.Literal("codex.app-server.request"), - Schema.Literal("codex.eventmsg"), - Schema.Literal("claude.sdk.message"), - Schema.Literal("claude.sdk.permission"), - Schema.Literal("codex.sdk.thread-event"), - Schema.Literal("opencode.sdk.event"), - Schema.Literal("acp.jsonrpc"), - Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), +const RuntimeEventRawSource = Schema.Literals([ + "codex.app-server.notification", + "codex.app-server.request", + "codex.eventmsg", + "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/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 6c45dde53007..d58d1fcda7d2 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -92,26 +92,12 @@ export const ClaudeSettings = Schema.Struct({ export type ClaudeSettings = typeof ClaudeSettings.Type; export const CursorSettings = Schema.Struct({ - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(() => true)), binaryPath: makeBinaryPathSetting("agent"), - apiEndpoint: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + apiEndpoint: TrimmedString.pipe(Schema.withDecodingDefault(() => "")), + customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(() => [])), }); export type CursorSettings = typeof CursorSettings.Type; -export const OpenCodeSettings = Schema.Struct({ - enabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), - binaryPath: makeBinaryPathSetting("opencode"), - serverUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - serverPassword: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - customModels: Schema.Array(Schema.String).pipe(Schema.withDecodingDefault(Effect.succeed([]))), -}); -export type OpenCodeSettings = typeof OpenCodeSettings.Type; - -export const ObservabilitySettings = Schema.Struct({ - otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), - otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), -}); -export type ObservabilitySettings = typeof ObservabilitySettings.Type; export const ServerSettings = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), @@ -130,12 +116,10 @@ export const ServerSettings = Schema.Struct({ // Provider specific settings providers: Schema.Struct({ - codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), - }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), - observability: ObservabilitySettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + codex: CodexSettings.pipe(Schema.withDecodingDefault(() => ({}))), + claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(() => ({}))), + cursor: CursorSettings.pipe(Schema.withDecodingDefault(() => ({}))), + }).pipe(Schema.withDecodingDefault(() => ({}))), }); export type ServerSettings = typeof ServerSettings.Type; @@ -188,6 +172,13 @@ const OpenCodeModelOptionsPatch = Schema.Struct({ agent: Schema.optionalKey(OpenCodeModelOptions.fields.agent), }); +const CursorModelOptionsPatch = Schema.Struct({ + reasoning: Schema.optionalKey(CursorModelOptions.fields.reasoning), + fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode), + thinking: Schema.optionalKey(CursorModelOptions.fields.thinking), + claudeOpusTier: Schema.optionalKey(CursorModelOptions.fields.claudeOpusTier), +}); + const ModelSelectionPatch = Schema.Union([ Schema.Struct({ provider: Schema.optionalKey(Schema.Literal("codex")), @@ -204,11 +195,6 @@ const ModelSelectionPatch = Schema.Union([ model: Schema.optionalKey(TrimmedNonEmptyString), options: Schema.optionalKey(CursorModelOptionsPatch), }), - Schema.Struct({ - provider: Schema.optionalKey(Schema.Literal("opencode")), - model: Schema.optionalKey(TrimmedNonEmptyString), - options: Schema.optionalKey(OpenCodeModelOptionsPatch), - }), ]); const CodexSettingsPatch = Schema.Struct({ @@ -240,6 +226,13 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const CursorSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(Schema.String), + apiEndpoint: Schema.optionalKey(Schema.String), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), @@ -256,7 +249,6 @@ export const ServerSettingsPatch = Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), - opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), }); diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 426ceca865ef..fac88d7fdbce 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,20 +1,35 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/contracts"; +import { + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, + type ModelCapabilities, +} from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, getDefaultContextWindow, getDefaultEffort, - hasContextWindowOption, + getDefaultReasoningEffort, + getEffectiveClaudeCodeEffort, hasEffortLevel, + inferProviderForModel, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptionsWithCapabilities, - normalizeCodexModelOptionsWithCapabilities, + normalizeClaudeModelOptions, + normalizeCodexModelOptions, normalizeModelSlug, - resolveContextWindow, - resolveEffort, + parseCursorModelSelection, + resolveCursorDispatchModel, + resolveCursorModelFromSelection, + resolveModelSlug, resolveModelSlugForProvider, + resolveReasoningEffortForProvider, resolveSelectableModel, + supportsClaudeAdaptiveReasoning, + supportsClaudeFastMode, + supportsClaudeMaxEffort, + supportsClaudeThinkingToggle, + supportsClaudeUltrathinkKeyword, trimOrNull, } from "./model.ts"; @@ -98,39 +113,201 @@ describe("capability helpers", () => { }); }); -describe("resolveEffort", () => { - it("returns the explicit value when supported and not prompt-injected", () => { - expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh"); - expect(resolveEffort(codexCaps, "high")).toBe("high"); - expect(resolveEffort(claudeCaps, "medium")).toBe("medium"); +describe("inferProviderForModel", () => { + it("detects known provider model slugs", () => { + expect(inferProviderForModel("gpt-5.3-codex")).toBe("codex"); + expect(inferProviderForModel("claude-sonnet-4-6")).toBe("claudeAgent"); + expect(inferProviderForModel("sonnet")).toBe("claudeAgent"); }); - it("falls back to default when value is unsupported", () => { - expect(resolveEffort(codexCaps, "bogus")).toBe("high"); - expect(resolveEffort(claudeCaps, "bogus")).toBe("high"); + it("falls back when the model is unknown", () => { + expect(inferProviderForModel("custom/internal-model")).toBe("codex"); + expect(inferProviderForModel("custom/internal-model", "claudeAgent")).toBe("claudeAgent"); }); - it("returns the default when no value is provided", () => { - expect(resolveEffort(codexCaps, undefined)).toBe("high"); - expect(resolveEffort(codexCaps, null)).toBe("high"); - expect(resolveEffort(codexCaps, "")).toBe("high"); - expect(resolveEffort(codexCaps, " ")).toBe("high"); - }); - - it("excludes prompt-injected efforts and falls back to default", () => { - expect(resolveEffort(claudeCaps, "ultrathink")).toBe("high"); - }); - - it("returns undefined for models with no effort levels", () => { - const noCaps: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }; - expect(resolveEffort(noCaps, undefined)).toBeUndefined(); - expect(resolveEffort(noCaps, "high")).toBeUndefined(); + 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", () => { + it("returns provider-scoped defaults", () => { + expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex); + expect(getDefaultReasoningEffort("claudeAgent")).toBe( + DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent, + ); + expect(getDefaultReasoningEffort("cursor")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor); + }); +}); + +describe("resolveReasoningEffortForProvider", () => { + it("accepts provider-scoped effort values", () => { + expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh"); + expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink"); + }); + + it("rejects effort values from the wrong provider", () => { + 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("getEffectiveClaudeCodeEffort", () => { + it("does not persist ultrathink into Claude runtime configuration", () => { + expect(getEffectiveClaudeCodeEffort("ultrathink")).toBeNull(); + expect(getEffectiveClaudeCodeEffort("high")).toBe("high"); + }); + + it("returns null when no claude effort is selected", () => { + expect(getEffectiveClaudeCodeEffort(null)).toBeNull(); + expect(getEffectiveClaudeCodeEffort(undefined)).toBeNull(); + }); +}); + +describe("normalizeCodexModelOptions", () => { + it("drops default-only codex options", () => { + expect( + normalizeCodexModelOptions({ reasoningEffort: "high", fastMode: false }), + ).toBeUndefined(); + }); + + it("preserves non-default codex options", () => { + expect(normalizeCodexModelOptions({ reasoningEffort: "xhigh", fastMode: true })).toEqual({ + reasoningEffort: "xhigh", + fastMode: true, + }); + }); +}); + +describe("normalizeClaudeModelOptions", () => { + it("drops unsupported fast mode and max effort for Sonnet", () => { + expect( + normalizeClaudeModelOptions("claude-sonnet-4-6", { + effort: "max", + fastMode: true, + }), + ).toBeUndefined(); + }); + + it("keeps the Haiku thinking toggle and removes unsupported effort", () => { + expect( + normalizeClaudeModelOptions("claude-haiku-4-5", { + thinking: false, + effort: "high", + }), + ).toEqual({ + thinking: false, + }); + }); +}); + +describe("supportsClaudeAdaptiveReasoning", () => { + it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { + expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true); + expect(supportsClaudeAdaptiveReasoning("claude-sonnet-4-6")).toBe(true); + expect(supportsClaudeAdaptiveReasoning("claude-haiku-4-5")).toBe(false); + expect(supportsClaudeAdaptiveReasoning(undefined)).toBe(false); + }); +}); + +describe("supportsClaudeMaxEffort", () => { + it("only enables max effort for Opus 4.6", () => { + expect(supportsClaudeMaxEffort("claude-opus-4-6")).toBe(true); + expect(supportsClaudeMaxEffort("claude-sonnet-4-6")).toBe(false); + expect(supportsClaudeMaxEffort("claude-haiku-4-5")).toBe(false); + expect(supportsClaudeMaxEffort(undefined)).toBe(false); + }); +}); + +describe("supportsClaudeFastMode", () => { + it("only enables Claude fast mode for Opus 4.6", () => { + expect(supportsClaudeFastMode("claude-opus-4-6")).toBe(true); + expect(supportsClaudeFastMode("opus")).toBe(true); + expect(supportsClaudeFastMode("claude-sonnet-4-6")).toBe(false); + expect(supportsClaudeFastMode("claude-haiku-4-5")).toBe(false); + expect(supportsClaudeFastMode(undefined)).toBe(false); + }); +}); + +describe("supportsClaudeUltrathinkKeyword", () => { + it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { + expect(supportsClaudeUltrathinkKeyword("claude-opus-4-6")).toBe(true); + expect(supportsClaudeUltrathinkKeyword("claude-sonnet-4-6")).toBe(true); + expect(supportsClaudeUltrathinkKeyword("claude-haiku-4-5")).toBe(false); + }); +}); + +describe("supportsClaudeThinkingToggle", () => { + it("only enables the Claude thinking toggle for Haiku 4.5", () => { + expect(supportsClaudeThinkingToggle("claude-opus-4-6")).toBe(false); + expect(supportsClaudeThinkingToggle("claude-sonnet-4-6")).toBe(false); + expect(supportsClaudeThinkingToggle("claude-haiku-4-5")).toBe(true); + expect(supportsClaudeThinkingToggle("haiku")).toBe(true); + expect(supportsClaudeThinkingToggle(undefined)).toBe(false); }); }); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index d15a3a6c333f..cc4406a3b304 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,17 +1,486 @@ import { + CURSOR_MODEL_FAMILY_OPTIONS, + CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_REASONING_EFFORT_BY_PROVIDER, + MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - type ClaudeAgentEffort, + REASONING_EFFORT_OPTIONS_BY_PROVIDER, + type ClaudeCodeEffort, type ClaudeModelOptions, type CodexModelOptions, + type CodexReasoningEffort, + type CursorClaudeOpusTier, + type CursorModelFamily, type CursorModelOptions, + type CursorModelSlug, + type CursorReasoningOption, type ModelCapabilities, type ModelSelection, type OpenCodeModelOptions, type ProviderKind, - type ProviderModelOptions, + type ProviderReasoningEffort, } from "@t3tools/contracts"; +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"; + export interface SelectableModelOption { slug: string; name: string; @@ -249,37 +718,176 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } -export function createModelSelection( +export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean { + const slug = normalizeModelSlug(model, "claudeAgent"); + return slug === CLAUDE_OPUS_4_6_MODEL || slug === CLAUDE_SONNET_4_6_MODEL; +} + +export function supportsClaudeMaxEffort(model: string | null | undefined): boolean { + const slug = normalizeModelSlug(model, "claudeAgent"); + return slug === CLAUDE_OPUS_4_6_MODEL; +} + +export function supportsClaudeFastMode(model: string | null | undefined): boolean { + const slug = normalizeModelSlug(model, "claudeAgent"); + return slug === CLAUDE_OPUS_4_6_MODEL; +} + +export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean { + const slug = normalizeModelSlug(model, "claudeAgent"); + return slug === CLAUDE_OPUS_4_6_MODEL || slug === CLAUDE_SONNET_4_6_MODEL; +} + +export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean { + const slug = normalizeModelSlug(model, "claudeAgent"); + return slug === CLAUDE_HAIKU_4_5_MODEL; +} + +export function inferProviderForModel( + model: string | null | undefined, + fallback: ProviderKind = "codex", +): ProviderKind { + const normalizedClaude = normalizeModelSlug(model, "claudeAgent"); + if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) { + return "claudeAgent"; + } + + const normalizedCodex = normalizeModelSlug(model, "codex"); + if (normalizedCodex && MODEL_SLUG_SET_BY_PROVIDER.codex.has(normalizedCodex)) { + 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; +} + +export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray; +export function getReasoningEffortOptions( + provider: "claudeAgent", + model?: string | null | undefined, +): ReadonlyArray; +export function getReasoningEffortOptions( + provider?: ProviderKind, + model?: string | null | undefined, +): ReadonlyArray; +export function getReasoningEffortOptions( + provider: ProviderKind = "codex", + model?: string | null | undefined, +): ReadonlyArray { + if (provider === "claudeAgent") { + if (supportsClaudeMaxEffort(model)) { + return ["low", "medium", "high", "max", "ultrathink"]; + } + if (supportsClaudeAdaptiveReasoning(model)) { + return ["low", "medium", "high", "ultrathink"]; + } + 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", +): ProviderReasoningEffort { + return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; +} + +export function resolveReasoningEffortForProvider( + provider: "codex", + effort: string | null | undefined, +): CodexReasoningEffort | null; +export function resolveReasoningEffortForProvider( + provider: "claudeAgent", + effort: string | null | undefined, +): ClaudeCodeEffort | null; +export function resolveReasoningEffortForProvider( provider: ProviderKind, - model: string, - options?: ProviderModelOptions[ProviderKind] | undefined, -): ModelSelection { - switch (provider) { - case "codex": - return { - provider, - model, - ...(options ? { options: options as CodexModelOptions } : {}), - }; - case "claudeAgent": - return { - provider, - model, - ...(options ? { options: options as ClaudeModelOptions } : {}), - }; - case "cursor": - return { - provider, - model, - ...(options ? { options: options as CursorModelOptions } : {}), - }; - case "opencode": - return { - provider, - model, - ...(options ? { options: options as OpenCodeModelOptions } : {}), - }; + effort: string | null | undefined, +): ProviderReasoningEffort | null; +export function resolveReasoningEffortForProvider( + provider: ProviderKind, + effort: string | null | undefined, +): ProviderReasoningEffort | null { + if (typeof effort !== "string") { + return null; + } + + const trimmed = effort.trim(); + if (!trimmed) { + return null; + } + + const options = REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider] as ReadonlyArray; + 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 { + if (!effort) { + return null; } + return effort === "ultrathink" ? null : effort; +} + +export function normalizeCodexModelOptions( + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const defaultReasoningEffort = getDefaultReasoningEffort("codex"); + const reasoningEffort = + resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ?? + defaultReasoningEffort; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeClaudeModelOptions( + model: string | null | undefined, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); + const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent"); + const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort); + const effort = + resolvedEffort && + resolvedEffort !== "ultrathink" && + reasoningOptions.includes(resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort + : undefined; + const thinking = + supportsClaudeThinkingToggle(model) && modelOptions?.thinking === false ? false : undefined; + const fastMode = + supportsClaudeFastMode(model) && modelOptions?.fastMode === true ? true : undefined; + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } export function applyClaudePromptEffortPrefix( diff --git a/scripts/cursor-agent-models-probe.mjs b/scripts/cursor-agent-models-probe.mjs new file mode 100644 index 000000000000..bf56ed0b7187 --- /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(); From 96a9a73f1d3c3775878a465b000378c985421642 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 12:48:00 -0700 Subject: [PATCH 02/42] Use server-driven ModelCapabilities for Cursor traits Replace the hardcoded client-side CURSOR_MODEL_CAPABILITY_BY_FAMILY map with server-provided ModelCapabilities, matching the Codex/Claude pattern. - Add CursorProvider snapshot service with BUILT_IN_MODELS and per-model capabilities; register it in ProviderRegistry alongside Codex/Claude. - Delete CursorTraitsPicker and route Cursor through the generic TraitsPicker, adding cursor support for the reasoning/effort key. - Add normalizeCursorModelOptionsWithCapabilities to providerModels. Made-with: Cursor --- .../src/provider/Layers/CursorProvider.ts | 1225 ++++------------- .../src/provider/Layers/ProviderRegistry.ts | 163 +-- .../src/provider/Services/CursorProvider.ts | 6 +- .../CompactComposerControlsMenu.browser.tsx | 44 +- .../components/chat/CursorTraitsPicker.tsx | 238 ---- apps/web/src/components/chat/TraitsPicker.tsx | 37 - .../chat/composerProviderRegistry.test.tsx | 81 +- .../chat/composerProviderRegistry.tsx | 64 +- apps/web/src/providerModels.ts | 50 +- 9 files changed, 509 insertions(+), 1399 deletions(-) delete mode 100644 apps/web/src/components/chat/CursorTraitsPicker.tsx diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 70d5656b3ec7..5300807766ea 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1,917 +1,238 @@ -import * as nodeFs from "node:fs"; -import * as nodeOs from "node:os"; -import * as nodePath from "node:path"; - import type { - CursorModelOptions, CursorSettings, ModelCapabilities, ServerProvider, - ServerProviderAuth, ServerProviderModel, + ServerProviderAuthStatus, ServerProviderState, - ServerSettingsError, } from "@t3tools/contracts"; -import type * as EffectAcpSchema from "effect-acp/schema"; -import { Cause, Effect, Equal, Exit, Layer, Option, Result, Stream } from "effect"; +import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildServerProvider, collectStreamAsString, + DEFAULT_TIMEOUT_MS, + detailFromResult, + extractAuthBoolean, isCommandMissingCause, + parseGenericCliVersion, providerModelsFromSettings, type CommandResult, -} from "../providerSnapshot.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { CursorProvider } from "../Services/CursorProvider.ts"; -import { AcpSessionRuntime } from "../acp/AcpSessionRuntime.ts"; -import { ServerSettingsService } from "../../serverSettings.ts"; +} from "../providerSnapshot"; +import { makeManagedServerProvider } from "../makeManagedServerProvider"; +import { CursorProvider } from "../Services/CursorProvider"; +import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; const PROVIDER = "cursor" as const; -const EMPTY_CAPABILITIES: ModelCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -}; - -const CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; -const CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT = "4 seconds"; -const CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY = 4; -const CURSOR_REFRESH_INTERVAL = "1 hour"; -const CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE = 2026_04_08; -export const CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES = { - _meta: { - parameterizedModelPicker: true, +const BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "auto", + name: "Auto", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, }, -} satisfies NonNullable; - -function buildInitialCursorProviderSnapshot(cursorSettings: CursorSettings): ServerProvider { - const checkedAt = new Date().toISOString(); - const models = getCursorFallbackModels(cursorSettings); - - if (!cursorSettings.enabled) { - return buildServerProvider({ - provider: PROVIDER, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Cursor is disabled in T3 Code settings.", - }, - }); - } - - return buildServerProvider({ - provider: PROVIDER, - enabled: true, - checkedAt, - models, - probe: { - installed: true, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Checking Cursor Agent availability...", + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], }, - }); -} - -interface CursorSessionSelectOption { - readonly value: string; - readonly name: string; -} - -interface CursorAcpDiscoveredModel { - readonly slug: string; - readonly name: string; - readonly capabilities: ModelCapabilities; -} - -function flattenSessionConfigSelectOptions( - configOption: EffectAcpSchema.SessionConfigOption | undefined, -): ReadonlyArray { - if (!configOption || configOption.type !== "select") { - return []; - } - return configOption.options.flatMap((entry) => - "value" in entry - ? [{ value: entry.value.trim(), name: entry.name.trim() } satisfies CursorSessionSelectOption] - : entry.options.map( - (option) => - ({ - value: option.value.trim(), - name: option.name.trim(), - }) satisfies CursorSessionSelectOption, - ), - ); -} - -function normalizeCursorReasoningValue(value: string | null | undefined): string | undefined { - const normalized = value?.trim().toLowerCase(); - switch (normalized) { - case "low": - case "medium": - case "high": - case "max": - return normalized; - case "xhigh": - case "extra-high": - case "extra high": - return "xhigh"; - default: - return undefined; - } -} - -function findCursorModelConfigOption( - configOptions: ReadonlyArray, -): EffectAcpSchema.SessionConfigOption | undefined { - return configOptions.find((option) => option.category === "model"); -} - -function getCursorConfigOptionCategory(option: EffectAcpSchema.SessionConfigOption): string { - return option.category?.trim().toLowerCase() ?? ""; -} - -function isCursorEffortConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { - const id = option.id.trim().toLowerCase(); - const name = option.name.trim().toLowerCase(); - return ( - id === "effort" || - id === "reasoning" || - name === "effort" || - name === "reasoning" || - name.includes("effort") || - name.includes("reasoning") - ); -} - -function findCursorEffortConfigOption( - configOptions: ReadonlyArray, -): EffectAcpSchema.SessionConfigOption | undefined { - const candidates = configOptions.filter( - (option) => option.type === "select" && isCursorEffortConfigOption(option), - ); - return ( - candidates.find((option) => getCursorConfigOptionCategory(option) === "model_option") ?? - candidates.find((option) => option.id.trim().toLowerCase() === "effort") ?? - candidates.find((option) => getCursorConfigOptionCategory(option) === "thought_level") ?? - candidates[0] - ); -} - -function isCursorContextConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { - const id = option.id.trim().toLowerCase(); - const name = option.name.trim().toLowerCase(); - return id === "context" || id === "context_size" || name.includes("context"); -} - -function isCursorFastConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { - const id = option.id.trim().toLowerCase(); - const name = option.name.trim().toLowerCase(); - return id === "fast" || name === "fast" || name.includes("fast mode"); -} - -function isCursorThinkingConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { - const id = option.id.trim().toLowerCase(); - const name = option.name.trim().toLowerCase(); - return id === "thinking" || name.includes("thinking"); -} - -function isBooleanLikeConfigOption(option: EffectAcpSchema.SessionConfigOption): boolean { - if (option.type === "boolean") { - return true; - } - if (option.type !== "select") { - return false; - } - const values = new Set( - flattenSessionConfigSelectOptions(option).map((entry) => entry.value.trim().toLowerCase()), - ); - return values.has("true") && values.has("false"); -} - -export function buildCursorCapabilitiesFromConfigOptions( - configOptions: ReadonlyArray | null | undefined, -): ModelCapabilities { - if (!configOptions || configOptions.length === 0) { - return EMPTY_CAPABILITIES; - } - - const reasoningConfig = findCursorEffortConfigOption(configOptions); - const reasoningEffortLevels = - reasoningConfig?.type === "select" - ? flattenSessionConfigSelectOptions(reasoningConfig).flatMap((entry) => { - const normalizedValue = normalizeCursorReasoningValue(entry.value); - if (!normalizedValue) { - return []; - } - return [ - { - value: normalizedValue, - label: entry.name, - ...(normalizeCursorReasoningValue(reasoningConfig.currentValue) === normalizedValue - ? { isDefault: true } - : {}), - }, - ]; - }) - : []; - - const contextOption = configOptions.find( - (option) => option.category === "model_config" && isCursorContextConfigOption(option), - ); - const contextWindowOptions = - contextOption?.type === "select" - ? flattenSessionConfigSelectOptions(contextOption).map((entry) => { - if (contextOption.currentValue === entry.value) { - return { - value: entry.value, - label: entry.name, - isDefault: true, - }; - } - return { - value: entry.value, - label: entry.name, - }; - }) - : []; - - const fastOption = configOptions.find( - (option) => option.category === "model_config" && isCursorFastConfigOption(option), - ); - const thinkingOption = configOptions.find( - (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), - ); - - return { - reasoningEffortLevels, - supportsFastMode: fastOption ? isBooleanLikeConfigOption(fastOption) : false, - supportsThinkingToggle: thinkingOption ? isBooleanLikeConfigOption(thinkingOption) : false, - contextWindowOptions, - promptInjectedEffortLevels: [], - }; -} - -function buildCursorDiscoveredModels( - discoveredModels: ReadonlyArray, -): ReadonlyArray { - const seen = new Set(); - return discoveredModels.flatMap((model) => { - if (!model.slug || seen.has(model.slug)) { - return []; - } - seen.add(model.slug); - return [ - { - slug: model.slug, - name: model.name, - isCustom: false, - capabilities: model.capabilities, - } satisfies ServerProviderModel, - ]; - }); -} - -function hasCursorModelCapabilities(model: Pick): boolean { - return ( - (model.capabilities?.reasoningEffortLevels.length ?? 0) > 0 || - model.capabilities?.supportsFastMode === true || - model.capabilities?.supportsThinkingToggle === true || - (model.capabilities?.contextWindowOptions.length ?? 0) > 0 || - (model.capabilities?.promptInjectedEffortLevels.length ?? 0) > 0 - ); -} - -export function buildCursorDiscoveredModelsFromConfigOptions( - configOptions: ReadonlyArray | null | undefined, -): ReadonlyArray { - if (!configOptions || configOptions.length === 0) { - return []; - } - - const modelOption = findCursorModelConfigOption(configOptions); - const modelChoices = flattenSessionConfigSelectOptions(modelOption); - if (!modelOption || modelChoices.length === 0) { - return []; - } - - const currentModelValue = - modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; - const currentModelCapabilities = buildCursorCapabilitiesFromConfigOptions(configOptions); - - return buildCursorDiscoveredModels( - modelChoices.map((modelChoice) => ({ - slug: modelChoice.value.trim(), - name: modelChoice.name.trim(), - capabilities: - currentModelValue === modelChoice.value.trim() - ? currentModelCapabilities - : EMPTY_CAPABILITIES, - })), - ); -} - -const makeCursorAcpProbeRuntime = (cursorSettings: CursorSettings) => - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const acpContext = yield* Layer.build( - AcpSessionRuntime.layer({ - spawn: { - command: cursorSettings.binaryPath, - args: [ - ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), - "acp", - ], - cwd: process.cwd(), - }, - cwd: process.cwd(), - clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, - authMethodId: "cursor_login", - clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, - }).pipe(Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner))), - ); - return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); - }); - -const withCursorAcpProbeRuntime = ( - cursorSettings: CursorSettings, - useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, -) => makeCursorAcpProbeRuntime(cursorSettings).pipe(Effect.flatMap(useRuntime), Effect.scoped); + }, + { + slug: "composer-1.5", + name: "Composer 1.5", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "Codex 5.3", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex-spark-preview", + name: "Codex 5.3 Spark", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.4-1m", + name: "GPT 5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-4.6-opus", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-4.6-sonnet", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gemini-3.1-pro", + name: "Gemini 3.1 Pro", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, +]; -function normalizeCursorConfigOptionToken(value: string | null | undefined): string { +export function getCursorModelCapabilities(model: string | null | undefined): ModelCapabilities { + const slug = model?.trim(); return ( - value - ?.trim() - .toLowerCase() - .replace(/[\s_-]+/g, "-") ?? "" - ); -} - -function findCursorSelectOptionValue( - configOption: EffectAcpSchema.SessionConfigOption | undefined, - matcher: (option: CursorSessionSelectOption) => boolean, -): string | undefined { - return flattenSessionConfigSelectOptions(configOption).find(matcher)?.value; -} - -function findCursorBooleanConfigValue( - configOption: EffectAcpSchema.SessionConfigOption | undefined, - requested: boolean, -): string | boolean | undefined { - if (!configOption) { - return undefined; - } - if (configOption.type === "boolean") { - return requested; - } - return findCursorSelectOptionValue( - configOption, - (option) => normalizeCursorConfigOptionToken(option.value) === String(requested), - ); -} - -export function resolveCursorAcpBaseModelId(model: string | null | undefined): string { - const trimmed = model?.trim(); - const base = trimmed && trimmed.length > 0 ? trimmed : "default"; - return base.includes("[") ? base.slice(0, base.indexOf("[")) : base; -} - -export function resolveCursorAcpConfigUpdates( - configOptions: ReadonlyArray | null | undefined, - modelOptions: CursorModelOptions | null | undefined, -): ReadonlyArray<{ readonly configId: string; readonly value: string | boolean }> { - if (!configOptions || configOptions.length === 0) { - return []; - } - - const updates: Array<{ readonly configId: string; readonly value: string | boolean }> = []; - - const reasoningOption = findCursorEffortConfigOption(configOptions); - const requestedReasoning = normalizeCursorReasoningValue(modelOptions?.reasoning); - if (reasoningOption && requestedReasoning) { - const value = findCursorSelectOptionValue(reasoningOption, (option) => { - const normalizedValue = normalizeCursorReasoningValue(option.value); - const normalizedName = normalizeCursorReasoningValue(option.name); - return normalizedValue === requestedReasoning || normalizedName === requestedReasoning; - }); - if (value) { - updates.push({ configId: reasoningOption.id, value }); + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], } - } - - const contextOption = configOptions.find( - (option) => option.category === "model_config" && isCursorContextConfigOption(option), ); - if (contextOption && modelOptions?.contextWindow) { - const value = findCursorSelectOptionValue( - contextOption, - (option) => - normalizeCursorConfigOptionToken(option.value) === - normalizeCursorConfigOptionToken(modelOptions.contextWindow) || - normalizeCursorConfigOptionToken(option.name) === - normalizeCursorConfigOptionToken(modelOptions.contextWindow), - ); - if (value) { - updates.push({ configId: contextOption.id, value }); - } - } - - const fastOption = configOptions.find( - (option) => option.category === "model_config" && isCursorFastConfigOption(option), - ); - if (fastOption && typeof modelOptions?.fastMode === "boolean") { - const value = findCursorBooleanConfigValue(fastOption, modelOptions.fastMode); - if (value !== undefined) { - updates.push({ configId: fastOption.id, value }); - } - } - - const thinkingOption = configOptions.find( - (option) => option.category === "model_config" && isCursorThinkingConfigOption(option), - ); - if (thinkingOption && typeof modelOptions?.thinking === "boolean") { - const value = findCursorBooleanConfigValue(thinkingOption, modelOptions.thinking); - if (value !== undefined) { - updates.push({ configId: thinkingOption.id, value }); - } - } - - return updates; } -export const discoverCursorModelsViaAcp = (cursorSettings: CursorSettings) => - withCursorAcpProbeRuntime(cursorSettings, (acp) => - Effect.map(acp.start(), (started) => - buildCursorDiscoveredModelsFromConfigOptions(started.sessionSetupResult.configOptions ?? []), - ), - ); - -export const discoverCursorModelCapabilitiesViaAcp = ( - cursorSettings: CursorSettings, - existingModels: ReadonlyArray, -) => - withCursorAcpProbeRuntime(cursorSettings, (acp) => - Effect.gen(function* () { - const started = yield* acp.start(); - const initialConfigOptions = started.sessionSetupResult.configOptions ?? []; - const modelOption = findCursorModelConfigOption(initialConfigOptions); - const modelChoices = flattenSessionConfigSelectOptions(modelOption); - if (!modelOption || modelChoices.length === 0) { - return []; - } - - const currentModelValue = - modelOption.type === "select" ? modelOption.currentValue?.trim() || undefined : undefined; - const capabilitiesBySlug = new Map(); - if (currentModelValue) { - capabilitiesBySlug.set( - currentModelValue, - buildCursorCapabilitiesFromConfigOptions(initialConfigOptions), - ); - } - - const targetModelSlugs = new Set( - existingModels - .filter((model) => !model.isCustom && !hasCursorModelCapabilities(model)) - .map((model) => model.slug), - ); - if (targetModelSlugs.size === 0) { - return buildCursorDiscoveredModels( - modelChoices.map((modelChoice) => ({ - slug: modelChoice.value.trim(), - name: modelChoice.name.trim(), - capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, - })), - ); - } - - const probedCapabilities = yield* Effect.forEach( - modelChoices, - (modelChoice) => { - const modelSlug = modelChoice.value.trim(); - if (!modelSlug || !targetModelSlugs.has(modelSlug) || capabilitiesBySlug.has(modelSlug)) { - return Effect.void.pipe( - Effect.as(undefined), - ); - } - - return withCursorAcpProbeRuntime(cursorSettings, (probeAcp) => - Effect.gen(function* () { - const probeStarted = yield* probeAcp.start(); - const probeConfigOptions = probeStarted.sessionSetupResult.configOptions ?? []; - const probeModelOption = findCursorModelConfigOption(probeConfigOptions); - const probeCurrentModelValue = - probeModelOption?.type === "select" - ? probeModelOption.currentValue?.trim() || undefined - : undefined; - yield* Effect.annotateCurrentSpan({ - "cursor.acp.model.value": modelSlug, - "cursor.acp.model.currentValue": probeCurrentModelValue, - "cursor.acp.config_option_id": probeModelOption?.id ?? modelOption.id, - }); - const nextConfigOptions = - probeCurrentModelValue === modelSlug - ? probeConfigOptions - : yield* probeAcp - .setConfigOption(probeModelOption?.id ?? modelOption.id, modelSlug) - .pipe(Effect.map((response) => response.configOptions ?? probeConfigOptions)); - return [ - modelSlug, - buildCursorCapabilitiesFromConfigOptions(nextConfigOptions), - ] as const; - }), - ).pipe( - Effect.timeout(CURSOR_ACP_MODEL_CAPABILITY_TIMEOUT), - Effect.retry({ times: 3 }), - Effect.withSpan("cursor-acp-model-capability-probe"), - Effect.catchCause((cause) => - Effect.logWarning("Cursor ACP capability probe failed", { - modelSlug, - cause: Cause.pretty(cause), - }), - ), - ); - }, - { concurrency: CURSOR_ACP_MODEL_DISCOVERY_CONCURRENCY }, - ); - - for (const entry of probedCapabilities) { - if (!entry) { - continue; - } - capabilitiesBySlug.set(entry[0], entry[1]); - } - - return buildCursorDiscoveredModels( - modelChoices.map((modelChoice) => ({ - slug: modelChoice.value.trim(), - name: modelChoice.name.trim(), - capabilities: capabilitiesBySlug.get(modelChoice.value.trim()) ?? EMPTY_CAPABILITIES, - })), - ); - }).pipe(Effect.withSpan("cursor-acp-model-capability-discovery", {})), - ); - -export function getCursorFallbackModels( - cursorSettings: Pick, -): ReadonlyArray { - return providerModelsFromSettings([], PROVIDER, cursorSettings.customModels, EMPTY_CAPABILITIES); -} - -/** Timeout for `agent about` — it's slower than a simple `--version` probe. */ -const ABOUT_TIMEOUT_MS = 8_000; - -/** Strip ANSI escape sequences so we can parse plain key-value lines. */ -function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07/g, ""); -} - -/** - * Extract a value from `agent about` key-value output. - * Lines look like: `CLI Version 2026.03.20-44cb435` - */ -function extractAboutField(plain: string, key: string): string | undefined { - const regex = new RegExp(`^${key}\\s{2,}(.+)$`, "mi"); - const match = regex.exec(plain); - return match?.[1]?.trim(); -} - -export interface CursorAboutResult { - readonly version: string | null; +export function parseCursorAuthStatusFromOutput(result: CommandResult): { readonly status: Exclude; - readonly auth: ServerProviderAuth; + readonly authStatus: ServerProviderAuthStatus; readonly message?: string; -} - -function joinProviderMessages(...messages: ReadonlyArray): string | undefined { - const parts = messages - .map((message) => message?.trim()) - .filter((message): message is string => Boolean(message)); - return parts.length > 0 ? parts.join(" ") : undefined; -} - -export function buildCursorProviderSnapshot(input: { - readonly checkedAt: string; - readonly cursorSettings: CursorSettings; - readonly parsed: CursorAboutResult; - readonly discoveredModels?: ReadonlyArray; - readonly discoveryWarning?: string; -}): ServerProvider { - const message = joinProviderMessages(input.parsed.message, input.discoveryWarning); - return buildServerProvider({ - provider: PROVIDER, - enabled: input.cursorSettings.enabled, - checkedAt: input.checkedAt, - models: providerModelsFromSettings( - input.discoveredModels ?? [], - PROVIDER, - input.cursorSettings.customModels, - EMPTY_CAPABILITIES, - ), - probe: { - installed: true, - version: input.parsed.version, - status: - input.discoveryWarning && input.parsed.status === "ready" ? "warning" : input.parsed.status, - auth: input.parsed.auth, - ...(message ? { message } : {}), - }, - }); -} - -interface CursorAboutJsonPayload { - readonly cliVersion?: unknown; - readonly subscriptionTier?: unknown; - readonly userEmail?: unknown; -} - -export function parseCursorVersionDate(version: string | null | undefined): number | undefined { - const match = version?.trim().match(/^(\d{4})\.(\d{2})\.(\d{2})(?:\b|-|$)/); - if (!match) { - return undefined; - } - const [, year, month, day] = match; - return Number(`${year}${month}${day}`); -} - -export function parseCursorCliConfigChannel(raw: string): string | undefined { - try { - const parsed = JSON.parse(raw) as unknown; - if ( - typeof parsed === "object" && - parsed !== null && - "channel" in parsed && - typeof parsed.channel === "string" - ) { - const channel = parsed.channel.trim().toLowerCase(); - return channel.length > 0 ? channel : undefined; - } - } catch { - return undefined; - } - return undefined; -} - -function toTitleCaseWords(value: string): string { - return value - .split(/[\s_-]+/g) - .filter((part) => part.length > 0) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) - .join(" "); -} - -function cursorSubscriptionLabel(subscriptionType: string | undefined): string | undefined { - const normalized = subscriptionType?.toLowerCase().replace(/[\s_-]+/g, ""); - if (!normalized) return undefined; - - switch (normalized) { - case "team": - return "Team"; - case "pro": - return "Pro"; - case "free": - return "Free"; - case "business": - return "Business"; - case "enterprise": - return "Enterprise"; - default: - return toTitleCaseWords(subscriptionType!); - } -} - -function cursorAuthMetadata( - subscriptionType: string | undefined, -): Pick | undefined { - if (!subscriptionType) { - return undefined; - } - const subscriptionLabel = cursorSubscriptionLabel(subscriptionType); - return { - type: subscriptionType, - label: `Cursor ${subscriptionLabel ?? toTitleCaseWords(subscriptionType)} Subscription`, - }; -} - -function parseCursorAboutJsonPayload(raw: string): CursorAboutJsonPayload | undefined { - const trimmed = raw.trim(); - if (!trimmed.startsWith("{")) { - return undefined; - } - try { - const parsed = JSON.parse(trimmed) as unknown; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return undefined; - } - return parsed as CursorAboutJsonPayload; - } catch { - return undefined; - } -} - -function hasOwn(record: object, key: string): boolean { - return Object.prototype.hasOwnProperty.call(record, key); -} - -function isCursorAboutJsonFormatUnsupported(result: CommandResult): boolean { +} { const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); - return ( - lowerOutput.includes("unknown option '--format'") || - lowerOutput.includes("unexpected argument '--format'") || - lowerOutput.includes("unrecognized option '--format'") || - lowerOutput.includes("unknown argument '--format'") - ); -} -function readCursorCliConfigChannel(): string | undefined { - try { - const configPath = nodePath.join(nodeOs.homedir(), ".cursor", "cli-config.json"); - return parseCursorCliConfigChannel(nodeFs.readFileSync(configPath, "utf8")); - } catch { - return undefined; - } -} - -export function getCursorParameterizedModelPickerUnsupportedMessage(input: { - readonly version: string | null | undefined; - readonly channel: string | null | undefined; -}): string | undefined { - const reasons: Array = []; - const versionDate = parseCursorVersionDate(input.version); if ( - versionDate !== undefined && - versionDate < CURSOR_PARAMETERIZED_MODEL_PICKER_MIN_VERSION_DATE + lowerOutput.includes("unknown command") || + lowerOutput.includes("unrecognized command") || + lowerOutput.includes("unexpected argument") ) { - reasons.push( - `Cursor Agent CLI version ${input.version} is too old for Cursor ACP parameterized model picker`, - ); + return { + status: "warning", + authStatus: "unknown", + message: + "Cursor Agent authentication status command is unavailable in this version of the Agent CLI.", + }; } - const normalizedChannel = input.channel?.trim().toLowerCase(); if ( - normalizedChannel !== undefined && - normalizedChannel.length > 0 && - normalizedChannel !== "lab" + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") || + lowerOutput.includes("run `agent login`") || + lowerOutput.includes("run agent login") ) { - reasons.push( - `Cursor Agent CLI channel is ${JSON.stringify(input.channel)}, but parameterized model picker is only available on the lab channel`, - ); - } - - if (reasons.length === 0) { - return undefined; + return { + status: "error", + authStatus: "unauthenticated", + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + }; } - return `${reasons.join(". ")}. Run \`agent set-channel lab && agent update\` and use Cursor Agent CLI 2026.04.08 or newer.`; -} - -/** - * Parse the output of `agent about` to extract version and authentication - * status in a single probe. - * - * Example output (logged in): - * ``` - * About Cursor CLI - * - * CLI Version 2026.03.20-44cb435 - * User Email user@example.com - * ``` - * - * Example output (logged out): - * ``` - * About Cursor CLI - * - * CLI Version 2026.03.20-44cb435 - * User Email Not logged in - * ``` - */ -export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult { - const jsonPayload = parseCursorAboutJsonPayload(result.stdout); - if (jsonPayload) { - const version = - typeof jsonPayload.cliVersion === "string" ? jsonPayload.cliVersion.trim() : null; - const hasUserEmailField = hasOwn(jsonPayload, "userEmail"); - const userEmail = - typeof jsonPayload.userEmail === "string" ? jsonPayload.userEmail.trim() : undefined; - const subscriptionType = - typeof jsonPayload.subscriptionTier === "string" - ? jsonPayload.subscriptionTier.trim() - : undefined; - const authMetadata = cursorAuthMetadata(subscriptionType); - - if (hasUserEmailField && jsonPayload.userEmail == null) { - return { - version, - status: "error", - auth: { status: "unauthenticated" }, - message: "Cursor Agent is not authenticated. Run `agent login` and try again.", - }; - } - - if (!userEmail) { - if (result.code === 0) { - return { - version, - status: "ready", - auth: { - status: "unknown", - ...authMetadata, - }, - }; - } - return { - version, - status: "warning", - auth: { status: "unknown" }, - message: "Could not verify Cursor Agent authentication status.", - }; + const parsedAuth = (() => { + const trimmed = result.stdout.trim(); + if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; } - - const lowerEmail = userEmail.toLowerCase(); - if ( - lowerEmail === "not logged in" || - lowerEmail.includes("login required") || - lowerEmail.includes("authentication required") - ) { + try { return { - version, - status: "error", - auth: { status: "unauthenticated" }, - message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + attemptedJsonParse: true as const, + auth: extractAuthBoolean(JSON.parse(trimmed)), }; + } catch { + return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; } + })(); - return { - version, - status: "ready", - auth: { - status: "authenticated", - ...authMetadata, - }, - }; + if (parsedAuth.auth === true) { + return { status: "ready", authStatus: "authenticated" }; } - - const combined = `${result.stdout}\n${result.stderr}`; - const lowerOutput = combined.toLowerCase(); - - // If the command itself isn't recognised, we're on an old CLI version. - if ( - lowerOutput.includes("unknown command") || - lowerOutput.includes("unrecognized command") || - lowerOutput.includes("unexpected argument") - ) { + if (parsedAuth.auth === false) { return { - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "The `agent about` command is unavailable in this version of the Cursor Agent CLI.", + status: "error", + authStatus: "unauthenticated", + message: "Cursor Agent is not authenticated. Run `agent login` and try again.", }; } - - const plain = stripAnsi(combined); - const version = extractAboutField(plain, "CLI Version") ?? null; - const userEmail = extractAboutField(plain, "User Email"); - - // Determine auth from the User Email field. - if (userEmail === undefined) { - // Field missing entirely — can't determine auth. - if (result.code === 0) { - return { version, status: "ready", auth: { status: "unknown" } }; - } + if (parsedAuth.attemptedJsonParse) { return { - version, status: "warning", - auth: { status: "unknown" }, - message: "Could not verify Cursor Agent authentication status.", + authStatus: "unknown", + message: + "Could not verify Cursor Agent authentication status from JSON output (missing auth marker).", }; } - - const lowerEmail = userEmail.toLowerCase(); - if ( - lowerEmail === "not logged in" || - lowerEmail.includes("login required") || - lowerEmail.includes("authentication required") - ) { - return { - version, - status: "error", - auth: { status: "unauthenticated" }, - message: "Cursor Agent is not authenticated. Run `agent login` and try again.", - }; + if (result.code === 0) { + return { status: "ready", authStatus: "authenticated" }; } - // Any non-empty email value means authenticated. - return { version, status: "ready", auth: { status: "authenticated" } }; + const detail = detailFromResult(result); + return { + status: "warning", + authStatus: "unknown", + message: detail + ? `Could not verify Cursor Agent authentication status. ${detail}` + : "Could not verify Cursor Agent authentication status.", + }; } const runCursorCommand = (args: ReadonlyArray) => @@ -938,14 +259,6 @@ const runCursorCommand = (args: ReadonlyArray) => return { stdout, stderr, code: exitCode } satisfies CommandResult; }).pipe(Effect.scoped); -const runCursorAboutCommand = Effect.gen(function* () { - const jsonResult = yield* runCursorCommand(["about", "--format", "json"]); - if (!isCursorAboutJsonFormatUnsupported(jsonResult)) { - return jsonResult; - } - return yield* runCursorCommand(["about"]); -}); - export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( function* (): Effect.fn.Return< ServerProvider, @@ -957,42 +270,45 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( Effect.map((settings) => settings.providers.cursor), ); const checkedAt = new Date().toISOString(); - const fallbackModels = getCursorFallbackModels(cursorSettings); + const models = providerModelsFromSettings( + BUILT_IN_MODELS, + PROVIDER, + cursorSettings.customModels, + ); if (!cursorSettings.enabled) { return buildServerProvider({ provider: PROVIDER, enabled: false, checkedAt, - models: fallbackModels, + models, probe: { installed: false, version: null, status: "warning", - auth: { status: "unknown" }, + authStatus: "unknown", message: "Cursor is disabled in T3 Code settings.", }, }); } - // Single `agent about` probe: returns version + auth status in one call. - const aboutProbe = yield* runCursorAboutCommand.pipe( - Effect.timeoutOption(ABOUT_TIMEOUT_MS), + const versionProbe = yield* runCursorCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), Effect.result, ); - if (Result.isFailure(aboutProbe)) { - const error = aboutProbe.failure; + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, checkedAt, - models: fallbackModels, + models, probe: { installed: !isCommandMissingCause(error), version: null, status: "error", - auth: { status: "unknown" }, + authStatus: "unknown", message: isCommandMissingCause(error) ? "Cursor Agent CLI (`agent`) is not installed or not on PATH." : `Failed to execute Cursor Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, @@ -1000,76 +316,99 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( }); } - if (Option.isNone(aboutProbe.success)) { + if (Option.isNone(versionProbe.success)) { return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, checkedAt, - models: fallbackModels, + models, probe: { installed: true, version: null, status: "error", - auth: { status: "unknown" }, - message: "Cursor Agent CLI is installed but timed out while running `agent about`.", + authStatus: "unknown", + message: + "Cursor Agent CLI is installed but failed to run. Timed out while running command.", }, }); } - const parsed = parseCursorAboutOutput(aboutProbe.success.value); - const parameterizedModelPickerUnsupportedMessage = - getCursorParameterizedModelPickerUnsupportedMessage({ - version: parsed.version, - channel: readCursorCliConfigChannel(), - }); - if (parameterizedModelPickerUnsupportedMessage) { + const version = versionProbe.success.value; + const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); + if (version.code !== 0) { + const detail = detailFromResult(version); return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, checkedAt, - models: fallbackModels, + models, probe: { installed: true, - version: parsed.version, + version: parsedVersion, status: "error", - auth: parsed.auth, + authStatus: "unknown", + message: detail + ? `Cursor Agent CLI is installed but failed to run. ${detail}` + : "Cursor Agent CLI is installed but failed to run.", + }, + }); + } + + const authProbe = yield* runCursorCommand(["login", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(authProbe)) { + const error = authProbe.failure; + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", message: - parsed.auth.status === "unauthenticated" && parsed.message - ? `${parameterizedModelPickerUnsupportedMessage} ${parsed.message}` - : parameterizedModelPickerUnsupportedMessage, + error instanceof Error + ? `Could not verify Cursor Agent authentication status: ${error.message}.` + : "Could not verify Cursor Agent authentication status.", }, }); } - let discoveredModels = Option.none>(); - let discoveryWarning: string | undefined; - if (parsed.auth.status !== "unauthenticated") { - const discoveryExit = yield* Effect.exit( - discoverCursorModelsViaAcp(cursorSettings).pipe( - Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - ), - ); - if (Exit.isFailure(discoveryExit)) { - yield* Effect.logWarning("Cursor ACP model discovery failed", { - cause: Cause.pretty(discoveryExit.cause), - }); - discoveryWarning = "Cursor ACP model discovery failed. Check server logs for details."; - } else if (Option.isNone(discoveryExit.value)) { - discoveryWarning = `Cursor ACP model discovery timed out after ${CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; - } else if (discoveryExit.value.value.length === 0) { - discoveryWarning = "Cursor ACP model discovery returned no built-in models."; - } else { - discoveredModels = discoveryExit.value; - } + + if (Option.isNone(authProbe.success)) { + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, + checkedAt, + models, + probe: { + installed: true, + version: parsedVersion, + status: "warning", + authStatus: "unknown", + message: + "Could not verify Cursor Agent authentication status. Timed out while running command.", + }, + }); } - return buildCursorProviderSnapshot({ + + const parsed = parseCursorAuthStatusFromOutput(authProbe.success.value); + return buildServerProvider({ + provider: PROVIDER, + enabled: cursorSettings.enabled, checkedAt, - cursorSettings, - parsed, - discoveredModels: Option.getOrElse( - Option.filter(discoveredModels, (models) => models.length > 0), - () => [] as const, - ), - ...(discoveryWarning ? { discoveryWarning } : {}), + models, + probe: { + installed: true, + version: parsedVersion, + status: parsed.status, + authStatus: parsed.authStatus, + ...(parsed.message ? { message: parsed.message } : {}), + }, }); }, ); @@ -1094,43 +433,7 @@ export const CursorProviderLive = Layer.effect( Stream.map((settings) => settings.providers.cursor), ), haveSettingsChanged: (previous, next) => !Equal.equals(previous, next), - initialSnapshot: buildInitialCursorProviderSnapshot, checkProvider, - enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => { - if ( - !settings.enabled || - snapshot.auth.status === "unauthenticated" || - !snapshot.models.some((model) => !model.isCustom && !hasCursorModelCapabilities(model)) - ) { - return Effect.void; - } - - return discoverCursorModelCapabilitiesViaAcp(settings, snapshot.models).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.flatMap((discoveredModels) => { - if (discoveredModels.length === 0) { - return Effect.void; - } - - return publishSnapshot({ - ...snapshot, - models: providerModelsFromSettings( - discoveredModels, - PROVIDER, - settings.customModels, - EMPTY_CAPABILITIES, - ), - }); - }), - Effect.catchCause((cause) => - Effect.logWarning("Cursor ACP background capability enrichment failed", { - models: snapshot.models.map((model) => model.slug), - cause: Cause.pretty(cause), - }).pipe(Effect.asVoid), - ), - ); - }, - refreshInterval: CURSOR_REFRESH_INTERVAL, }); }), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index d068335314b9..86bba76c1f4e 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -6,36 +6,23 @@ import type { ProviderKind, ServerProvider } from "@t3tools/contracts"; import { Effect, Equal, FileSystem, Layer, Path, PubSub, Ref, Stream } from "effect"; -import { ServerConfig } from "../../config.ts"; -import { ClaudeProviderLive } from "./ClaudeProvider.ts"; -import { CodexProviderLive } from "./CodexProvider.ts"; -import { CursorProviderLive } from "./CursorProvider.ts"; -import { OpenCodeProviderLive } from "./OpenCodeProvider.ts"; -import { ClaudeProvider } from "../Services/ClaudeProvider.ts"; -import { CodexProvider } from "../Services/CodexProvider.ts"; -import { CursorProvider } from "../Services/CursorProvider.ts"; -import { OpenCodeProvider } from "../Services/OpenCodeProvider.ts"; -import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry.ts"; -import { - hydrateCachedProvider, - PROVIDER_CACHE_IDS, - orderProviderSnapshots, - readProviderStatusCache, - resolveProviderStatusCachePath, - writeProviderStatusCache, -} from "../providerStatusCache.ts"; - -type ProviderSnapshotSource = { - readonly provider: ProviderKind; - readonly getSnapshot: Effect.Effect; - readonly refresh: Effect.Effect; - readonly streamChanges: Stream.Stream; -}; +import { ClaudeProviderLive } from "./ClaudeProvider"; +import { CodexProviderLive } from "./CodexProvider"; +import { CursorProviderLive } from "./CursorProvider"; +import type { ClaudeProviderShape } from "../Services/ClaudeProvider"; +import { ClaudeProvider } from "../Services/ClaudeProvider"; +import type { CodexProviderShape } from "../Services/CodexProvider"; +import { CodexProvider } from "../Services/CodexProvider"; +import type { CursorProviderShape } from "../Services/CursorProvider"; +import { CursorProvider } from "../Services/CursorProvider"; +import { ProviderRegistry, type ProviderRegistryShape } from "../Services/ProviderRegistry"; const loadProviders = ( - providerSources: ReadonlyArray, -): Effect.Effect> => - Effect.forEach(providerSources, (providerSource) => providerSource.getSnapshot, { + codexProvider: CodexProviderShape, + claudeProvider: ClaudeProviderShape, + cursorProvider: CursorProviderShape, +): Effect.Effect => + Effect.all([codexProvider.getSnapshot, claudeProvider.getSnapshot, cursorProvider.getSnapshot], { concurrency: "unbounded", }); @@ -90,87 +77,20 @@ const ProviderRegistryLiveBase = Layer.effect( Effect.gen(function* () { const codexProvider = yield* CodexProvider; const claudeProvider = yield* ClaudeProvider; - const openCodeProvider = yield* OpenCodeProvider; - const config = yield* ServerConfig; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const cursorProvider = yield* CursorProvider; - - const providerSources = [ - { - provider: "codex", - getSnapshot: codexProvider.getSnapshot, - refresh: codexProvider.refresh, - streamChanges: codexProvider.streamChanges, - }, - { - provider: "claudeAgent", - getSnapshot: claudeProvider.getSnapshot, - refresh: claudeProvider.refresh, - streamChanges: claudeProvider.streamChanges, - }, - { - provider: "opencode", - getSnapshot: openCodeProvider.getSnapshot, - refresh: openCodeProvider.refresh, - streamChanges: openCodeProvider.streamChanges, - }, - { - provider: "cursor", - getSnapshot: cursorProvider.getSnapshot, - refresh: cursorProvider.refresh, - streamChanges: cursorProvider.streamChanges, - }, - ] satisfies ReadonlyArray; - const activeProviders = PROVIDER_CACHE_IDS; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, ); - const fallbackProviders = yield* loadProviders(providerSources); - const cachePathByProvider = new Map( - activeProviders.map( - (provider) => - [ - provider, - resolveProviderStatusCachePath({ - cacheDir: config.providerStatusCacheDir, - provider, - }), - ] as const, - ), - ); - const fallbackByProvider = new Map( - fallbackProviders.map((provider) => [provider.provider, provider] as const), + const providersRef = yield* Ref.make>( + yield* loadProviders(codexProvider, claudeProvider, cursorProvider), ); - const cachedProviders = yield* Effect.forEach( - activeProviders, - (provider) => { - const filePath = cachePathByProvider.get(provider)!; - const fallbackProvider = fallbackByProvider.get(provider)!; - return readProviderStatusCache(filePath).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.map((cachedProvider) => - cachedProvider === undefined - ? undefined - : hydrateCachedProvider({ - cachedProvider, - fallbackProvider, - }), - ), - ); - }, - { concurrency: "unbounded" }, - ).pipe( - Effect.map((providers) => - orderProviderSnapshots( - providers.filter((provider): provider is ServerProvider => provider !== undefined), - ), - ), - ); - const providersRef = yield* Ref.make>(cachedProviders); + const syncProviders = (options?: { readonly publish?: boolean }) => + Effect.gen(function* () { + const previousProviders = yield* Ref.get(providersRef); + const providers = yield* loadProviders(codexProvider, claudeProvider, cursorProvider); + yield* Ref.set(providersRef, providers); const persistProvider = (provider: ServerProvider) => writeProviderStatusCache({ @@ -265,11 +185,33 @@ const ProviderRegistryLiveBase = Layer.effect( yield* loadProviders(providerSources).pipe( Effect.flatMap((providers) => upsertProviders(providers, { publish: false })), ); + yield* Stream.runForEach(cursorProvider.streamChanges, () => syncProviders()).pipe( + Effect.forkScoped, + ); return { getProviders: Ref.get(providersRef), refresh: (provider?: ProviderKind) => - refresh(provider).pipe( + Effect.gen(function* () { + switch (provider) { + case "codex": + yield* codexProvider.refresh; + break; + case "claudeAgent": + yield* claudeProvider.refresh; + break; + case "cursor": + yield* cursorProvider.refresh; + break; + default: + yield* Effect.all( + [codexProvider.refresh, claudeProvider.refresh, cursorProvider.refresh], + { concurrency: "unbounded" }, + ); + break; + } + return yield* syncProviders(); + }).pipe( Effect.tapError(Effect.logError), Effect.orElseSucceed(() => [] as ReadonlyArray), ), @@ -278,15 +220,8 @@ const ProviderRegistryLiveBase = Layer.effect( }, } satisfies ProviderRegistryShape; }), -); - -export const ProviderRegistryLive = Layer.unwrap( - Effect.sync(() => - ProviderRegistryLiveBase.pipe( - Layer.provideMerge(CursorProviderLive), - Layer.provideMerge(CodexProviderLive), - Layer.provideMerge(ClaudeProviderLive), - Layer.provideMerge(OpenCodeProviderLive), - ), - ), +).pipe( + Layer.provideMerge(CodexProviderLive), + Layer.provideMerge(ClaudeProviderLive), + Layer.provideMerge(CursorProviderLive), ); diff --git a/apps/server/src/provider/Services/CursorProvider.ts b/apps/server/src/provider/Services/CursorProvider.ts index aa70994f5e95..f4b8611bf8c1 100644 --- a/apps/server/src/provider/Services/CursorProvider.ts +++ b/apps/server/src/provider/Services/CursorProvider.ts @@ -1,9 +1,9 @@ -import { Context } from "effect"; +import { ServiceMap } from "effect"; -import type { ServerProviderShape } from "./ServerProvider.ts"; +import type { ServerProviderShape } from "./ServerProvider"; export interface CursorProviderShape extends ServerProviderShape {} -export class CursorProvider extends Context.Service()( +export class CursorProvider extends ServiceMap.Service()( "t3/provider/Services/CursorProvider", ) {} diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 0e753f368c5d..081005e61e95 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -6,7 +6,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; -import { CursorTraitsMenuContent } from "./CursorTraitsPicker"; import { TraitsMenuContent } from "./TraitsPicker"; import { useComposerDraftStore } from "../../composerDraftStore"; @@ -114,7 +113,26 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str }, }, ] - : []; + : provider === "cursor" + ? [ + { + slug: "gpt-5.3-codex", + name: "Codex 5.3", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra high" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + ] + : []; const screen = await render( - ) : ( - - ) + } onToggleInteractionMode={vi.fn()} onTogglePlanSidebar={vi.fn()} diff --git a/apps/web/src/components/chat/CursorTraitsPicker.tsx b/apps/web/src/components/chat/CursorTraitsPicker.tsx deleted file mode 100644 index fe589049fd82..000000000000 --- a/apps/web/src/components/chat/CursorTraitsPicker.tsx +++ /dev/null @@ -1,238 +0,0 @@ -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 setModelSelection = useComposerDraftStore((s) => s.setModelSelection); - const setStickyModelSelection = useComposerDraftStore((s) => s.setStickyModelSelection); - 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); - const nextModelSelection = { provider: "cursor" as const, model: nextSel.family }; - setModelSelection(threadId, nextModelSelection); - setProviderModelOptions(threadId, "cursor", persisted, { persistSticky: true }); - setStickyModelSelection(nextModelSelection); - }, - [setModelSelection, setProviderModelOptions, setStickyModelSelection, 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/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 77cb1b9a269f..68e52155e0e6 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -2,7 +2,6 @@ import { type ClaudeModelOptions, type CodexModelOptions, type CursorModelOptions, - type OpenCodeModelOptions, type ProviderKind, type ProviderModelOptions, type ScopedThreadRef, @@ -64,46 +63,15 @@ function getRawEffort( if (provider === "cursor") { return trimOrNull((modelOptions as CursorModelOptions | undefined)?.reasoning); } - if (provider === "opencode") { - return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant); - } return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort); } function getEffortKey(provider: ProviderKind): string { if (provider === "codex") return "reasoningEffort"; if (provider === "cursor") return "reasoning"; - if (provider === "opencode") return "variant"; return "effort"; } -function getRawAgent(modelOptions: ProviderOptions | null | undefined): string | null { - return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.agent); -} - -function resolveNamedOption( - options: ReadonlyArray, - raw: string | null, -): NamedOption | null { - if (raw) { - const matchingOption = options.find((option) => option.value === raw); - if (matchingOption) { - return matchingOption; - } - } - return options.find((option) => option.isDefault) ?? null; -} - -function getRawContextWindow( - provider: ProviderKind, - modelOptions: ProviderOptions | null | undefined, -): string | null { - if (modelOptions && "contextWindow" in modelOptions) { - return trimOrNull(modelOptions.contextWindow); - } - return null; -} - function buildNextOptions( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, @@ -319,11 +287,6 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ onPromptChange(nextPrompt); return; } - if (ultrathinkInBodyText) return; - if (ultrathinkPromptControlled) { - const stripped = prompt.replace(/^Ultrathink:\s*/i, ""); - onPromptChange(stripped); - } const effortKey = getEffortKey(provider); updateModelOptions( buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }), diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 803db6bbc826..483af1da4754 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -27,6 +27,47 @@ const CODEX_MODELS: ReadonlyArray = [ }, ]; +const CURSOR_MODELS: ReadonlyArray = [ + { + slug: "auto", + name: "Auto", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "Codex 5.3", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "normal", label: "Normal", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra high" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + promptInjectedEffortLevels: [], + }, + }, +]; + const CLAUDE_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-6", @@ -317,7 +358,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "cursor", model: "auto", - models: [], + models: CURSOR_MODELS, prompt: "", modelOptions: undefined, }); @@ -333,7 +374,7 @@ describe("getComposerProviderState", () => { const state = getComposerProviderState({ provider: "cursor", model: "composer-2", - models: [], + models: CURSOR_MODELS, prompt: "", modelOptions: { cursor: { fastMode: true }, @@ -347,6 +388,42 @@ describe("getComposerProviderState", () => { }); }); + it("resolves Cursor reasoning effort from server-driven capabilities", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "gpt-5.3-codex", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { reasoning: "high" }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: "high", + modelOptionsForDispatch: { reasoning: "high" }, + }); + }); + + it("drops default Cursor reasoning from dispatch options", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "gpt-5.3-codex", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { reasoning: "normal" }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: "normal", + modelOptionsForDispatch: undefined, + }); + }); + it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => { const state = getComposerProviderState({ provider: "claudeAgent", diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index c87a7c8c1382..050beec6a147 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -6,18 +6,16 @@ import { } from "@t3tools/contracts"; import { isClaudeUltrathinkPrompt, - normalizeCursorModelOptions, trimOrNull, } from "@t3tools/shared/model"; -import type { CursorModelOptions } from "@t3tools/contracts"; import type { ReactNode } from "react"; import { getProviderModelCapabilities, normalizeClaudeModelOptionsWithCapabilities, normalizeCodexModelOptionsWithCapabilities, + normalizeCursorModelOptionsWithCapabilities, } from "../../providerModels"; import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; -import { CursorTraitsMenuContent, CursorTraitsPicker } from "./CursorTraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; @@ -110,9 +108,7 @@ function getProviderStateFromCapabilities( ? providerOptions.reasoningEffort : "reasoning" in providerOptions ? providerOptions.reasoning - : "variant" in providerOptions - ? providerOptions.variant - : null + : null : null; const normalizedOptions = normalizeProviderModelOptionsWithCapabilities( provider, @@ -120,11 +116,21 @@ function getProviderStateFromCapabilities( providerOptions, ); const promptEffort = - provider === "opencode" - ? (trimOrNull( - normalizedOptions && "variant" in normalizedOptions ? normalizedOptions.variant : null, - ) ?? null) - : (resolveEffort(caps, rawEffort) ?? null); + draftEffort && !isPromptInjected && hasEffortLevel(caps, draftEffort) + ? draftEffort + : defaultEffort && hasEffortLevel(caps, defaultEffort) + ? defaultEffort + : null; + + // Normalize options for dispatch + const normalizedOptions = + provider === "codex" + ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) + : provider === "cursor" + ? normalizeCursorModelOptionsWithCapabilities(caps, providerOptions) + : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); + + // Ultrathink styling (driven by capabilities data, not provider identity) const ultrathinkActive = caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt); @@ -225,26 +231,34 @@ const composerProviderRegistry: Record = { ), }, cursor: { - getState: ({ model, modelOptions }) => { - const normalized = normalizeCursorModelOptions(model, modelOptions?.cursor); - return { - provider: "cursor" as const, - promptEffort: null, - modelOptionsForDispatch: normalized ?? undefined, - }; - }, - renderTraitsMenuContent: ({ threadId, model, modelOptions }) => ( - getProviderStateFromCapabilities(input), + renderTraitsMenuContent: ({ + threadId, + model, + models, + modelOptions, + prompt, + onPromptChange, + }) => ( + ), - renderTraitsPicker: ({ threadId, model, modelOptions }) => ( - ( + ), }, diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index e901a895f49a..03881b54168e 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -1,5 +1,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, + type ClaudeModelOptions, + type CodexModelOptions, type CursorModelOptions, type ModelCapabilities, type ProviderKind, @@ -77,18 +79,58 @@ export function getDefaultServerModel( ); } +export function normalizeCursorModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: CodexModelOptions | null | undefined, +): CodexModelOptions | undefined { + const defaultReasoningEffort = getDefaultEffort(caps); + const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; + const fastModeEnabled = modelOptions?.fastMode === true; + const nextOptions: CodexModelOptions = { + ...(reasoningEffort && reasoningEffort !== defaultReasoningEffort + ? { reasoningEffort: reasoningEffort as CodexModelOptions["reasoningEffort"] } + : {}), + ...(fastModeEnabled ? { fastMode: true } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + export function normalizeCursorModelOptionsWithCapabilities( caps: ModelCapabilities, modelOptions: CursorModelOptions | null | undefined, ): CursorModelOptions | undefined { + const defaultEffort = getDefaultEffort(caps); const reasoning = trimOrNull(modelOptions?.reasoning); const reasoningValue = - reasoning && hasEffortLevel(caps, reasoning) + reasoning && hasEffortLevel(caps, reasoning) && reasoning !== defaultEffort ? (reasoning as CursorModelOptions["reasoning"]) : undefined; - const fastMode = - caps.supportsFastMode && typeof modelOptions?.fastMode === "boolean" - ? modelOptions.fastMode + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const claudeOpusTier = modelOptions?.claudeOpusTier ?? undefined; + const nextOptions: CursorModelOptions = { + ...(reasoningValue ? { reasoning: reasoningValue } : {}), + ...(fastMode ? { fastMode: true } : {}), + ...(thinking === false ? { thinking: false } : {}), + ...(claudeOpusTier ? { claudeOpusTier } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} + +export function normalizeClaudeModelOptionsWithCapabilities( + caps: ModelCapabilities, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const defaultReasoningEffort = getDefaultEffort(caps); + const resolvedEffort = trimOrNull(modelOptions?.effort); + const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); + const effort = + resolvedEffort && + !isPromptInjected && + hasEffortLevel(caps, resolvedEffort) && + resolvedEffort !== defaultReasoningEffort + ? resolvedEffort : undefined; const thinking = caps.supportsThinkingToggle && typeof modelOptions?.thinking === "boolean" From bab95e796d90119c783469e8c02ff72b638a3fab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 13:30:29 -0700 Subject: [PATCH 03/42] Switch Cursor model selection to in-session via session/set_config_option Instead of restarting the ACP process when the model changes mid-thread, use session/set_config_option to switch models within a live session. Update sessionModelSwitch to "in-session" and add probe tests to verify the real agent supports this method. Made-with: Cursor --- apps/server/scripts/acp-mock-agent.mjs | 36 +++++++ .../cursor-acp-model-selection-probe.ts | 39 ++++---- .../Layers/ProviderCommandReactor.test.ts | 8 +- .../src/provider/Layers/CursorAdapter.test.ts | 23 ++--- .../src/provider/Layers/CursorAdapter.ts | 50 ++++++---- .../provider/acp/CursorAcpCliProbe.test.ts | 95 +++++++++++++++++++ 6 files changed, 199 insertions(+), 52 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.mjs b/apps/server/scripts/acp-mock-agent.mjs index a261ed692dab..d7320b929ea7 100644 --- a/apps/server/scripts/acp-mock-agent.mjs +++ b/apps/server/scripts/acp-mock-agent.mjs @@ -11,7 +11,27 @@ 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 currentModelId = "auto"; let nextRequestId = 1; + +function configOptions() { + return [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: [ + { value: "auto", name: "Auto" }, + { value: "composer-2", name: "Composer 2" }, + { value: "composer-2-fast", name: "Composer 2 Fast" }, + { value: "gpt-5.3-codex", name: "Codex 5.3" }, + ], + }, + ]; +} + const availableModes = [ { id: "ask", @@ -127,6 +147,7 @@ rl.on("line", (line) => { result: { sessionId, modes: modeState(), + configOptions: configOptions(), }, }); return; @@ -146,11 +167,26 @@ rl.on("line", (line) => { id, result: { modes: modeState(), + configOptions: configOptions(), }, }); return; } + if (method === "session/set_config_option" && id !== undefined) { + const configId = msg.params?.configId; + const value = msg.params?.value; + if (configId === "model" && typeof value === "string") { + currentModelId = value; + } + send({ + jsonrpc: "2.0", + id, + result: { configOptions: configOptions() }, + }); + return; + } + if (method === "session/prompt" && id !== undefined) { const requestedSessionId = msg.params?.sessionId ?? sessionId; if (emitToolCalls) { diff --git a/apps/server/scripts/cursor-acp-model-selection-probe.ts b/apps/server/scripts/cursor-acp-model-selection-probe.ts index efcc78affbc9..992502ae1ec5 100644 --- a/apps/server/scripts/cursor-acp-model-selection-probe.ts +++ b/apps/server/scripts/cursor-acp-model-selection-probe.ts @@ -94,9 +94,21 @@ const program = Effect.gen(function* () { yield* adapter.sendTurn({ threadId, - input: "probe model selection", + input: "first turn with initial model", attachments: [], }); + + yield* adapter.sendTurn({ + threadId, + input: "second turn after model switch", + attachments: [], + modelSelection: { + provider: "cursor", + model: "composer-2", + options: { fastMode: true }, + }, + }); + yield* adapter.stopSession(threadId); const argv = (yield* Effect.promise(() => readFile(argvLogPath, "utf8"))) @@ -104,30 +116,23 @@ const program = Effect.gen(function* () { .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; + const setConfigRequests = requests.filter( + (entry) => entry.method === "session/set_config_option", + ); + const promptRequests = requests.filter((entry) => entry.method === "session/prompt"); return { - input: { - model, - fastMode, - }, + 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"), - ), + setConfigRequests: setConfigRequests.map((r) => r.params), + promptCount: promptRequests.length, + sessionRestartCount: requests.filter((entry) => entry.method === "session/new").length, conclusion: - "Cursor model selection is decided before ACP initialize via CLI argv. The ACP session/prompt payload does not carry a model field.", + "Model switching uses session/set_config_option (in-session). No session restart needed.", }; }).pipe(Effect.provide(layer)); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d2abcffe6cea..7ccc2f35ed89 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1066,10 +1066,9 @@ describe("ProviderCommandReactor", () => { }); }); - it("restarts cursor sessions on model changes while preserving resumeCursor", async () => { + it("switches cursor model in-session without restarting", async () => { const harness = await createHarness({ threadModelSelection: { provider: "cursor", model: "composer-2" }, - sessionModelSwitch: "unsupported", }); const now = new Date().toISOString(); @@ -1112,12 +1111,11 @@ describe("ProviderCommandReactor", () => { }), ); - await waitFor(() => harness.startSession.mock.calls.length === 2); await waitFor(() => harness.sendTurn.mock.calls.length === 2); - expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + expect(harness.startSession.mock.calls.length).toBe(1); + expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ modelSelection: { provider: "cursor", model: "composer-2-fast" }, - resumeCursor: { opaque: "resume-1" }, }); }); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index fc429ecaa025..12fde586042a 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -428,11 +428,11 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ), ); - it.effect("restarts ACP with session/load when the Cursor model changes mid-thread", () => + it.effect("switches model in-session via session/set_config_option", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; - const threadId = ThreadId.makeUnsafe("cursor-model-restart"); + const threadId = ThreadId.makeUnsafe("cursor-model-switch"); 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"); @@ -464,19 +464,16 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }); const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); - assert.deepStrictEqual(argvRuns, [ - ["--model", "composer-2", "acp"], - ["--model", "composer-2-fast", "acp"], - ]); + assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); + assert.deepStrictEqual(argvRuns[0], ["--model", "composer-2", "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: [], - }); + const setConfigRequests = requests.filter( + (entry) => entry.method === "session/set_config_option", + ); + assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); + const lastSetConfig = setConfigRequests[setConfigRequests.length - 1]; + assert.equal((lastSetConfig?.params as Record)?.value, "composer-2-fast"); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 121a46000e39..5614ca59c64d 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -88,6 +88,18 @@ function parseCursorResume(raw: unknown): { sessionId: string } | undefined { return { sessionId: raw.sessionId.trim() }; } +function extractModelConfigId(sessionResponse: unknown): string | undefined { + if (!isRecord(sessionResponse)) return undefined; + const configOptions = sessionResponse.configOptions; + if (!Array.isArray(configOptions)) return undefined; + for (const opt of configOptions) { + if (isRecord(opt) && opt.category === "model" && typeof opt.id === "string") { + return opt.id; + } + } + return undefined; +} + function buildCursorSpawnInput(cwd: string, opts?: CursorSpawnOptions, model?: string | undefined) { const command = opts?.binaryPath?.trim() || "agent"; const hasCustomArgs = opts?.args && opts.args.length > 0; @@ -670,6 +682,8 @@ interface CursorSessionContext { readonly child: ChildProcessWithoutNullStreams; readonly conn: AcpJsonRpcConnection; acpSessionId: string; + /** ACP configId for the model selector (discovered from session/new configOptions). */ + modelConfigId: string | undefined; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -940,6 +954,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { child, conn, acpSessionId: "", + modelConfigId: undefined, notificationFiber: undefined, pendingApprovals: new Map(), pendingUserInputs: new Map(), @@ -1189,6 +1204,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { ctx.session = session; ctx.acpSessionId = acpSessionId; + ctx.modelConfigId = extractModelConfigId(sessionSetupResult); ctx.modeState = parseSessionModeState(sessionSetupResult); const handleNotification = (msg: AcpInboundMessage) => @@ -1269,9 +1285,22 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return session; }); + const setSessionModel = (ctx: CursorSessionContext, model: string) => + Effect.gen(function* () { + const configId = ctx.modelConfigId ?? "model"; + yield* ctx.conn + .request("session/set_config_option", { + sessionId: ctx.acpSessionId, + configId, + value: model, + }) + .pipe(Effect.ignore); + ctx.session = { ...ctx.session, model, updatedAt: yield* nowIso }; + }); + const sendTurn: CursorAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { - let ctx = yield* requireSession(input.threadId); + const ctx = yield* requireSession(input.threadId); const turnId = TurnId.makeUnsafe(crypto.randomUUID()); const turnModelSelection = input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; @@ -1279,21 +1308,8 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { turnModelSelection?.model ?? ctx.session.model, turnModelSelection?.options, ); - 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, - modelSelection: turnModelSelection ?? { provider: PROVIDER, model }, - ...(ctx.session.resumeCursor !== undefined - ? { resumeCursor: ctx.session.resumeCursor } - : {}), - }); - ctx = yield* requireSession(input.threadId); - } + + yield* setSessionModel(ctx, model); ctx.activeTurnId = turnId; ctx.lastPlanFingerprint = undefined; ctx.toolCalls.clear(); @@ -1494,7 +1510,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { return { provider: PROVIDER, - capabilities: { sessionModelSwitch: "unsupported" }, + capabilities: { sessionModelSwitch: "in-session" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index 9114b88ade27..3546ad9ed359 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -9,6 +9,10 @@ import { describe, expect } from "vitest"; import { makeAcpJsonRpcConnection } from "./AcpJsonRpcConnection.ts"; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + 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* () { @@ -31,4 +35,95 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", yield* conn.request("authenticate", { methodId: "cursor_login" }); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("session/new returns configOptions with a model selector", () => + Effect.gen(function* () { + const conn = yield* makeAcpJsonRpcConnection({ + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }); + + yield* conn.request("initialize", { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }); + yield* conn.request("authenticate", { methodId: "cursor_login" }); + + const result = yield* conn.request("session/new", { + cwd: process.cwd(), + mcpServers: [], + }); + + expect(isRecord(result)).toBe(true); + const r = result as Record; + expect(typeof r.sessionId).toBe("string"); + + const configOptions = r.configOptions; + console.log("session/new configOptions:", JSON.stringify(configOptions, null, 2)); + + if (Array.isArray(configOptions)) { + const modelConfig = configOptions.find( + (opt: unknown) => isRecord(opt) && opt.category === "model", + ); + console.log("Model config option:", JSON.stringify(modelConfig, null, 2)); + expect(modelConfig).toBeDefined(); + expect(isRecord(modelConfig) && typeof modelConfig.id === "string").toBe(true); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("session/set_config_option switches the model in-session", () => + Effect.gen(function* () { + const conn = yield* makeAcpJsonRpcConnection({ + command: "agent", + args: ["acp"], + cwd: process.cwd(), + }); + + yield* conn.request("initialize", { + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false }, + clientInfo: { name: "t3-probe", version: "0.0.0" }, + }); + yield* conn.request("authenticate", { methodId: "cursor_login" }); + + const newResult = (yield* conn.request("session/new", { + cwd: process.cwd(), + mcpServers: [], + })) as Record; + const sessionId = newResult.sessionId as string; + + const configOptions = newResult.configOptions; + let modelConfigId = "model"; + if (Array.isArray(configOptions)) { + const modelConfig = configOptions.find( + (opt: unknown) => isRecord(opt) && opt.category === "model", + ); + if (isRecord(modelConfig) && typeof modelConfig.id === "string") { + modelConfigId = modelConfig.id; + } + } + + const setResult = yield* conn.request("session/set_config_option", { + sessionId, + configId: modelConfigId, + value: "composer-2", + }); + + console.log("session/set_config_option result:", JSON.stringify(setResult, null, 2)); + + expect(isRecord(setResult)).toBe(true); + const sr = setResult as Record; + if (Array.isArray(sr.configOptions)) { + const modelConfig = sr.configOptions.find( + (opt: unknown) => isRecord(opt) && opt.category === "model", + ); + if (isRecord(modelConfig)) { + expect(modelConfig.currentValue).toBe("composer-2"); + } + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); From 04d6d12c8c1c2ccce817b26d97a037562404ac94 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 15:06:15 -0700 Subject: [PATCH 04/42] Refactor Cursor model handling in ChatView and ProviderModelPicker - Removed unused CursorModelOptions and related logic from ChatView. - Updated model selection handling to map concrete Cursor slugs to server-provided options. - Simplified ProviderModelPicker by eliminating unnecessary cursor-related state and logic. - Adjusted tests to reflect changes in model selection behavior for Cursor provider. Made-with: Cursor --- apps/web/src/components/ChatView.tsx | 46 +------------ .../CompactComposerControlsMenu.browser.tsx | 2 +- .../chat/ProviderModelPicker.browser.tsx | 63 ++++++++++++++--- .../components/chat/ProviderModelPicker.tsx | 69 +++++-------------- apps/web/src/routes/_chat.settings.tsx | 1 - packages/contracts/src/model.ts | 27 -------- packages/shared/src/model.test.ts | 16 +++++ packages/shared/src/model.ts | 35 ++++++++-- 8 files changed, 116 insertions(+), 143 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 01732dfd6591..7bcfbeea7128 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2,7 +2,6 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, - type CursorModelOptions, type MessageId, type ModelSelection, type ProjectScript, @@ -20,14 +19,7 @@ import { RuntimeMode, TerminalOpenInput, } from "@t3tools/contracts"; -import { - applyClaudePromptEffortPrefix, - getDefaultModel, - isCursorModelFamilySlug, - normalizeModelSlug, - parseCursorModelSelection, - resolveModelSlugForProvider, -} from "@t3tools/shared/model"; +import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -637,9 +629,6 @@ export default function ChatView(props: ChatViewProps) { (store) => store.setTerminalContexts, ); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); - const setComposerDraftProviderModelOptions = useComposerDraftStore( - (store) => store.setProviderModelOptions, - ); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( (store) => store.setInteractionMode, @@ -3242,33 +3231,6 @@ export default function ChatView(props: ChatViewProps) { providerStatuses, model, ); - if ( - resolvedProvider === "cursor" && - isCursorModelFamilySlug(resolvedModel) && - activeThread.id.length > 0 - ) { - const prevDraft = useComposerDraftStore.getState().draftsByThreadId[activeThread.id]; - const prevCursorSelection = prevDraft?.modelSelectionByProvider?.cursor; - const prevModelRaw = - prevCursorSelection?.model ?? - (typeof activeThread.modelSelection?.model === "string" - ? resolveModelSlugForProvider("cursor", activeThread.modelSelection.model) - : null) ?? - getDefaultModel("cursor"); - const prevResolved = resolveAppModelSelection( - "cursor", - settings, - providerStatuses, - prevModelRaw, - ); - const prevCursorOptions = prevCursorSelection?.options as CursorModelOptions | undefined; - const prevFamily = parseCursorModelSelection(prevResolved, prevCursorOptions).family; - if (prevFamily !== resolvedModel) { - setComposerDraftProviderModelOptions(activeThread.id, "cursor", null, { - persistSticky: true, - }); - } - } const nextModelSelection: ModelSelection = { provider: resolvedProvider, model: resolvedModel, @@ -3285,7 +3247,6 @@ export default function ChatView(props: ChatViewProps) { lockedProvider, scheduleComposerFocus, setComposerDraftModelSelection, - setComposerDraftProviderModelOptions, setStickyComposerModelSelection, providerStatuses, settings, @@ -3670,11 +3631,6 @@ export default function ChatView(props: ChatViewProps) { lockedProvider={lockedProvider} providers={providerStatuses} modelOptionsByProvider={modelOptionsByProvider} - cursorModelOptions={ - selectedProvider === "cursor" - ? (composerModelOptions?.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 081005e61e95..2ad83b1df465 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -246,7 +246,7 @@ describe("CompactComposerControlsMenu", () => { it("shows Cursor reasoning controls for GPT-5.3 Codex family", async () => { const mounted = await mountMenu({ - modelSelection: { provider: "cursor", model: "gpt-5.3-codex-high" }, + modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, }); try { diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 65c3168b0b2d..86f53744712c 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -1,9 +1,4 @@ -import { - CURSOR_MODEL_FAMILY_OPTIONS, - type ModelSlug, - type ProviderKind, - type ServerProvider, -} from "@t3tools/contracts"; +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; @@ -117,6 +112,58 @@ const TEST_PROVIDERS: ReadonlyArray = [ }, ], }, + { + provider: "cursor", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + authStatus: "authenticated", + checkedAt: new Date().toISOString(), + models: [ + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "gpt-5.3-codex", + name: "Codex 5.3", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + effort("low"), + effort("normal", true), + effort("high"), + effort("xhigh"), + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + { + slug: "claude-4.6-opus", + name: "Claude Opus 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }, ]; function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { @@ -152,7 +199,6 @@ async function mountPicker(props: { props.provider, props.model, ), - cursor: [...CURSOR_MODEL_FAMILY_OPTIONS], }; const screen = await render( , @@ -269,7 +314,7 @@ describe("ProviderModelPicker", () => { } }); - it("keeps Cursor submenu values as family keys (traits resolve the CLI slug)", async () => { + it("maps concrete Cursor slugs onto the server-provided model options", async () => { const mounted = await mountPicker({ provider: "cursor", model: "claude-4.6-opus-high-thinking", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 1042672ccdb8..bc12a06c8d7d 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,17 +1,5 @@ -import { - CURSOR_MODEL_FAMILY_OPTIONS, - MODEL_OPTIONS_BY_PROVIDER, - type CursorModelOptions, - type ModelSlug, - type ProviderKind, - type ServerProvider, -} from "@t3tools/contracts"; -import { - isCursorModelFamilySlug, - parseCursorModelSelection, - resolveModelSlugForProvider, - resolveSelectableModel, -} from "@t3tools/shared/model"; +import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { resolveModelSlugForProvider, resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; @@ -72,55 +60,28 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { triggerVariant?: VariantProps["variant"]; triggerClassName?: string; disabledReason?: string; - cursorModelOptions: CursorModelOptions | null; - onProviderModelChange: (provider: ProviderKind, model: ModelSlug) => void; + onProviderModelChange: (provider: ProviderKind, model: string) => 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 selectedModelValue = + resolveSelectableModel(activeProvider, props.model, selectedProviderOptions) ?? props.model; const selectedModelLabel = - 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); + selectedProviderOptions.find((option) => option.slug === selectedModelValue)?.name ?? + props.model; const ProviderIcon = PROVIDER_ICON_BY_PROVIDER[activeProvider]; const handleModelChange = (provider: ProviderKind, value: string) => { if (props.disabled) return; if (!value) return; - 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], - ); - } + const resolvedModel = + resolveSelectableModel(provider, value, props.modelOptionsByProvider[provider]) ?? + resolveModelSlugForProvider(provider, value); if (!resolvedModel) return; props.onProviderModelChange(provider, resolvedModel); setIsMenuOpen(false); }; - const cursorRadioValue = - activeProvider === "cursor" - ? parseCursorModelSelection(props.model, props.cursorModelOptions).family - : ""; - return ( handleModelChange(props.lockedProvider!, value)} > {props.modelOptionsByProvider[props.lockedProvider].map((modelOption) => ( @@ -230,9 +191,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { handleModelChange(option.value, value)} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4f572395eb3..26bdeeac4dda 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -852,7 +852,6 @@ function SettingsRouteView() { lockedProvider={null} providers={serverProviders} modelOptionsByProvider={gitModelOptionsByProvider} - cursorModelOptions={null} triggerVariant="outline" triggerClassName="min-w-0 max-w-none shrink-0 text-foreground/90 hover:text-foreground" onProviderModelChange={(provider, model) => { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 1205f890db02..3b18e65d5352 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -79,33 +79,6 @@ export 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" }, diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index fac88d7fdbce..c8530d4de7c9 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -99,6 +99,22 @@ describe("resolveSelectableModel", () => { expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); }); + + it("maps concrete slugs back to the closest selectable family option", () => { + const cursorOptions = [ + { slug: "composer-2", name: "Composer 2" }, + { slug: "gpt-5.3-codex", name: "Codex 5.3" }, + { slug: "claude-4.6-opus", name: "Claude Opus 4.6" }, + ]; + + expect(resolveSelectableModel("cursor", "composer-2-fast", cursorOptions)).toBe("composer-2"); + expect(resolveSelectableModel("cursor", "gpt-5.3-codex-high-fast", cursorOptions)).toBe( + "gpt-5.3-codex", + ); + expect(resolveSelectableModel("cursor", "claude-4.6-opus-high-thinking", cursorOptions)).toBe( + "claude-4.6-opus", + ); + }); }); describe("capability helpers", () => { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index cc4406a3b304..836294179d7c 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,5 +1,4 @@ import { - CURSOR_MODEL_FAMILY_OPTIONS, CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_REASONING_EFFORT_BY_PROVIDER, @@ -11,7 +10,6 @@ import { type CodexModelOptions, type CodexReasoningEffort, type CursorClaudeOpusTier, - type CursorModelFamily, type CursorModelOptions, type CursorModelSlug, type CursorReasoningOption, @@ -28,6 +26,20 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record> = cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)), }; +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; + +export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"]; + type CursorModelCapability = { readonly supportsReasoning: boolean; readonly supportsFast: boolean; @@ -134,10 +146,6 @@ export interface CursorModelSelection { readonly claudeOpusTier: CursorClaudeOpusTier; } -export function getCursorModelFamilyOptions() { - return CURSOR_MODEL_FAMILY_OPTIONS; -} - export function getCursorModelCapabilities(family: CursorModelFamily) { return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family]; } @@ -693,7 +701,20 @@ export function resolveSelectableModel( } const resolved = options.find((option) => option.slug === normalized); - return resolved ? resolved.slug : null; + if (resolved) { + return resolved.slug; + } + + const familyMatch = options + .toSorted((left, right) => right.slug.length - left.slug.length) + .find((option) => { + if (!normalized.startsWith(option.slug)) { + return false; + } + const nextChar = normalized.charAt(option.slug.length); + return nextChar === "-" || nextChar === "[" || nextChar === ""; + }); + return familyMatch?.slug ?? null; } function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string { From 988453993e2b969a7a35ff62cdeebc21c285f7a5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 15:52:18 -0700 Subject: [PATCH 05/42] rm probes --- apps/server/scripts/acp-mock-agent.mjs | 315 ------------------ .../cursor-acp-model-selection-probe.ts | 140 -------- 2 files changed, 455 deletions(-) delete mode 100644 apps/server/scripts/acp-mock-agent.mjs delete mode 100644 apps/server/scripts/cursor-acp-model-selection-probe.ts diff --git a/apps/server/scripts/acp-mock-agent.mjs b/apps/server/scripts/acp-mock-agent.mjs deleted file mode 100644 index d7320b929ea7..000000000000 --- a/apps/server/scripts/acp-mock-agent.mjs +++ /dev/null @@ -1,315 +0,0 @@ -#!/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 currentModelId = "auto"; -let nextRequestId = 1; - -function configOptions() { - return [ - { - id: "model", - name: "Model", - category: "model", - type: "select", - currentValue: currentModelId, - options: [ - { value: "auto", name: "Auto" }, - { value: "composer-2", name: "Composer 2" }, - { value: "composer-2-fast", name: "Composer 2 Fast" }, - { value: "gpt-5.3-codex", name: "Codex 5.3" }, - ], - }, - ]; -} - -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(), - configOptions: configOptions(), - }, - }); - 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(), - configOptions: configOptions(), - }, - }); - return; - } - - if (method === "session/set_config_option" && id !== undefined) { - const configId = msg.params?.configId; - const value = msg.params?.value; - if (configId === "model" && typeof value === "string") { - currentModelId = value; - } - send({ - jsonrpc: "2.0", - id, - result: { configOptions: configOptions() }, - }); - 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 deleted file mode 100644 index 992502ae1ec5..000000000000 --- a/apps/server/scripts/cursor-acp-model-selection-probe.ts +++ /dev/null @@ -1,140 +0,0 @@ -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 { ServerSettingsService } from "../src/serverSettings.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(ServerSettingsService.layerTest()), - Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), - Layer.provideMerge(NodeServices.layer), -); - -const program = Effect.gen(function* () { - const adapter = yield* CursorAdapter; - const serverSettings = yield* ServerSettingsService; - 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 cursorModelOptions = fastMode ? { fastMode: true as const } : undefined; - const dispatchedModel = resolveCursorDispatchModel(model, cursorModelOptions); - - yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - - yield* adapter.startSession({ - threadId, - provider: "cursor", - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: { - provider: "cursor", - model, - ...(cursorModelOptions ? { options: cursorModelOptions } : {}), - }, - }); - - yield* adapter.sendTurn({ - threadId, - input: "first turn with initial model", - attachments: [], - }); - - yield* adapter.sendTurn({ - threadId, - input: "second turn after model switch", - attachments: [], - modelSelection: { - provider: "cursor", - model: "composer-2", - options: { fastMode: true }, - }, - }); - - 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 setConfigRequests = requests.filter( - (entry) => entry.method === "session/set_config_option", - ); - const promptRequests = requests.filter((entry) => entry.method === "session/prompt"); - - return { - input: { model, fastMode }, - dispatchedModel, - spawnedArgv: argv, - acpMethods: requests - .map((entry) => entry.method) - .filter((method): method is string => typeof method === "string"), - setConfigRequests: setConfigRequests.map((r) => r.params), - promptCount: promptRequests.length, - sessionRestartCount: requests.filter((entry) => entry.method === "session/new").length, - conclusion: - "Model switching uses session/set_config_option (in-session). No session restart needed.", - }; -}).pipe(Effect.provide(layer)); - -const result = await Effect.runPromise(program); -process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); From 20aeccad1701b85426accbf9460572d4d283eb6c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 15:53:49 -0700 Subject: [PATCH 06/42] rm probe --- scripts/cursor-agent-models-probe.mjs | 115 -------------------------- 1 file changed, 115 deletions(-) delete mode 100644 scripts/cursor-agent-models-probe.mjs diff --git a/scripts/cursor-agent-models-probe.mjs b/scripts/cursor-agent-models-probe.mjs deleted file mode 100644 index bf56ed0b7187..000000000000 --- a/scripts/cursor-agent-models-probe.mjs +++ /dev/null @@ -1,115 +0,0 @@ -#!/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(); From d4bd1ccb4f3fd96de400804889a1f975495b6d20 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 16:23:40 -0700 Subject: [PATCH 07/42] Probe Cursor ACP session setup in one script - Add a standalone ACP probe script for initialize/auth/session/new - Switch Cursor provider status checks to `agent about` for version and auth - Log the ACP session/new result in the probe test --- .../cursor-acp-model-selection-probe.ts | 64 ++++++ .../src/provider/Layers/CursorProvider.ts | 211 +++++++----------- .../provider/acp/CursorAcpCliProbe.test.ts | 1 + 3 files changed, 147 insertions(+), 129 deletions(-) create mode 100644 apps/server/scripts/cursor-acp-model-selection-probe.ts 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 000000000000..c8fef3c7d074 --- /dev/null +++ b/apps/server/scripts/cursor-acp-model-selection-probe.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env bun +/** + * Standalone probe: spawn `agent acp`, send initialize → session/new, print results. + * + * Usage: + * bun apps/server/scripts/cursor-acp-model-selection-probe.ts [--cwd /path/to/dir] + */ +import { parseArgs } from "node:util"; + +import { + attachAcpJsonRpcConnection, + disposeAcpChild, + spawnAcpChildProcess, +} from "../src/provider/acp/AcpJsonRpcConnection.ts"; +import { Effect } from "effect"; + +const { values } = parseArgs({ + options: { + cwd: { type: "string", default: process.cwd() }, + }, + strict: true, +}); + +const cwd = values.cwd!; + +const program = Effect.gen(function* () { + const child = yield* spawnAcpChildProcess({ command: "agent", args: ["acp"], cwd }); + + try { + const conn = yield* attachAcpJsonRpcConnection(child); + + console.log("→ initialize"); + const initResult = yield* conn.request("initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "cursor-acp-probe", version: "0.0.0" }, + }); + console.log("← initialize response:"); + console.log(JSON.stringify(initResult, null, 2)); + + console.log("\n→ authenticate"); + const authResult = yield* conn.request("authenticate", { methodId: "cursor_login" }); + console.log("← authenticate response:"); + console.log(JSON.stringify(authResult, null, 2)); + + console.log(`\n→ session/new (cwd: ${cwd})`); + const sessionResult = yield* conn.request("session/new", { + cwd, + mcpServers: [], + }); + console.log("← session/new response:"); + console.log(JSON.stringify(sessionResult, null, 2)); + } finally { + disposeAcpChild(child); + } +}); + +Effect.runPromise(program).catch((err) => { + console.error("Probe failed:", err); + process.exit(1); +}); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 5300807766ea..da66a905d77b 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -12,11 +12,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { buildServerProvider, collectStreamAsString, - DEFAULT_TIMEOUT_MS, - detailFromResult, - extractAuthBoolean, isCommandMissingCause, - parseGenericCliVersion, providerModelsFromSettings, type CommandResult, } from "../providerSnapshot"; @@ -154,85 +150,105 @@ export function getCursorModelCapabilities(model: string | null | undefined): Mo ); } -export function parseCursorAuthStatusFromOutput(result: CommandResult): { +/** Timeout for `agent about` — it's slower than a simple `--version` probe. */ +const ABOUT_TIMEOUT_MS = 8_000; + +/** Strip ANSI escape sequences so we can parse plain key-value lines. */ +function stripAnsi(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07/g, ""); +} + +/** + * Extract a value from `agent about` key-value output. + * Lines look like: `CLI Version 2026.03.20-44cb435` + */ +function extractAboutField(plain: string, key: string): string | undefined { + const regex = new RegExp(`^${key}\\s{2,}(.+)$`, "mi"); + const match = regex.exec(plain); + return match?.[1]?.trim(); +} + +export interface CursorAboutResult { + readonly version: string | null; readonly status: Exclude; readonly authStatus: ServerProviderAuthStatus; readonly message?: string; -} { - const lowerOutput = `${result.stdout}\n${result.stderr}`.toLowerCase(); +} + +/** + * Parse the output of `agent about` to extract version and authentication + * status in a single probe. + * + * Example output (logged in): + * ``` + * About Cursor CLI + * + * CLI Version 2026.03.20-44cb435 + * User Email user@example.com + * ``` + * + * Example output (logged out): + * ``` + * About Cursor CLI + * + * CLI Version 2026.03.20-44cb435 + * User Email Not logged in + * ``` + */ +export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult { + const combined = `${result.stdout}\n${result.stderr}`; + const lowerOutput = combined.toLowerCase(); + // If the command itself isn't recognised, we're on an old CLI version. if ( lowerOutput.includes("unknown command") || lowerOutput.includes("unrecognized command") || lowerOutput.includes("unexpected argument") ) { return { + version: null, status: "warning", authStatus: "unknown", - message: - "Cursor Agent authentication status command is unavailable in this version of the Agent CLI.", + message: "The `agent about` command is unavailable in this version of the Cursor Agent CLI.", }; } - if ( - lowerOutput.includes("not logged in") || - lowerOutput.includes("login required") || - lowerOutput.includes("authentication required") || - lowerOutput.includes("run `agent login`") || - lowerOutput.includes("run agent login") - ) { + const plain = stripAnsi(combined); + console.log("plain:", plain); + const version = extractAboutField(plain, "CLI Version") ?? null; + const userEmail = extractAboutField(plain, "User Email"); + + // Determine auth from the User Email field. + if (userEmail === undefined) { + // Field missing entirely — can't determine auth. + if (result.code === 0) { + return { version, status: "ready", authStatus: "unknown" }; + } return { - status: "error", - authStatus: "unauthenticated", - message: "Cursor Agent is not authenticated. Run `agent login` and try again.", + version, + status: "warning", + authStatus: "unknown", + message: "Could not verify Cursor Agent authentication status.", }; } - const parsedAuth = (() => { - const trimmed = result.stdout.trim(); - if (!trimmed || (!trimmed.startsWith("{") && !trimmed.startsWith("["))) { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - try { - return { - attemptedJsonParse: true as const, - auth: extractAuthBoolean(JSON.parse(trimmed)), - }; - } catch { - return { attemptedJsonParse: false as const, auth: undefined as boolean | undefined }; - } - })(); - - if (parsedAuth.auth === true) { - return { status: "ready", authStatus: "authenticated" }; - } - if (parsedAuth.auth === false) { + const lowerEmail = userEmail.toLowerCase(); + if ( + lowerEmail === "not logged in" || + lowerEmail.includes("login required") || + lowerEmail.includes("authentication required") + ) { return { + version, status: "error", authStatus: "unauthenticated", message: "Cursor Agent is not authenticated. Run `agent login` and try again.", }; } - if (parsedAuth.attemptedJsonParse) { - return { - status: "warning", - authStatus: "unknown", - message: - "Could not verify Cursor Agent authentication status from JSON output (missing auth marker).", - }; - } - if (result.code === 0) { - return { status: "ready", authStatus: "authenticated" }; - } - const detail = detailFromResult(result); - return { - status: "warning", - authStatus: "unknown", - message: detail - ? `Could not verify Cursor Agent authentication status. ${detail}` - : "Could not verify Cursor Agent authentication status.", - }; + // Any non-empty email value means authenticated. + return { version, status: "ready", authStatus: "authenticated" }; } const runCursorCommand = (args: ReadonlyArray) => @@ -292,13 +308,14 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( }); } - const versionProbe = yield* runCursorCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + // Single `agent about` probe: returns version + auth status in one call. + const aboutProbe = yield* runCursorCommand(["about"]).pipe( + Effect.timeoutOption(ABOUT_TIMEOUT_MS), Effect.result, ); - if (Result.isFailure(versionProbe)) { - const error = versionProbe.failure; + if (Result.isFailure(aboutProbe)) { + const error = aboutProbe.failure; return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, @@ -316,7 +333,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( }); } - if (Option.isNone(versionProbe.success)) { + if (Option.isNone(aboutProbe.success)) { return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, @@ -327,76 +344,12 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( version: null, status: "error", authStatus: "unknown", - message: - "Cursor Agent CLI is installed but failed to run. Timed out while running command.", - }, - }); - } - - const version = versionProbe.success.value; - const parsedVersion = parseGenericCliVersion(`${version.stdout}\n${version.stderr}`); - if (version.code !== 0) { - const detail = detailFromResult(version); - return buildServerProvider({ - provider: PROVIDER, - enabled: cursorSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "error", - authStatus: "unknown", - message: detail - ? `Cursor Agent CLI is installed but failed to run. ${detail}` - : "Cursor Agent CLI is installed but failed to run.", - }, - }); - } - - const authProbe = yield* runCursorCommand(["login", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); - - if (Result.isFailure(authProbe)) { - const error = authProbe.failure; - return buildServerProvider({ - provider: PROVIDER, - enabled: cursorSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - authStatus: "unknown", - message: - error instanceof Error - ? `Could not verify Cursor Agent authentication status: ${error.message}.` - : "Could not verify Cursor Agent authentication status.", - }, - }); - } - - if (Option.isNone(authProbe.success)) { - return buildServerProvider({ - provider: PROVIDER, - enabled: cursorSettings.enabled, - checkedAt, - models, - probe: { - installed: true, - version: parsedVersion, - status: "warning", - authStatus: "unknown", - message: - "Could not verify Cursor Agent authentication status. Timed out while running command.", + message: "Cursor Agent CLI is installed but timed out while running `agent about`.", }, }); } - const parsed = parseCursorAuthStatusFromOutput(authProbe.success.value); + const parsed = parseCursorAboutOutput(aboutProbe.success.value); return buildServerProvider({ provider: PROVIDER, enabled: cursorSettings.enabled, @@ -404,7 +357,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( models, probe: { installed: true, - version: parsedVersion, + version: parsed.version, status: parsed.status, authStatus: parsed.authStatus, ...(parsed.message ? { message: parsed.message } : {}), diff --git a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts index 3546ad9ed359..deb86f99c04d 100644 --- a/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/CursorAcpCliProbe.test.ts @@ -55,6 +55,7 @@ describe.runIf(process.env.T3_CURSOR_ACP_PROBE === "1")("Cursor ACP CLI probe", cwd: process.cwd(), mcpServers: [], }); + console.log("session/new result:", JSON.stringify(result, null, 2)); expect(isRecord(result)).toBe(true); const r = result as Record; From a07ad3a632e0349dab74a8306a3d38d6bf60af66 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 16:24:27 -0700 Subject: [PATCH 08/42] rm unused probe --- .../cursor-acp-model-selection-probe.ts | 64 ------------------- 1 file changed, 64 deletions(-) delete mode 100644 apps/server/scripts/cursor-acp-model-selection-probe.ts diff --git a/apps/server/scripts/cursor-acp-model-selection-probe.ts b/apps/server/scripts/cursor-acp-model-selection-probe.ts deleted file mode 100644 index c8fef3c7d074..000000000000 --- a/apps/server/scripts/cursor-acp-model-selection-probe.ts +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bun -/** - * Standalone probe: spawn `agent acp`, send initialize → session/new, print results. - * - * Usage: - * bun apps/server/scripts/cursor-acp-model-selection-probe.ts [--cwd /path/to/dir] - */ -import { parseArgs } from "node:util"; - -import { - attachAcpJsonRpcConnection, - disposeAcpChild, - spawnAcpChildProcess, -} from "../src/provider/acp/AcpJsonRpcConnection.ts"; -import { Effect } from "effect"; - -const { values } = parseArgs({ - options: { - cwd: { type: "string", default: process.cwd() }, - }, - strict: true, -}); - -const cwd = values.cwd!; - -const program = Effect.gen(function* () { - const child = yield* spawnAcpChildProcess({ command: "agent", args: ["acp"], cwd }); - - try { - const conn = yield* attachAcpJsonRpcConnection(child); - - console.log("→ initialize"); - const initResult = yield* conn.request("initialize", { - protocolVersion: 1, - clientCapabilities: { - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - }, - clientInfo: { name: "cursor-acp-probe", version: "0.0.0" }, - }); - console.log("← initialize response:"); - console.log(JSON.stringify(initResult, null, 2)); - - console.log("\n→ authenticate"); - const authResult = yield* conn.request("authenticate", { methodId: "cursor_login" }); - console.log("← authenticate response:"); - console.log(JSON.stringify(authResult, null, 2)); - - console.log(`\n→ session/new (cwd: ${cwd})`); - const sessionResult = yield* conn.request("session/new", { - cwd, - mcpServers: [], - }); - console.log("← session/new response:"); - console.log(JSON.stringify(sessionResult, null, 2)); - } finally { - disposeAcpChild(child); - } -}); - -Effect.runPromise(program).catch((err) => { - console.error("Probe failed:", err); - process.exit(1); -}); From 87668ec2b78e68e497720ac943ef7a40a49da419 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 18:04:41 -0700 Subject: [PATCH 09/42] Normalize provider model IDs and Cursor options - Canonicalize Claude and Cursor dispatch model slugs - Update provider model selection, defaults, and tests --- apps/server/scripts/acp-mock-agent.mjs | 315 ++++++++ .../src/git/Layers/ClaudeTextGeneration.ts | 45 +- .../Layers/ProviderCommandReactor.test.ts | 8 +- .../src/provider/Layers/ClaudeAdapter.ts | 228 +++++- .../src/provider/Layers/ClaudeModelId.test.ts | 28 + .../src/provider/Layers/ClaudeModelId.ts | 10 + .../src/provider/Layers/CursorAdapter.test.ts | 9 +- .../src/provider/Layers/CursorAdapter.ts | 2 +- .../provider/Layers/CursorProvider.test.ts | 698 +----------------- .../src/provider/Layers/CursorProvider.ts | 98 ++- .../CompactComposerControlsMenu.browser.tsx | 2 +- .../chat/ProviderModelPicker.browser.tsx | 8 +- .../chat/composerProviderRegistry.test.tsx | 6 +- apps/web/src/composerDraftStore.ts | 13 +- apps/web/src/modelSelection.ts | 2 +- apps/web/src/providerModels.ts | 2 - apps/web/src/routes/_chat.settings.tsx | 2 +- packages/contracts/src/cursorCliModels.json | 343 --------- packages/contracts/src/model.ts | 85 +-- packages/contracts/src/settings.ts | 2 +- packages/shared/src/model.test.ts | 307 +------- packages/shared/src/model.ts | 670 +---------------- 22 files changed, 774 insertions(+), 2109 deletions(-) create mode 100644 apps/server/scripts/acp-mock-agent.mjs create mode 100644 apps/server/src/provider/Layers/ClaudeModelId.test.ts create mode 100644 apps/server/src/provider/Layers/ClaudeModelId.ts delete mode 100644 packages/contracts/src/cursorCliModels.json diff --git a/apps/server/scripts/acp-mock-agent.mjs b/apps/server/scripts/acp-mock-agent.mjs new file mode 100644 index 000000000000..c828cc453d68 --- /dev/null +++ b/apps/server/scripts/acp-mock-agent.mjs @@ -0,0 +1,315 @@ +#!/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 currentModelId = "default"; +let nextRequestId = 1; + +function configOptions() { + return [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: [ + { value: "default", name: "Auto" }, + { value: "composer-2", name: "Composer 2" }, + { value: "composer-2[fast=true]", name: "Composer 2 Fast" }, + { value: "gpt-5.3-codex[reasoning=medium,fast=false]", name: "Codex 5.3" }, + ], + }, + ]; +} + +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(), + configOptions: configOptions(), + }, + }); + 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(), + configOptions: configOptions(), + }, + }); + return; + } + + if (method === "session/set_config_option" && id !== undefined) { + const configId = msg.params?.configId; + const value = msg.params?.value; + if (configId === "model" && typeof value === "string") { + currentModelId = value; + } + send({ + jsonrpc: "2.0", + id, + result: { configOptions: configOptions() }, + }); + 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/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 97e18c3e7896..6cd8447df2ff 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -28,8 +28,8 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { normalizeClaudeModelOptionsWithCapabilities } from "@t3tools/shared/model"; -import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts"; +import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeModelId.ts"; +import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; @@ -100,26 +100,27 @@ const makeClaudeTextGeneration = Effect.gen(function* () { (settings) => settings.providers.claudeAgent, ).pipe(Effect.catch(() => Effect.undefined)); - const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { - const command = ChildProcess.make( - claudeSettings?.binaryPath || "claude", - [ - "-p", - "--output-format", - "json", - "--json-schema", - jsonSchemaStr, - "--model", - resolveClaudeApiModelId(modelSelection), - ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), - ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), - "--dangerously-skip-permissions", - ], - { - cwd, - shell: process.platform === "win32", - stdin: { - stream: Stream.encodeText(Stream.make(prompt)), + const runClaudeCommand = Effect.gen(function* () { + const command = ChildProcess.make( + claudeSettings?.binaryPath || "claude", + [ + "-p", + "--output-format", + "json", + "--json-schema", + jsonSchemaStr, + "--model", + resolveClaudeApiModelId(modelSelection), + ...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []), + ...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []), + "--dangerously-skip-permissions", + ], + { + cwd, + shell: process.platform === "win32", + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, }, }, ); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 7ccc2f35ed89..6647209ca45d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1104,7 +1104,11 @@ describe("ProviderCommandReactor", () => { text: "second cursor turn", attachments: [], }, - modelSelection: { provider: "cursor", model: "composer-2-fast" }, + modelSelection: { + provider: "cursor", + model: "composer-2", + options: { fastMode: true }, + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, runtimeMode: "approval-required", createdAt: now, @@ -1115,7 +1119,7 @@ describe("ProviderCommandReactor", () => { expect(harness.startSession.mock.calls.length).toBe(1); expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ - modelSelection: { provider: "cursor", model: "composer-2-fast" }, + modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, }); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 81980acb9b1f..f2e895f6420e 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -61,7 +61,8 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts"; +import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; +import { resolveClaudeApiModelId } from "./ClaudeModelId.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -1235,10 +1236,167 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( entry.block.fallbackText = text; } - if (entry.block.streamClosed && !entry.block.completionEmitted) { - yield* completeAssistantTextBlock(context, entry.block, { - rawMethod: "claude/assistant", - rawPayload: message, + const requestedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.opened", + eventId: requestedStamp.eventId, + provider: PROVIDER, + createdAt: requestedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + detail, + args: { + toolName, + input: toolInput, + ...(callbackOptions.toolUseID ? { toolUseId: callbackOptions.toolUseID } : {}), + }, + }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + raw: { + source: "claude.sdk.permission", + method: "canUseTool/request", + payload: { + toolName, + input: toolInput, + }, + }, + }); + + pendingApprovals.set(requestId, pendingApproval); + + const onAbort = () => { + if (!pendingApprovals.has(requestId)) { + return; + } + pendingApprovals.delete(requestId); + Effect.runFork(Deferred.succeed(decisionDeferred, "cancel")); + }; + + callbackOptions.signal.addEventListener("abort", onAbort, { + once: true, + }); + + const decision = yield* Deferred.await(decisionDeferred); + pendingApprovals.delete(requestId); + + const resolvedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "request.resolved", + eventId: resolvedStamp.eventId, + provider: PROVIDER, + createdAt: resolvedStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState + ? { turnId: asCanonicalTurnId(context.turnState.turnId) } + : {}), + requestId: asRuntimeRequestId(requestId), + payload: { + requestType, + decision, + }, + providerRefs: nativeProviderRefs(context, { + providerItemId: callbackOptions.toolUseID, + }), + raw: { + source: "claude.sdk.permission", + method: "canUseTool/decision", + payload: { + decision, + }, + }, + }); + + if (decision === "accept" || decision === "acceptForSession") { + return { + behavior: "allow", + updatedInput: toolInput, + ...(decision === "acceptForSession" && pendingApproval.suggestions + ? { updatedPermissions: [...pendingApproval.suggestions] } + : {}), + } satisfies PermissionResult; + } + + return { + behavior: "deny", + message: + decision === "cancel" + ? "User cancelled tool execution." + : "User declined tool execution.", + } satisfies PermissionResult; + }), + ); + + const claudeSettings = yield* serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.providers.claudeAgent), + Effect.mapError( + (error) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: error.message, + cause: error, + }), + ), + ); + const claudeBinaryPath = claudeSettings.binaryPath; + const modelSelection = + input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + const caps = getClaudeModelCapabilities(modelSelection?.model); + const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; + const effort = (resolveEffort(caps, modelSelection?.options?.effort) ?? + null) as ClaudeCodeEffort | null; + const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; + const thinking = + typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle + ? modelSelection.options.thinking + : undefined; + const effectiveEffort = getEffectiveClaudeCodeEffort(effort); + const permissionMode = + input.runtimeMode === "full-access" ? "bypassPermissions" : undefined; + const settings = { + ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), + ...(fastMode ? { fastMode: true } : {}), + }; + + const queryOptions: ClaudeQueryOptions = { + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(apiModelId ? { model: apiModelId } : {}), + pathToClaudeCodeExecutable: claudeBinaryPath, + settingSources: [...CLAUDE_SETTING_SOURCES], + ...(effectiveEffort ? { effort: effectiveEffort } : {}), + ...(permissionMode ? { permissionMode } : {}), + ...(permissionMode === "bypassPermissions" + ? { allowDangerouslySkipPermissions: true } + : {}), + ...(Object.keys(settings).length > 0 ? { settings } : {}), + ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), + ...(newSessionId ? { sessionId: newSessionId } : {}), + includePartialMessages: true, + canUseTool, + env: process.env, + ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), + }; + + const queryRuntime = yield* Effect.try({ + try: () => + createQuery({ + prompt, + options: queryOptions, + }), + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: toMessage(cause, "Failed to start Claude runtime session."), + cause, + }), }); } } @@ -1711,11 +1869,61 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - if (event.type === "content_block_start") { - const { index, content_block: block } = event; - if (block.type === "text") { - yield* ensureAssistantTextBlock(context, index, { - fallbackText: extractContentBlockText(block), + if (modelSelection?.model) { + const apiModelId = resolveClaudeApiModelId(modelSelection); + yield* Effect.tryPromise({ + try: () => context.query.setModel(apiModelId), + catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), + }); + } + + // Apply interaction mode by switching the SDK's permission mode. + // "plan" maps directly to the SDK's "plan" permission mode; + // "default" restores the session's original permission mode. + // When interactionMode is absent we leave the current mode unchanged. + if (input.interactionMode === "plan") { + yield* Effect.tryPromise({ + try: () => context.query.setPermissionMode("plan"), + catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), + }); + } else if (input.interactionMode === "default") { + yield* Effect.tryPromise({ + try: () => + context.query.setPermissionMode(context.basePermissionMode ?? "bypassPermissions"), + catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), + }); + } + + const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4); + const turnState: ClaudeTurnState = { + turnId, + startedAt: yield* nowIso, + items: [], + assistantTextBlocks: new Map(), + assistantTextBlockOrder: [], + capturedProposedPlanKeys: new Set(), + nextSyntheticAssistantBlockIndex: -1, + }; + + const updatedAt = yield* nowIso; + context.turnState = turnState; + context.session = { + ...context.session, + status: "running", + activeTurnId: turnId, + updatedAt, + }; + + const turnStartedStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "turn.started", + eventId: turnStartedStamp.eventId, + provider: PROVIDER, + createdAt: turnStartedStamp.createdAt, + threadId: context.session.threadId, + turnId, + payload: modelSelection?.model ? { model: modelSelection.model } : {}, + providerRefs: {}, }); return; } diff --git a/apps/server/src/provider/Layers/ClaudeModelId.test.ts b/apps/server/src/provider/Layers/ClaudeModelId.test.ts new file mode 100644 index 000000000000..5102047fc7d7 --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeModelId.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; + +import { resolveClaudeApiModelId } from "./ClaudeModelId.ts"; + +describe("resolveClaudeApiModelId", () => { + it("appends [1m] for 1m context window", () => { + expect( + resolveClaudeApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "1m" }, + }), + ).toBe("claude-opus-4-6[1m]"); + }); + + it("returns the canonical slug for default context windows", () => { + expect( + resolveClaudeApiModelId({ + provider: "claudeAgent", + model: "claude-opus-4-6", + options: { contextWindow: "200k" }, + }), + ).toBe("claude-opus-4-6"); + expect(resolveClaudeApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( + "claude-opus-4-6", + ); + }); +}); diff --git a/apps/server/src/provider/Layers/ClaudeModelId.ts b/apps/server/src/provider/Layers/ClaudeModelId.ts new file mode 100644 index 000000000000..a4371c80a51f --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeModelId.ts @@ -0,0 +1,10 @@ +import type { ClaudeModelSelection } from "@t3tools/contracts"; + +export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { + switch (modelSelection.options?.contextWindow) { + case "1m": + return `${modelSelection.model}[1m]`; + default: + return modelSelection.model; + } +} diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 12fde586042a..b97872b0336b 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -8,12 +8,12 @@ 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 { ServerSettingsService } from "../../serverSettings.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { makeCursorAdapterLive } from "./CursorAdapter.ts"; +import { resolveCursorDispatchModel } from "./CursorProvider.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); @@ -465,7 +465,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); - assert.deepStrictEqual(argvRuns[0], ["--model", "composer-2", "acp"]); + assert.deepStrictEqual(argvRuns[0], ["--model", "composer-2[fast=false]", "acp"]); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const setConfigRequests = requests.filter( @@ -473,7 +473,10 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ); assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); const lastSetConfig = setConfigRequests[setConfigRequests.length - 1]; - assert.equal((lastSetConfig?.params as Record)?.value, "composer-2-fast"); + assert.equal( + (lastSetConfig?.params as Record)?.value, + "composer-2[fast=true]", + ); yield* adapter.stopSession(threadId); }), diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 5614ca59c64d..28c78e4b6883 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -22,7 +22,6 @@ import { TurnId, type UserInputQuestion, } from "@t3tools/contracts"; -import { resolveCursorDispatchModel } from "@t3tools/shared/model"; import { Cause, DateTime, @@ -57,6 +56,7 @@ import { 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 { resolveCursorDispatchModel } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "cursor" as const; diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index be90e3c85690..3f5094b82ed0 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1,687 +1,47 @@ -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 { Effect } from "effect"; import { describe, expect, it } from "vitest"; -import type * as EffectAcpSchema from "effect-acp/schema"; -import type { CursorSettings, ServerProviderModel } from "@t3tools/contracts"; - -import { - buildCursorProviderSnapshot, - buildCursorCapabilitiesFromConfigOptions, - buildCursorDiscoveredModelsFromConfigOptions, - discoverCursorModelCapabilitiesViaAcp, - discoverCursorModelsViaAcp, - getCursorFallbackModels, - getCursorParameterizedModelPickerUnsupportedMessage, - parseCursorAboutOutput, - parseCursorCliConfigChannel, - parseCursorVersionDate, - resolveCursorAcpBaseModelId, - resolveCursorAcpConfigUpdates, -} from "./CursorProvider.ts"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); - -async function makeMockAgentWrapper(extraEnv?: Record) { - const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-mock-")); - 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 -${envExports} -exec ${JSON.stringify("bun")} ${JSON.stringify(mockAgentPath)} "$@" -`; - await writeFile(wrapperPath, script, "utf8"); - await chmod(wrapperPath, 0o755); - return wrapperPath; -} - -async function waitForFileContent(filePath: string, attempts = 40): Promise { - for (let attempt = 0; attempt < attempts; attempt += 1) { - try { - const content = await readFile(filePath, "utf8"); - if (content.trim().length > 0) { - return content; - } - } catch {} - await new Promise((resolve) => setTimeout(resolve, 50)); - } - throw new Error(`Timed out waiting for file content at ${filePath}`); -} - -const parameterizedGpt54ConfigOptions = [ - { - type: "select", - currentValue: "gpt-5.4-medium-fast", - options: [{ name: "GPT-5.4", value: "gpt-5.4-medium-fast" }], - category: "model", - id: "model", - name: "Model", - }, - { - type: "select", - currentValue: "medium", - options: [ - { name: "None", value: "none" }, - { name: "Low", value: "low" }, - { name: "Medium", value: "medium" }, - { name: "High", value: "high" }, - { name: "Extra High", value: "extra-high" }, - ], - category: "thought_level", - id: "reasoning", - name: "Reasoning", - }, - { - type: "select", - currentValue: "272k", - options: [ - { name: "272K", value: "272k" }, - { name: "1M", value: "1m" }, - ], - category: "model_config", - id: "context", - name: "Context", - }, - { - type: "select", - currentValue: "false", - options: [ - { name: "Off", value: "false" }, - { name: "Fast", value: "true" }, - ], - category: "model_config", - id: "fast", - name: "Fast", - }, -] satisfies ReadonlyArray; - -const parameterizedClaudeConfigOptions = [ - { - type: "select", - currentValue: "claude-4.6-opus-high-thinking", - options: [{ name: "Opus 4.6", value: "claude-4.6-opus-high-thinking" }], - category: "model", - id: "model", - name: "Model", - }, - { - type: "select", - currentValue: "high", - options: [ - { name: "Low", value: "low" }, - { name: "Medium", value: "medium" }, - { name: "High", value: "high" }, - ], - category: "thought_level", - id: "reasoning", - name: "Reasoning", - }, - { - type: "boolean", - currentValue: true, - category: "model_config", - id: "thinking", - name: "Thinking", - }, -] satisfies ReadonlyArray; - -const parameterizedClaudeModelOptionConfigOptions = [ - { - type: "select", - currentValue: "claude-opus-4-6", - options: [{ name: "Opus 4.6", value: "claude-opus-4-6" }], - category: "model", - id: "model", - name: "Model", - }, - { - type: "select", - currentValue: "high", - options: [ - { name: "Low", value: "low" }, - { name: "Medium", value: "medium" }, - { name: "High", value: "high" }, - ], - category: "thought_level", - id: "reasoning", - name: "Reasoning", - }, - { - type: "select", - currentValue: "max", - options: [ - { name: "Low", value: "low" }, - { name: "Medium", value: "medium" }, - { name: "High", value: "high" }, - { name: "Max", value: "max" }, - ], - category: "model_option", - id: "effort", - name: "Effort", - }, - { - type: "select", - currentValue: "true", - options: [ - { name: "Off", value: "false" }, - { name: "Fast", value: "true" }, - ], - category: "model_config", - id: "fast", - name: "Fast", - }, - { - type: "select", - currentValue: "true", - options: [ - { name: "Off", value: "false" }, - { name: ":icon-brain:", value: "true" }, - ], - category: "model_config", - id: "thinking", - name: "Thinking", - }, -] satisfies ReadonlyArray; - -const sessionNewCursorConfigOptions = [ - { - type: "select", - currentValue: "agent", - options: [ - { name: "Agent", value: "agent", description: "Full agent capabilities with tool access" }, - ], - category: "mode", - id: "mode", - name: "Mode", - description: "Controls how the agent executes tasks", - }, - { - type: "select", - currentValue: "composer-2", - options: [ - { name: "Auto", value: "default" }, - { name: "Composer 2", value: "composer-2" }, - { name: "GPT-5.4", value: "gpt-5.4" }, - { name: "Sonnet 4.6", value: "claude-sonnet-4-6" }, - { name: "Opus 4.6", value: "claude-opus-4-6" }, - { name: "Codex 5.3 Spark", value: "gpt-5.3-codex-spark" }, - ], - category: "model", - id: "model", - name: "Model", - description: "Controls which model is used for responses", - }, - { - type: "select", - currentValue: "true", - options: [ - { name: "Off", value: "false" }, - { name: "Fast", value: "true" }, - ], - category: "model_config", - id: "fast", - name: "Fast", - description: "Faster speeds.", - }, -] satisfies ReadonlyArray; - -const baseCursorSettings: CursorSettings = { - enabled: true, - binaryPath: "agent", - apiEndpoint: "", - customModels: [], -}; - -const emptyCapabilities = { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], -} as const; - -describe("getCursorFallbackModels", () => { - it("does not publish any built-in cursor models before ACP discovery", () => { - expect( - getCursorFallbackModels({ - customModels: ["internal/cursor-model"], - }).map((model) => model.slug), - ).toEqual(["internal/cursor-model"]); - }); -}); - -describe("buildCursorProviderSnapshot", () => { - it("downgrades ready status to warning when ACP model discovery times out", () => { - expect( - buildCursorProviderSnapshot({ - checkedAt: "2026-01-01T00:00:00.000Z", - cursorSettings: baseCursorSettings, - parsed: { - version: "2026.04.09-f2b0fcd", - status: "ready", - auth: { status: "authenticated", type: "Team", label: "Cursor Team Subscription" }, - }, - discoveryWarning: "Cursor ACP model discovery timed out after 15000ms.", - }), - ).toMatchObject({ - status: "warning", - message: "Cursor ACP model discovery timed out after 15000ms.", - models: [], - }); - }); - - it("preserves provider error state while appending discovery warnings", () => { - expect( - buildCursorProviderSnapshot({ - checkedAt: "2026-01-01T00:00:00.000Z", - cursorSettings: { - ...baseCursorSettings, - customModels: ["claude-sonnet-4-6"], - }, - parsed: { - version: "2026.04.09-f2b0fcd", - status: "error", - auth: { status: "unauthenticated" }, - message: "Cursor Agent is not authenticated. Run `agent login` and try again.", - }, - discoveryWarning: "Cursor ACP model discovery failed. Check server logs for details.", - }), - ).toMatchObject({ - status: "error", - message: - "Cursor Agent is not authenticated. Run `agent login` and try again. Cursor ACP model discovery failed. Check server logs for details.", - models: [ - { - slug: "claude-sonnet-4-6", - isCustom: true, - }, - ], - }); - }); -}); -describe("buildCursorCapabilitiesFromConfigOptions", () => { - it("derives model capabilities from parameterized Cursor ACP config options", () => { - expect(buildCursorCapabilitiesFromConfigOptions(parameterizedGpt54ConfigOptions)).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "272k", label: "272K", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: [], - }); - }); - - it("detects boolean thinking toggles from model_config options", () => { - expect(buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeConfigOptions)).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - ], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }); - }); - - it("prefers the newer model_option effort control over legacy thought_level", () => { - expect( - buildCursorCapabilitiesFromConfigOptions(parameterizedClaudeModelOptionConfigOptions), - ).toEqual({ - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - { value: "max", label: "Max", isDefault: true }, - ], - supportsFastMode: true, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }); - }); -}); - -describe("buildCursorDiscoveredModelsFromConfigOptions", () => { - it("publishes ACP model choices immediately from session/new config options", () => { - expect(buildCursorDiscoveredModelsFromConfigOptions(sessionNewCursorConfigOptions)).toEqual([ - { - slug: "default", - name: "Auto", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "composer-2", - name: "Composer 2", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-sonnet-4-6", - name: "Sonnet 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex-spark", - name: "Codex 5.3 Spark", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ]); - }); -}); - -describe("discoverCursorModelsViaAcp", () => { - it("keeps the ACP probe runtime alive long enough to discover models", async () => { - const wrapperPath = await makeMockAgentWrapper(); +import { getCursorModelCapabilities, resolveCursorDispatchModel } from "./CursorProvider.ts"; - const models = await Effect.runPromise( - discoverCursorModelsViaAcp({ - enabled: true, - binaryPath: wrapperPath, - apiEndpoint: "", - customModels: [], - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), +describe("resolveCursorDispatchModel", () => { + it("builds bracket notation from canonical base slugs and capabilities", () => { + expect(resolveCursorDispatchModel("composer-2", { fastMode: true })).toBe( + "composer-2[fast=true]", ); - - expect(models.map((model) => model.slug)).toEqual([ - "default", - "composer-2", - "gpt-5.4", - "claude-opus-4-6", - ]); - }); - - it("closes the ACP probe runtime after discovery completes", async () => { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-provider-exit-log-")); - const exitLogPath = path.join(tempDir, "exit.log"); - const wrapperPath = await makeMockAgentWrapper({ - T3_ACP_EXIT_LOG_PATH: exitLogPath, - }); - - await Effect.runPromise( - discoverCursorModelsViaAcp({ - enabled: true, - binaryPath: wrapperPath, - apiEndpoint: "", - customModels: [], - }).pipe(Effect.provide(NodeServices.layer)), - ); - - const exitLog = await waitForFileContent(exitLogPath); - expect(exitLog).toContain("SIGTERM"); - }); -}); - -describe("discoverCursorModelCapabilitiesViaAcp", () => { - it("closes all ACP probe runtimes after capability enrichment completes", async () => { - const tempDir = await mkdtemp(path.join(os.tmpdir(), "cursor-capabilities-exit-log-")); - const exitLogPath = path.join(tempDir, "exit.log"); - const wrapperPath = await makeMockAgentWrapper({ - T3_ACP_EXIT_LOG_PATH: exitLogPath, - }); - const existingModels: ReadonlyArray = [ - { slug: "default", name: "Auto", isCustom: false, capabilities: emptyCapabilities }, - { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: emptyCapabilities }, - { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, capabilities: emptyCapabilities }, - { - slug: "claude-opus-4-6", - name: "Opus 4.6", - isCustom: false, - capabilities: emptyCapabilities, - }, - ]; - - const models = await Effect.runPromise( - discoverCursorModelCapabilitiesViaAcp( - { - enabled: true, - binaryPath: wrapperPath, - apiEndpoint: "", - customModels: [], - }, - existingModels, - ).pipe(Effect.provide(NodeServices.layer)), + expect(resolveCursorDispatchModel("gpt-5.4", undefined)).toBe( + "gpt-5.4[reasoning=medium,context=272k,fast=false]", ); - - expect(models.map((model) => model.slug)).toEqual([ - "default", - "composer-2", - "gpt-5.4", - "claude-opus-4-6", - ]); - - const exitLog = await waitForFileContent(exitLogPath); - expect(exitLog.match(/SIGTERM/g)?.length ?? 0).toBe(4); - }); -}); - -describe("parseCursorAboutOutput", () => { - it("parses json about output and forwards subscription metadata", () => { - expect( - parseCursorAboutOutput({ - code: 0, - stdout: JSON.stringify({ - cliVersion: "2026.04.09-f2b0fcd", - subscriptionTier: "Team", - userEmail: "jmarminge@gmail.com", - }), - stderr: "", - }), - ).toEqual({ - version: "2026.04.09-f2b0fcd", - status: "ready", - auth: { - status: "authenticated", - type: "Team", - label: "Cursor Team Subscription", - }, - }); - }); - - it("treats json about output with a logged-out email as unauthenticated", () => { - expect( - parseCursorAboutOutput({ - code: 0, - stdout: JSON.stringify({ - cliVersion: "2026.04.09-f2b0fcd", - subscriptionTier: "Team", - userEmail: "Not logged in", - }), - stderr: "", - }), - ).toEqual({ - version: "2026.04.09-f2b0fcd", - status: "error", - auth: { - status: "unauthenticated", - }, - message: "Cursor Agent is not authenticated. Run `agent login` and try again.", - }); - }); - - it("treats json about output with a null email as unauthenticated", () => { - expect( - parseCursorAboutOutput({ - code: 0, - stdout: JSON.stringify({ - cliVersion: "2026.04.09-f2b0fcd", - subscriptionTier: null, - userEmail: null, - }), - stderr: "", - }), - ).toEqual({ - version: "2026.04.09-f2b0fcd", - status: "error", - auth: { - status: "unauthenticated", - }, - message: "Cursor Agent is not authenticated. Run `agent login` and try again.", - }); - }); -}); - -describe("Cursor parameterized model picker preview gating", () => { - it("parses Cursor CLI version dates from build versions", () => { - expect(parseCursorVersionDate("2026.04.08-c4e73a3")).toBe(20260408); - expect(parseCursorVersionDate("2026.04.09")).toBe(20260409); - expect(parseCursorVersionDate("not-a-version")).toBeUndefined(); - }); - - it("parses the Cursor CLI channel from cli-config.json", () => { - expect(parseCursorCliConfigChannel('{ "channel": "lab" }')).toBe("lab"); - expect(parseCursorCliConfigChannel('{ "channel": "stable" }')).toBe("stable"); - expect(parseCursorCliConfigChannel('{ "version": 1 }')).toBeUndefined(); - expect(parseCursorCliConfigChannel("not-json")).toBeUndefined(); - }); - - it("returns no warning when the preview requirements are met", () => { - expect( - getCursorParameterizedModelPickerUnsupportedMessage({ - version: "2026.04.08-c4e73a3", - channel: "lab", - }), - ).toBeUndefined(); - }); - - it("explains when the Cursor Agent version is too old", () => { expect( - getCursorParameterizedModelPickerUnsupportedMessage({ - version: "2026.04.07-c4e73a3", - channel: "lab", + resolveCursorDispatchModel("claude-opus-4-6", { + reasoning: "high", + thinking: true, + contextWindow: "1m", }), - ).toContain("too old"); + ).toBe("claude-opus-4-6[effort=high,thinking=true,context=1m]"); }); - it("explains when the Cursor Agent channel is not lab", () => { - expect( - getCursorParameterizedModelPickerUnsupportedMessage({ - version: "2026.04.08-c4e73a3", - channel: "stable", - }), - ).toContain("lab channel"); + it("maps legacy cursor aliases onto the canonical base slug", () => { + expect(resolveCursorDispatchModel("gpt-5.4-1m", undefined)).toBe( + "gpt-5.4[reasoning=medium,context=272k,fast=false]", + ); + expect(resolveCursorDispatchModel("auto", undefined)).toBe("default[]"); + expect(resolveCursorDispatchModel("claude-4.6-opus", undefined)).toBe( + "claude-opus-4-6[effort=high,thinking=true,context=200k]", + ); }); -}); -describe("resolveCursorAcpBaseModelId", () => { - it("drops bracket traits without rewriting raw ACP model ids", () => { - expect(resolveCursorAcpBaseModelId("gpt-5.4[reasoning=medium,context=272k]")).toBe("gpt-5.4"); - expect(resolveCursorAcpBaseModelId("gpt-5.4-medium-fast")).toBe("gpt-5.4-medium-fast"); - expect(resolveCursorAcpBaseModelId("claude-4.6-opus-high-thinking")).toBe( - "claude-4.6-opus-high-thinking", + it("passes custom models through unchanged", () => { + expect(resolveCursorDispatchModel("custom/internal-model", undefined)).toBe( + "custom/internal-model[]", ); - expect(resolveCursorAcpBaseModelId("composer-2")).toBe("composer-2"); - expect(resolveCursorAcpBaseModelId("auto")).toBe("auto"); }); }); -describe("resolveCursorAcpConfigUpdates", () => { - it("maps Cursor model options onto separate ACP config option updates", () => { - expect( - resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { - reasoning: "xhigh", - fastMode: true, - contextWindow: "1m", - }), - ).toEqual([ - { configId: "reasoning", value: "extra-high" }, - { configId: "context", value: "1m" }, - { configId: "fast", value: "true" }, - ]); - }); - - it("maps boolean thinking toggles when the model exposes them separately", () => { - expect( - resolveCursorAcpConfigUpdates(parameterizedClaudeConfigOptions, { - thinking: false, - }), - ).toEqual([{ configId: "thinking", value: false }]); - }); - - it("maps explicit fastMode: false so the adapter can clear a prior fast selection", () => { - expect( - resolveCursorAcpConfigUpdates(parameterizedGpt54ConfigOptions, { - fastMode: false, - }), - ).toEqual([{ configId: "fast", value: "false" }]); - }); - - it("writes Cursor effort changes through the newer model_option config when available", () => { - expect( - resolveCursorAcpConfigUpdates(parameterizedClaudeModelOptionConfigOptions, { - reasoning: "max", - thinking: false, - }), - ).toEqual([ - { configId: "effort", value: "max" }, - { configId: "thinking", value: "false" }, +describe("getCursorModelCapabilities", () => { + it("resolves capabilities from canonical cursor base slugs", () => { + expect(getCursorModelCapabilities("gpt-5.4").contextWindowOptions).toEqual([ + { value: "272k", label: "272k", isDefault: true }, + { value: "1m", label: "1M" }, ]); + expect(getCursorModelCapabilities("claude-opus-4-6").supportsThinkingToggle).toBe(true); }); }); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index da66a905d77b..63b68accb140 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -1,4 +1,5 @@ import type { + CursorModelOptions, CursorSettings, ModelCapabilities, ServerProvider, @@ -6,6 +7,7 @@ import type { ServerProviderAuthStatus, ServerProviderState, } from "@t3tools/contracts"; +import { normalizeModelSlug, resolveContextWindow, resolveEffort } from "@t3tools/shared/model"; import { Effect, Equal, Layer, Option, Result, Stream } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -21,9 +23,16 @@ import { CursorProvider } from "../Services/CursorProvider"; import { ServerSettingsError, ServerSettingsService } from "../../serverSettings"; const PROVIDER = "cursor" as const; +const EMPTY_CAPABILITIES: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], +}; const BUILT_IN_MODELS: ReadonlyArray = [ { - slug: "auto", + slug: "default", name: "Auto", isCustom: false, capabilities: { @@ -62,7 +71,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ capabilities: { reasoningEffortLevels: [ { value: "low", label: "Low" }, - { value: "normal", label: "Normal", isDefault: true }, + { value: "medium", label: "Medium", isDefault: true }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra High" }, ], @@ -72,13 +81,13 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, }, { - slug: "gpt-5.3-codex-spark-preview", + slug: "gpt-5.3-codex-spark", name: "Codex 5.3 Spark", isCustom: false, capabilities: { reasoningEffortLevels: [ { value: "low", label: "Low" }, - { value: "normal", label: "Normal", isDefault: true }, + { value: "medium", label: "Medium", isDefault: true }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra High" }, ], @@ -88,38 +97,54 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, }, { - slug: "gpt-5.4-1m", - name: "GPT 5.4", + slug: "gpt-5.4", + name: "GPT-5.4", isCustom: false, capabilities: { reasoningEffortLevels: [ { value: "low", label: "Low" }, - { value: "normal", label: "Normal", isDefault: true }, + { value: "medium", label: "Medium", isDefault: true }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra High" }, ], supportsFastMode: true, supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "272k", label: "272k", isDefault: true }, + { value: "1m", label: "1M" }, + ], promptInjectedEffortLevels: [], }, }, { - slug: "claude-4.6-opus", - name: "Claude Opus 4.6", + slug: "claude-opus-4-6", + name: "Opus 4.6", isCustom: false, capabilities: { - reasoningEffortLevels: [], + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + ], supportsFastMode: false, supportsThinkingToggle: true, + contextWindowOptions: [ + { value: "200k", label: "200k", isDefault: true }, + { value: "1m", label: "1M" }, + ], promptInjectedEffortLevels: [], }, }, { - slug: "claude-4.6-sonnet", - name: "Claude Sonnet 4.6", + slug: "claude-sonnet-4-6", + name: "Sonnet 4.6", isCustom: false, capabilities: { - reasoningEffortLevels: [], + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + ], supportsFastMode: false, supportsThinkingToggle: true, promptInjectedEffortLevels: [], @@ -139,17 +164,49 @@ const BUILT_IN_MODELS: ReadonlyArray = [ ]; export function getCursorModelCapabilities(model: string | null | undefined): ModelCapabilities { - const slug = model?.trim(); + const slug = normalizeModelSlug(model, "cursor"); return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - } + BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? EMPTY_CAPABILITIES ); } +export function resolveCursorDispatchModel( + model: string | null | undefined, + modelOptions: CursorModelOptions | null | undefined, +): string { + const slug = normalizeModelSlug(model, "cursor") ?? "default"; + if (slug.includes("[") && slug.endsWith("]")) { + return slug; + } + const caps = getCursorModelCapabilities(slug); + const isBuiltIn = BUILT_IN_MODELS.some((candidate) => candidate.slug === slug); + if (!isBuiltIn) { + return `${slug}[]`; + } + + const traits: string[] = []; + const reasoning = resolveEffort(caps, modelOptions?.reasoning); + if (reasoning) { + traits.push(`${slug.startsWith("claude-") ? "effort" : "reasoning"}=${reasoning}`); + } + + const thinking = caps.supportsThinkingToggle ? (modelOptions?.thinking ?? true) : undefined; + if (thinking !== undefined) { + traits.push(`thinking=${thinking}`); + } + + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); + if (contextWindow) { + traits.push(`context=${contextWindow}`); + } + + if (caps.supportsFastMode) { + traits.push(`fast=${modelOptions?.fastMode === true}`); + } + + return `${slug}[${traits.join(",")}]`; +} + /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ const ABOUT_TIMEOUT_MS = 8_000; @@ -215,7 +272,6 @@ export function parseCursorAboutOutput(result: CommandResult): CursorAboutResult } const plain = stripAnsi(combined); - console.log("plain:", plain); const version = extractAboutField(plain, "CLI Version") ?? null; const userEmail = extractAboutField(plain, "User Email"); diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 2ad83b1df465..4386c9c7ace7 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -122,7 +122,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str capabilities: { reasoningEffortLevels: [ { value: "low", label: "Low" }, - { value: "normal", label: "Normal", isDefault: true }, + { value: "medium", label: "Medium", isDefault: true }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra high" }, ], diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 86f53744712c..9ec2f37196c0 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -140,7 +140,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ capabilities: { reasoningEffortLevels: [ effort("low"), - effort("normal", true), + effort("medium", true), effort("high"), effort("xhigh"), ], @@ -151,7 +151,7 @@ const TEST_PROVIDERS: ReadonlyArray = [ }, }, { - slug: "claude-4.6-opus", + slug: "claude-opus-4-6", name: "Claude Opus 4.6", isCustom: false, capabilities: { @@ -314,10 +314,10 @@ describe("ProviderModelPicker", () => { } }); - it("maps concrete Cursor slugs onto the server-provided model options", async () => { + it("uses canonical Cursor slugs from the server-provided model options", async () => { const mounted = await mountPicker({ provider: "cursor", - model: "claude-4.6-opus-high-thinking", + model: "claude-opus-4-6", lockedProvider: "cursor", }); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 483af1da4754..3eea1db4833d 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -57,7 +57,7 @@ const CURSOR_MODELS: ReadonlyArray = [ capabilities: { reasoningEffortLevels: [ { value: "low", label: "Low" }, - { value: "normal", label: "Normal", isDefault: true }, + { value: "medium", label: "Medium", isDefault: true }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra high" }, ], @@ -413,13 +413,13 @@ describe("getComposerProviderState", () => { models: CURSOR_MODELS, prompt: "", modelOptions: { - cursor: { reasoning: "normal" }, + cursor: { reasoning: "medium" }, }, }); expect(state).toEqual({ provider: "cursor", - promptEffort: "normal", + promptEffort: "medium", modelOptionsForDispatch: undefined, }); }); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 0c5411d2f1a3..00f8f0147e5f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,6 +1,7 @@ import { CODEX_REASONING_EFFORT_OPTIONS, CURSOR_REASONING_OPTIONS, + DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, type CodexReasoningEffort, type CursorModelOptions, @@ -621,23 +622,13 @@ function normalizeProviderModelOptions( : 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 } - : {}), + ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), ...(cursorFastMode ? { fastMode: true } : {}), ...(cursorThinkingFalse ? { thinking: false } : {}), - ...(cursorClaudeOpusTier === "max" ? { claudeOpusTier: "max" } : {}), } : undefined; diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 0ab6dbaf8829..d8d924ca213c 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -54,7 +54,7 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record 0 ? nextOptions : undefined; } diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index 26bdeeac4dda..19cd4c9c6bb2 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1257,7 +1257,7 @@ function SettingsRouteView() { providerCard.provider === "codex" ? "gpt-6.7-codex-ultra-preview" : providerCard.provider === "cursor" - ? "claude-4.6-sonnet-medium-thinking" + ? "claude-sonnet-4-6" : "claude-sonnet-5-0" } spellCheck={false} diff --git a/packages/contracts/src/cursorCliModels.json b/packages/contracts/src/cursorCliModels.json deleted file mode 100644 index a4a07e7d3385..000000000000 --- a/packages/contracts/src/cursorCliModels.json +++ /dev/null @@ -1,343 +0,0 @@ -{ - "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 3b18e65d5352..86a1ae21a1a2 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -1,15 +1,12 @@ import { Schema } from "effect"; import { TrimmedNonEmptyString } from "./baseSchemas"; 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]; - -/** 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 const CURSOR_REASONING_OPTIONS = ["low", "medium", "high", "xhigh"] as const; export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number]; export type ProviderReasoningEffort = @@ -31,14 +28,11 @@ 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)), + contextWindow: Schema.optional(Schema.String), }); export type CursorModelOptions = typeof CursorModelOptions.Type; @@ -74,41 +68,10 @@ export const ModelCapabilities = Schema.Struct({ }); export type ModelCapabilities = typeof ModelCapabilities.Type; -export type ModelOption = { - readonly slug: string; - readonly name: string; -}; - -export const MODEL_OPTIONS_BY_PROVIDER = { - codex: [ - { slug: "gpt-5.4", name: "GPT-5.4" }, - { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, - { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, - { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, - { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, - { slug: "gpt-5.2", name: "GPT-5.2" }, - ], - claudeAgent: [ - { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, - { 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; - -export type ModelSlug = 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 = { +export const DEFAULT_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4", claudeAgent: "claude-sonnet-4-6", - cursor: "claude-4.6-opus-high-thinking", + cursor: "auto", }; export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; @@ -117,7 +80,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex; export const DEFAULT_GIT_TEXT_GENERATION_MODEL_BY_PROVIDER: Record = { codex: "gpt-5.4-mini", claudeAgent: "claude-haiku-4-5", - cursor: "composer-2-fast", + cursor: "composer-2", }; export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record> = { @@ -146,23 +109,23 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record = { claudeAgent: "Claude", cursor: "Cursor", }; - -export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = { - codex: CODEX_REASONING_EFFORT_OPTIONS, - claudeAgent: CLAUDE_CODE_EFFORT_OPTIONS, - cursor: CURSOR_REASONING_OPTIONS, -} as const satisfies Record; - -export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { - codex: "high", - claudeAgent: "high", - cursor: "normal", -} as const satisfies Record; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index d58d1fcda7d2..27040af64132 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -176,7 +176,7 @@ const CursorModelOptionsPatch = Schema.Struct({ reasoning: Schema.optionalKey(CursorModelOptions.fields.reasoning), fastMode: Schema.optionalKey(CursorModelOptions.fields.fastMode), thinking: Schema.optionalKey(CursorModelOptions.fields.thinking), - claudeOpusTier: Schema.optionalKey(CursorModelOptions.fields.claudeOpusTier), + contextWindow: Schema.optionalKey(CursorModelOptions.fields.contextWindow), }); const ModelSelectionPatch = Schema.Union([ diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index c8530d4de7c9..9a081c934989 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -1,35 +1,18 @@ import { describe, expect, it } from "vitest"; -import { - DEFAULT_MODEL, - DEFAULT_MODEL_BY_PROVIDER, - DEFAULT_REASONING_EFFORT_BY_PROVIDER, - type ModelCapabilities, -} from "@t3tools/contracts"; +import { DEFAULT_MODEL_BY_PROVIDER, type ModelCapabilities } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, getDefaultContextWindow, getDefaultEffort, - getDefaultReasoningEffort, - getEffectiveClaudeCodeEffort, + hasContextWindowOption, hasEffortLevel, - inferProviderForModel, isClaudeUltrathinkPrompt, - normalizeClaudeModelOptions, - normalizeCodexModelOptions, normalizeModelSlug, - parseCursorModelSelection, - resolveCursorDispatchModel, - resolveCursorModelFromSelection, - resolveModelSlug, + resolveContextWindow, + resolveEffort, resolveModelSlugForProvider, - resolveReasoningEffortForProvider, resolveSelectableModel, - supportsClaudeAdaptiveReasoning, - supportsClaudeFastMode, - supportsClaudeMaxEffort, - supportsClaudeThinkingToggle, - supportsClaudeUltrathinkKeyword, trimOrNull, } from "./model.ts"; @@ -99,22 +82,6 @@ describe("resolveSelectableModel", () => { expect(resolveSelectableModel("codex", "gpt-5.3 codex", options)).toBe("gpt-5.3-codex"); expect(resolveSelectableModel("claudeAgent", "sonnet", options)).toBe("claude-sonnet-4-6"); }); - - it("maps concrete slugs back to the closest selectable family option", () => { - const cursorOptions = [ - { slug: "composer-2", name: "Composer 2" }, - { slug: "gpt-5.3-codex", name: "Codex 5.3" }, - { slug: "claude-4.6-opus", name: "Claude Opus 4.6" }, - ]; - - expect(resolveSelectableModel("cursor", "composer-2-fast", cursorOptions)).toBe("composer-2"); - expect(resolveSelectableModel("cursor", "gpt-5.3-codex-high-fast", cursorOptions)).toBe( - "gpt-5.3-codex", - ); - expect(resolveSelectableModel("cursor", "claude-4.6-opus-high-thinking", cursorOptions)).toBe( - "claude-4.6-opus", - ); - }); }); describe("capability helpers", () => { @@ -129,201 +96,39 @@ describe("capability helpers", () => { }); }); -describe("inferProviderForModel", () => { - it("detects known provider model slugs", () => { - expect(inferProviderForModel("gpt-5.3-codex")).toBe("codex"); - expect(inferProviderForModel("claude-sonnet-4-6")).toBe("claudeAgent"); - expect(inferProviderForModel("sonnet")).toBe("claudeAgent"); - }); - - it("falls back when the model is unknown", () => { - expect(inferProviderForModel("custom/internal-model")).toBe("codex"); - expect(inferProviderForModel("custom/internal-model", "claudeAgent")).toBe("claudeAgent"); - }); - - 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", () => { - it("returns provider-scoped defaults", () => { - expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex); - expect(getDefaultReasoningEffort("claudeAgent")).toBe( - DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent, - ); - expect(getDefaultReasoningEffort("cursor")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor); - }); -}); - -describe("resolveReasoningEffortForProvider", () => { - it("accepts provider-scoped effort values", () => { - expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh"); - expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink"); - }); - - it("rejects effort values from the wrong provider", () => { - 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("getEffectiveClaudeCodeEffort", () => { - it("does not persist ultrathink into Claude runtime configuration", () => { - expect(getEffectiveClaudeCodeEffort("ultrathink")).toBeNull(); - expect(getEffectiveClaudeCodeEffort("high")).toBe("high"); - }); - - it("returns null when no claude effort is selected", () => { - expect(getEffectiveClaudeCodeEffort(null)).toBeNull(); - expect(getEffectiveClaudeCodeEffort(undefined)).toBeNull(); - }); -}); - -describe("normalizeCodexModelOptions", () => { - it("drops default-only codex options", () => { - expect( - normalizeCodexModelOptions({ reasoningEffort: "high", fastMode: false }), - ).toBeUndefined(); - }); - - it("preserves non-default codex options", () => { - expect(normalizeCodexModelOptions({ reasoningEffort: "xhigh", fastMode: true })).toEqual({ - reasoningEffort: "xhigh", - fastMode: true, - }); - }); -}); - -describe("normalizeClaudeModelOptions", () => { - it("drops unsupported fast mode and max effort for Sonnet", () => { - expect( - normalizeClaudeModelOptions("claude-sonnet-4-6", { - effort: "max", - fastMode: true, - }), - ).toBeUndefined(); - }); - - it("keeps the Haiku thinking toggle and removes unsupported effort", () => { - expect( - normalizeClaudeModelOptions("claude-haiku-4-5", { - thinking: false, - effort: "high", - }), - ).toEqual({ - thinking: false, - }); - }); -}); - -describe("supportsClaudeAdaptiveReasoning", () => { - it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { - expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true); - expect(supportsClaudeAdaptiveReasoning("claude-sonnet-4-6")).toBe(true); - expect(supportsClaudeAdaptiveReasoning("claude-haiku-4-5")).toBe(false); - expect(supportsClaudeAdaptiveReasoning(undefined)).toBe(false); +describe("resolveEffort", () => { + it("returns the explicit value when supported and not prompt-injected", () => { + expect(resolveEffort(codexCaps, "xhigh")).toBe("xhigh"); + expect(resolveEffort(codexCaps, "high")).toBe("high"); + expect(resolveEffort(claudeCaps, "medium")).toBe("medium"); }); -}); -describe("supportsClaudeMaxEffort", () => { - it("only enables max effort for Opus 4.6", () => { - expect(supportsClaudeMaxEffort("claude-opus-4-6")).toBe(true); - expect(supportsClaudeMaxEffort("claude-sonnet-4-6")).toBe(false); - expect(supportsClaudeMaxEffort("claude-haiku-4-5")).toBe(false); - expect(supportsClaudeMaxEffort(undefined)).toBe(false); + it("falls back to default when value is unsupported", () => { + expect(resolveEffort(codexCaps, "bogus")).toBe("high"); + expect(resolveEffort(claudeCaps, "bogus")).toBe("high"); }); -}); -describe("supportsClaudeFastMode", () => { - it("only enables Claude fast mode for Opus 4.6", () => { - expect(supportsClaudeFastMode("claude-opus-4-6")).toBe(true); - expect(supportsClaudeFastMode("opus")).toBe(true); - expect(supportsClaudeFastMode("claude-sonnet-4-6")).toBe(false); - expect(supportsClaudeFastMode("claude-haiku-4-5")).toBe(false); - expect(supportsClaudeFastMode(undefined)).toBe(false); + it("returns the default when no value is provided", () => { + expect(resolveEffort(codexCaps, undefined)).toBe("high"); + expect(resolveEffort(codexCaps, null)).toBe("high"); + expect(resolveEffort(codexCaps, "")).toBe("high"); + expect(resolveEffort(codexCaps, " ")).toBe("high"); }); -}); -describe("supportsClaudeUltrathinkKeyword", () => { - it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { - expect(supportsClaudeUltrathinkKeyword("claude-opus-4-6")).toBe(true); - expect(supportsClaudeUltrathinkKeyword("claude-sonnet-4-6")).toBe(true); - expect(supportsClaudeUltrathinkKeyword("claude-haiku-4-5")).toBe(false); + it("excludes prompt-injected efforts and falls back to default", () => { + expect(resolveEffort(claudeCaps, "ultrathink")).toBe("high"); }); -}); -describe("supportsClaudeThinkingToggle", () => { - it("only enables the Claude thinking toggle for Haiku 4.5", () => { - expect(supportsClaudeThinkingToggle("claude-opus-4-6")).toBe(false); - expect(supportsClaudeThinkingToggle("claude-sonnet-4-6")).toBe(false); - expect(supportsClaudeThinkingToggle("claude-haiku-4-5")).toBe(true); - expect(supportsClaudeThinkingToggle("haiku")).toBe(true); - expect(supportsClaudeThinkingToggle(undefined)).toBe(false); + it("returns undefined for models with no effort levels", () => { + const noCaps: ModelCapabilities = { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }; + expect(resolveEffort(noCaps, undefined)).toBeUndefined(); + expect(resolveEffort(noCaps, "high")).toBeUndefined(); }); }); @@ -387,57 +192,3 @@ describe("resolveContextWindow", () => { expect(resolveContextWindow(codexCaps, "1m")).toBeUndefined(); }); }); - -describe("normalize*ModelOptionsWithCapabilities", () => { - it("preserves explicit false codex fast mode", () => { - expect( - normalizeCodexModelOptionsWithCapabilities(codexCaps, { - reasoningEffort: "high", - fastMode: false, - }), - ).toEqual({ - reasoningEffort: "high", - fastMode: false, - }); - }); - - it("preserves the default Claude context window explicitly", () => { - expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - contextWindowOptions: [ - { value: "200k", label: "200k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - }, - { - effort: "high", - contextWindow: "200k", - }, - ), - ).toEqual({ - effort: "high", - contextWindow: "200k", - }); - }); - - it("omits unsupported Claude context window options", () => { - expect( - normalizeClaudeModelOptionsWithCapabilities( - { - ...claudeCaps, - reasoningEffortLevels: [], - supportsThinkingToggle: true, - contextWindowOptions: [], - }, - { - thinking: true, - contextWindow: "1m", - }, - ), - ).toEqual({ - thinking: true, - }); - }); -}); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 836294179d7c..15351d43dcc7 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -1,494 +1,11 @@ import { - CURSOR_REASONING_OPTIONS, DEFAULT_MODEL_BY_PROVIDER, - DEFAULT_REASONING_EFFORT_BY_PROVIDER, - MODEL_OPTIONS_BY_PROVIDER, MODEL_SLUG_ALIASES_BY_PROVIDER, - REASONING_EFFORT_OPTIONS_BY_PROVIDER, type ClaudeCodeEffort, - type ClaudeModelOptions, - type CodexModelOptions, - type CodexReasoningEffort, - type CursorClaudeOpusTier, - type CursorModelOptions, - type CursorModelSlug, - type CursorReasoningOption, type ModelCapabilities, - type ModelSelection, - type OpenCodeModelOptions, type ProviderKind, - type ProviderReasoningEffort, } from "@t3tools/contracts"; -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)), -}; - -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; - -export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["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 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"; - export interface SelectableModelOption { slug: string; name: string; @@ -701,20 +218,7 @@ export function resolveSelectableModel( } const resolved = options.find((option) => option.slug === normalized); - if (resolved) { - return resolved.slug; - } - - const familyMatch = options - .toSorted((left, right) => right.slug.length - left.slug.length) - .find((option) => { - if (!normalized.startsWith(option.slug)) { - return false; - } - const nextChar = normalized.charAt(option.slug.length); - return nextChar === "-" || nextChar === "[" || nextChar === ""; - }); - return familyMatch?.slug ?? null; + return resolved ? resolved.slug : null; } function resolveModelSlug(model: string | null | undefined, provider: ProviderKind): string { @@ -739,178 +243,6 @@ export function trimOrNull(value: T | null | undefined): T | n return trimmed || null; } -export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean { - const slug = normalizeModelSlug(model, "claudeAgent"); - return slug === CLAUDE_OPUS_4_6_MODEL || slug === CLAUDE_SONNET_4_6_MODEL; -} - -export function supportsClaudeMaxEffort(model: string | null | undefined): boolean { - const slug = normalizeModelSlug(model, "claudeAgent"); - return slug === CLAUDE_OPUS_4_6_MODEL; -} - -export function supportsClaudeFastMode(model: string | null | undefined): boolean { - const slug = normalizeModelSlug(model, "claudeAgent"); - return slug === CLAUDE_OPUS_4_6_MODEL; -} - -export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean { - const slug = normalizeModelSlug(model, "claudeAgent"); - return slug === CLAUDE_OPUS_4_6_MODEL || slug === CLAUDE_SONNET_4_6_MODEL; -} - -export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean { - const slug = normalizeModelSlug(model, "claudeAgent"); - return slug === CLAUDE_HAIKU_4_5_MODEL; -} - -export function inferProviderForModel( - model: string | null | undefined, - fallback: ProviderKind = "codex", -): ProviderKind { - const normalizedClaude = normalizeModelSlug(model, "claudeAgent"); - if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) { - return "claudeAgent"; - } - - const normalizedCodex = normalizeModelSlug(model, "codex"); - if (normalizedCodex && MODEL_SLUG_SET_BY_PROVIDER.codex.has(normalizedCodex)) { - 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; -} - -export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray; -export function getReasoningEffortOptions( - provider: "claudeAgent", - model?: string | null | undefined, -): ReadonlyArray; -export function getReasoningEffortOptions( - provider?: ProviderKind, - model?: string | null | undefined, -): ReadonlyArray; -export function getReasoningEffortOptions( - provider: ProviderKind = "codex", - model?: string | null | undefined, -): ReadonlyArray { - if (provider === "claudeAgent") { - if (supportsClaudeMaxEffort(model)) { - return ["low", "medium", "high", "max", "ultrathink"]; - } - if (supportsClaudeAdaptiveReasoning(model)) { - return ["low", "medium", "high", "ultrathink"]; - } - 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", -): ProviderReasoningEffort { - return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider]; -} - -export function resolveReasoningEffortForProvider( - provider: "codex", - effort: string | null | undefined, -): CodexReasoningEffort | null; -export function resolveReasoningEffortForProvider( - provider: "claudeAgent", - effort: string | null | undefined, -): ClaudeCodeEffort | null; -export function resolveReasoningEffortForProvider( - provider: ProviderKind, - effort: string | null | undefined, -): ProviderReasoningEffort | null; -export function resolveReasoningEffortForProvider( - provider: ProviderKind, - effort: string | null | undefined, -): ProviderReasoningEffort | null { - if (typeof effort !== "string") { - return null; - } - - const trimmed = effort.trim(); - if (!trimmed) { - return null; - } - - const options = REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider] as ReadonlyArray; - 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 { - if (!effort) { - return null; - } - return effort === "ultrathink" ? null : effort; -} - -export function normalizeCodexModelOptions( - modelOptions: CodexModelOptions | null | undefined, -): CodexModelOptions | undefined { - const defaultReasoningEffort = getDefaultReasoningEffort("codex"); - const reasoningEffort = - resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ?? - defaultReasoningEffort; - const fastModeEnabled = modelOptions?.fastMode === true; - const nextOptions: CodexModelOptions = { - ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), - ...(fastModeEnabled ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - -export function normalizeClaudeModelOptions( - model: string | null | undefined, - modelOptions: ClaudeModelOptions | null | undefined, -): ClaudeModelOptions | undefined { - const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); - const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent"); - const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort); - const effort = - resolvedEffort && - resolvedEffort !== "ultrathink" && - reasoningOptions.includes(resolvedEffort) && - resolvedEffort !== defaultReasoningEffort - ? resolvedEffort - : undefined; - const thinking = - supportsClaudeThinkingToggle(model) && modelOptions?.thinking === false ? false : undefined; - const fastMode = - supportsClaudeFastMode(model) && modelOptions?.fastMode === true ? true : undefined; - const nextOptions: ClaudeModelOptions = { - ...(thinking === false ? { thinking: false } : {}), - ...(effort ? { effort } : {}), - ...(fastMode ? { fastMode: true } : {}), - }; - return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; -} - export function applyClaudePromptEffortPrefix( text: string, effort: ClaudeAgentEffort | null | undefined, From 163025b6153bac4de3d1356e2e30d29c8a77987a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 18:21:25 -0700 Subject: [PATCH 10/42] fix picker --- .../src/provider/Layers/CursorProvider.ts | 2 +- .../components/chat/TraitsPicker.browser.tsx | 212 +++++++++++------- apps/web/src/components/chat/TraitsPicker.tsx | 55 ++--- .../chat/composerProviderRegistry.test.tsx | 44 +++- .../chat/composerProviderRegistry.tsx | 197 ++++++++-------- apps/web/src/composerDraftStore.ts | 5 + apps/web/src/providerModels.ts | 2 + 7 files changed, 318 insertions(+), 199 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 63b68accb140..375d54ce0100 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -126,7 +126,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ { value: "medium", label: "Medium" }, { value: "high", label: "High", isDefault: true }, ], - supportsFastMode: false, + supportsFastMode: true, supportsThinkingToggle: true, contextWindowOptions: [ { value: "200k", label: "200k", isDefault: true }, diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index f39d0defc769..bf3071006dcc 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -628,48 +628,84 @@ describe("TraitsPicker (Codex)", () => { }); }); -// ── OpenCode TraitsPicker tests ─────────────────────────────────────── +// ── Cursor TraitsPicker tests ───────────────────────────────────────── -async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeModelOptions }) { - const threadId = ThreadId.make("thread-opencode-traits"); - const threadRef = scopeThreadRef(LOCAL_ENVIRONMENT_ID, threadId); - const threadKey = scopedThreadKey(threadRef); - const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.opencode; - const draftsByThreadKey: Record = { - [threadKey]: { +async function mountCursorPicker(props?: { + model?: string; + options?: CursorModelOptions; + models?: ServerProvider["models"]; +}) { + const threadId = ThreadId.makeUnsafe("thread-cursor-traits"); + const model = props?.model ?? "gpt-5.4"; + const cursorProvider = { + provider: "cursor", + enabled: true, + installed: true, + version: "0.1.0", + status: "ready", + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + models: props?.models ?? [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "272k", label: "272k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + promptInjectedEffortLevels: [], + }, + }, + ], + } satisfies ServerProvider; + + const draftsByThreadId: Record = { + [threadId]: { prompt: "", images: [], nonPersistedImageIds: [], persistedAttachments: [], terminalContexts: [], modelSelectionByProvider: { - opencode: { - provider: "opencode", + cursor: { + provider: "cursor", model, - ...(props.options ? { options: props.options } : {}), + ...(props?.options ? { options: props.options } : {}), }, }, - activeProvider: "opencode", + activeProvider: "cursor", runtimeMode: null, interactionMode: null, }, }; useComposerDraftStore.setState({ - draftsByThreadKey, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + draftsByThreadId, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: { + [ProjectId.makeUnsafe("project-cursor-traits")]: threadId, + }, }); const host = document.createElement("div"); document.body.append(host); const screen = await render( {}} />, { container: host }, @@ -686,97 +722,117 @@ async function mountOpenCodePicker(props: { model?: string; options?: OpenCodeMo }; } -describe("TraitsPicker (OpenCode)", () => { - afterEach(() => { - document.body.innerHTML = ""; - localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); - useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows the selected agent label with capitalization in the trigger", async () => { - await using _ = await mountOpenCodePicker({ - options: { - variant: "medium", - agent: "plan", - }, - }); - - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Medium · Plan"); - expect(text).not.toContain("Medium · plan"); - }); - }); -}); - describe("TraitsPicker (Cursor)", () => { afterEach(() => { document.body.innerHTML = ""; localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); useComposerDraftStore.setState({ - draftsByThreadKey: {}, - draftThreadsByThreadKey: {}, - logicalProjectDraftThreadKeyByLogicalProjectKey: {}, + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, stickyModelSelectionByProvider: {}, }); }); - it("uses the selected fast mode menu label for the trigger in fast-only state", async () => { + it("shows the selected Cursor context window in the trigger label", async () => { await using _ = await mountCursorPicker({ - model: "composer-2", - options: { fastMode: false }, + options: { contextWindow: "1m" }, }); await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Normal"); + expect(document.body.textContent ?? "").toContain("Medium · 1M"); }); + }); + + it("persists Cursor context window changes", async () => { + await using _ = await mountCursorPicker(); await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "1M" }).click(); - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("off"); - expect(text).toContain("on"); + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toMatchObject({ + provider: "cursor", + model: "gpt-5.4", + options: { + contextWindow: "1m", + }, }); }); - it("shows Normal for Cursor Opus 4.6 when fast mode and context window are both at defaults", async () => { + it("does not render for models with no trait capabilities", async () => { await using _ = await mountCursorPicker({ - model: "claude-opus-4-6", - options: { fastMode: false }, + model: "default", + models: [ + { + slug: "default", + name: "Auto", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], }); - await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Normal"); - }); + expect(document.querySelector("button")).toBeNull(); + }); - await page.getByRole("button").click(); + it("renders for fast-only Cursor models with a usable label", async () => { + await using _ = await mountCursorPicker({ + model: "composer-2", + models: [ + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }); await vi.waitFor(() => { - const text = document.body.textContent ?? ""; - expect(text).toContain("Fast Mode"); - expect(text).toContain("Context Window"); - expect(text).toContain("200K (default)"); - expect(text).toContain("1M"); + expect(document.body.textContent ?? "").toContain("Normal"); }); }); - it("shows Normal · 1M for Cursor Opus 4.6 when fast mode is off and context window is overridden", async () => { + it("persists fast-mode changes for fast-only Cursor models", async () => { await using _ = await mountCursorPicker({ - model: "claude-opus-4-6", - options: { fastMode: false, contextWindow: "1m" }, + model: "composer-2", + models: [ + { + slug: "composer-2", + name: "Composer 2", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], }); - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Normal · 1M"); + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "on" }).click(); + + expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toMatchObject({ + provider: "cursor", + model: "composer-2", + options: { + fastMode: true, + }, }); }); }); diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 68e52155e0e6..077980b8994f 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -72,6 +72,16 @@ function getEffortKey(provider: ProviderKind): string { return "effort"; } +function getRawContextWindow( + provider: ProviderKind, + modelOptions: ProviderOptions | null | undefined, +): string | null { + if (modelOptions && "contextWindow" in modelOptions) { + return trimOrNull(modelOptions.contextWindow); + } + return null; +} + function buildNextOptions( provider: ProviderKind, modelOptions: ProviderOptions | null | undefined, @@ -178,7 +188,6 @@ function getTraitsSectionVisibility(input: { const showThinking = selected.thinkingEnabled !== null; const showFastMode = selected.caps.supportsFastMode; const showContextWindow = selected.contextWindowOptions.length > 1; - const showAgent = selected.agentOptions.length > 0; return { ...selected, @@ -186,8 +195,7 @@ function getTraitsSectionVisibility(input: { showThinking, showFastMode, showContextWindow, - showAgent, - hasAnyControls: showEffort || showThinking || showFastMode || showContextWindow || showAgent, + hasAnyControls: showEffort || showThinking || showFastMode || showContextWindow, }; } @@ -256,9 +264,6 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ showThinking, showFastMode, showContextWindow, - ultrathinkInBodyText, - agentOptions, - selectedAgent, hasAnyControls, } = getTraitsSectionVisibility({ provider, @@ -462,14 +467,6 @@ export const TraitsPicker = memo(function TraitsPicker({ modelOptions, allowPromptInjectedEffort, }); - const { selectedAgentLabel } = getSelectedTraits( - provider, - models, - model, - prompt, - modelOptions, - allowPromptInjectedEffort, - ); const effortLabel = effort ? (effortLevels.find((l) => l.value === effort)?.label ?? effort) @@ -500,27 +497,23 @@ export const TraitsPicker = memo(function TraitsPicker({ return null; } - const selectedTriggerTraits = [ - primaryTraitLabel, - ...(caps.supportsFastMode && - (fastModeEnabled || (primaryTraitLabel === null && contextWindowLabel !== null)) - ? [fastModeEnabled ? "Fast" : "Normal"] - : []), - ...(contextWindowLabel ? [contextWindowLabel] : []), - ...(selectedAgentLabel ? [selectedAgentLabel] : []), - ].filter(Boolean); const triggerLabel = fastOnlyControl ? fastModeEnabled ? "Fast" : "Normal" - : selectedTriggerTraits.length > 0 - ? selectedTriggerTraits.join(" · ") - : caps.supportsFastMode - ? "Normal" - : defaultContextWindow - ? (contextWindowOptions.find((option) => option.value === defaultContextWindow)?.label ?? - defaultContextWindow) - : (selectedAgentLabel ?? ""); + : [ + ultrathinkPromptControlled + ? "Ultrathink" + : effortLabel + ? effortLabel + : thinkingEnabled === null + ? null + : `Thinking ${thinkingEnabled ? "On" : "Off"}`, + ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []), + ...(contextWindowLabel ? [contextWindowLabel] : []), + ] + .filter(Boolean) + .join(" · "); const isCodexStyle = provider === "codex"; diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 3eea1db4833d..987d53b658ac 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -66,6 +66,26 @@ const CURSOR_MODELS: ReadonlyArray = [ promptInjectedEffortLevels: [], }, }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra high" }, + ], + supportsFastMode: true, + supportsThinkingToggle: false, + contextWindowOptions: [ + { value: "272k", label: "272k", isDefault: true }, + { value: "1m", label: "1M" }, + ], + promptInjectedEffortLevels: [], + }, + }, ]; const CLAUDE_MODELS: ReadonlyArray = [ @@ -424,7 +444,29 @@ describe("getComposerProviderState", () => { }); }); - it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => { + it("preserves Cursor context window in dispatch options", () => { + const state = getComposerProviderState({ + provider: "cursor", + model: "gpt-5.4", + models: CURSOR_MODELS, + prompt: "", + modelOptions: { + cursor: { + contextWindow: "1m", + }, + }, + }); + + expect(state).toEqual({ + provider: "cursor", + promptEffort: "medium", + modelOptionsForDispatch: { + contextWindow: "1m", + }, + }); + }); + + it("preserves Claude default effort explicitly in dispatch options", () => { const state = getComposerProviderState({ provider: "claudeAgent", model: "claude-opus-4-6", diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 050beec6a147..1e05470eaa27 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -15,7 +15,7 @@ import { normalizeCodexModelOptionsWithCapabilities, normalizeCursorModelOptionsWithCapabilities, } from "../../providerModels"; -import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; +import { shouldRenderTraitsControls, TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; export type ComposerProviderStateInput = { provider: ProviderKind; @@ -168,99 +168,120 @@ function createProviderRegistryEntry( const composerProviderRegistry: Record = { codex: { getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: ({ - threadId, - model, - models, - modelOptions, - prompt, - onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + renderTraitsMenuContent: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "codex", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "codex", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, }, claudeAgent: { getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: ({ - threadId, - model, - models, - modelOptions, - prompt, - onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + renderTraitsMenuContent: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "claudeAgent", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "claudeAgent", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, }, cursor: { getState: (input) => getProviderStateFromCapabilities(input), - renderTraitsMenuContent: ({ - threadId, - model, - models, - modelOptions, - prompt, - onPromptChange, - }) => ( - - ), - renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => ( - - ), + renderTraitsMenuContent: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "cursor", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, + renderTraitsPicker: ({ threadId, model, models, modelOptions, prompt, onPromptChange }) => + shouldRenderTraitsControls({ + provider: "cursor", + models, + model, + modelOptions, + prompt, + }) ? ( + + ) : null, }, }; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 00f8f0147e5f..c7e924dd61be 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -622,6 +622,10 @@ function normalizeProviderModelOptions( : undefined; const cursorFastMode = cursorCandidate?.fastMode === true; const cursorThinkingFalse = cursorCandidate?.thinking === false; + const cursorContextWindow = + typeof cursorCandidate?.contextWindow === "string" && cursorCandidate.contextWindow.length > 0 + ? cursorCandidate.contextWindow + : undefined; const cursor: CursorModelOptions | undefined = cursorCandidate !== null @@ -629,6 +633,7 @@ function normalizeProviderModelOptions( ...(cursorReasoning ? { reasoning: cursorReasoning } : {}), ...(cursorFastMode ? { fastMode: true } : {}), ...(cursorThinkingFalse ? { thinking: false } : {}), + ...(cursorContextWindow !== undefined ? { contextWindow: cursorContextWindow } : {}), } : undefined; diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index 7f0271faff2f..5efa5f1af82a 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -108,10 +108,12 @@ export function normalizeCursorModelOptionsWithCapabilities( const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; const thinking = caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); const nextOptions: CursorModelOptions = { ...(reasoningValue ? { reasoning: reasoningValue } : {}), ...(fastMode ? { fastMode: true } : {}), ...(thinking === false ? { thinking: false } : {}), + ...(contextWindow ? { contextWindow } : {}), }; return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } From dbcc55637b035872b8aab20bb16535cee7156a2f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 18:33:58 -0700 Subject: [PATCH 11/42] fix spawning --- .../src/provider/Layers/CursorAdapter.ts | 41 +++++-------------- .../src/provider/Layers/CursorProvider.ts | 12 ++++++ 2 files changed, 23 insertions(+), 30 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 28c78e4b6883..fb43ecd17650 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -71,12 +71,6 @@ export interface CursorAdapterLiveOptions { 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); } @@ -100,17 +94,6 @@ function extractModelConfigId(sessionResponse: unknown): string | undefined { return undefined; } -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; @@ -678,7 +661,6 @@ interface PendingUserInput { interface CursorSessionContext { readonly threadId: ThreadId; session: ProviderSession; - readonly spawnOptions?: CursorSpawnOptions | undefined; readonly child: ChildProcessWithoutNullStreams; readonly conn: AcpJsonRpcConnection; acpSessionId: string; @@ -896,8 +878,16 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }); } const cwd = nodePath.resolve(input.cwd.trim()); - const cursorSettings = yield* serverSettingsService.getSettings.pipe( + const spawnOptions = yield* serverSettingsService.getSettings.pipe( Effect.map((settings) => settings.providers.cursor), + Effect.map((cursorSettings) => ({ + command: cursorSettings.binaryPath, + args: [ + ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), + "acp", + ], + cwd, + })), Effect.mapError( (error) => new ProviderAdapterProcessError({ @@ -908,22 +898,14 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { }), ), ); - const cursorOpts: CursorSpawnOptions = { - binaryPath: cursorSettings.binaryPath, - apiEndpoint: cursorSettings.apiEndpoint || undefined, - }; const cursorModelSelection = input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; - const initialModel = resolveCursorDispatchModel( - cursorModelSelection?.model, - cursorModelSelection?.options, - ); 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( + + const child = yield* spawnAcpChildProcess(spawnOptions).pipe( Effect.mapError( (e) => new ProviderAdapterProcessError({ @@ -950,7 +932,6 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const ctx: CursorSessionContext = { threadId: input.threadId, session: {} as ProviderSession, - spawnOptions: cursorOpts, child, conn, acpSessionId: "", diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 375d54ce0100..c528ea7f1a4f 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -161,6 +161,18 @@ const BUILT_IN_MODELS: ReadonlyArray = [ promptInjectedEffortLevels: [], }, }, + { + slug: "grok-4-20", + name: "Grok 4.20", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: true, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, ]; export function getCursorModelCapabilities(model: string | null | undefined): ModelCapabilities { From 27174db68b1d7c0b6ceff72adf0e1863739dedb4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:07:02 -0700 Subject: [PATCH 12/42] Add Cursor text generation and model mapping - route Cursor commit/PR/branch generation through the agent CLI - resolve separate ACP and agent model IDs for Cursor models - improve git action failure logging and surface command output --- apps/server/src/git/Errors.ts | 74 ++++ .../git/Layers/CursorTextGeneration.test.ts | 381 +++++++----------- .../src/git/Layers/CursorTextGeneration.ts | 254 ++++++------ apps/server/src/git/Layers/GitManager.ts | 15 +- .../src/git/Layers/RoutingTextGeneration.ts | 32 +- .../src/provider/Layers/CursorAdapter.test.ts | 24 +- .../src/provider/Layers/CursorAdapter.ts | 4 +- .../provider/Layers/CursorProvider.test.ts | 55 ++- .../src/provider/Layers/CursorProvider.ts | 51 ++- 9 files changed, 483 insertions(+), 407 deletions(-) create mode 100644 apps/server/src/git/Errors.ts diff --git a/apps/server/src/git/Errors.ts b/apps/server/src/git/Errors.ts new file mode 100644 index 000000000000..c6f42c6b5166 --- /dev/null +++ b/apps/server/src/git/Errors.ts @@ -0,0 +1,74 @@ +import { Schema } from "effect"; + +/** + * GitCommandError - Git command execution failed. + */ +export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { + operation: Schema.String, + command: Schema.String, + cwd: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `Git command failed in ${this.operation}: ${this.command} (${this.cwd}) - ${this.detail}`; + } +} + +/** + * GitHubCliError - GitHub CLI execution or authentication failed. + */ +export class GitHubCliError extends Schema.TaggedErrorClass()("GitHubCliError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `GitHub CLI failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * TextGenerationError - Commit or PR text generation failed. + */ +export class TextGenerationError extends Schema.TaggedErrorClass()( + "TextGenerationError", + { + operation: Schema.String, + detail: Schema.String, + commandOutput: Schema.optional( + Schema.Struct({ + stdout: Schema.optional(Schema.String), + stderr: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Number), + }), + ), + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Text generation failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * GitManagerError - Stacked Git workflow orchestration failed. + */ +export class GitManagerError extends Schema.TaggedErrorClass()("GitManagerError", { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message(): string { + return `Git manager failed in ${this.operation}: ${this.detail}`; + } +} + +/** + * GitManagerServiceError - Errors emitted by stacked Git workflow orchestration. + */ +export type GitManagerServiceError = + | GitManagerError + | GitCommandError + | GitHubCliError + | TextGenerationError; diff --git a/apps/server/src/git/Layers/CursorTextGeneration.test.ts b/apps/server/src/git/Layers/CursorTextGeneration.test.ts index e7bce1134741..62079411b9cc 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.test.ts @@ -1,27 +1,13 @@ -import * as path from "node:path"; -import * as os from "node:os"; -import { fileURLToPath } from "node:url"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Effect, FileSystem, Layer, Path } from "effect"; import { expect } from "vitest"; -import { ServerSettingsError } from "@t3tools/contracts"; - import { ServerConfig } from "../../config.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.ts"); - -function shellSingleQuote(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe( Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge( @@ -32,98 +18,159 @@ const CursorTextGenerationTestLayer = CursorTextGenerationLive.pipe( Layer.provideMerge(NodeServices.layer), ); -function makeAcpAgentWrapper(dir: string, env: Record): string { - const binDir = path.join(dir, "bin"); - const agentPath = path.join(binDir, "agent"); - mkdirSync(binDir, { recursive: true }); - writeFileSync( - agentPath, - [ - "#!/bin/sh", - ...Object.entries(env).map(([key, value]) => `export ${key}=${shellSingleQuote(value)}`), - 'if [ "$1" != "acp" ]; then', - ' printf "%s\\n" "unexpected args: $*" >&2', - " exit 11", - "fi", - `exec bun ${JSON.stringify(mockAgentPath)}`, - "", - ].join("\n"), - "utf8", - ); - chmodSync(agentPath, 0o755); - return agentPath; -} - -function withFakeAcpAgent( - env: Record, - effect: Effect.Effect, -): Effect.Effect { +function makeFakeAgentBinary( + dir: string, + input: { + result: string; + requireModel?: string; + requireTrust?: boolean; + requireMode?: string; + stdinMustContain?: string; + stderr?: string; + exitCode?: number; + }, +) { return Effect.gen(function* () { - const tempDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-acp-")); - const agentPath = makeAcpAgentWrapper(tempDir, env); - const serverSettings = yield* ServerSettingsService; - const previousSettings = yield* serverSettings.getSettings; - - yield* serverSettings.updateSettings({ - providers: { - cursor: { - binaryPath: agentPath, - }, - }, - }); - - return yield* effect.pipe( - Effect.ensuring( - serverSettings - .updateSettings({ - providers: { - cursor: { - binaryPath: previousSettings.providers.cursor.binaryPath, - }, - }, - }) - .pipe( - Effect.catch(() => Effect.void), - Effect.ensuring( - Effect.sync(() => { - rmSync(tempDir, { recursive: true, force: true }); - }), - ), - Effect.asVoid, - ), - ), + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const binDir = path.join(dir, "bin"); + const agentPath = path.join(binDir, "agent"); + yield* fs.makeDirectory(binDir, { recursive: true }); + + yield* fs.writeFileString( + agentPath, + [ + "#!/bin/sh", + 'model=""', + 'seen_trust="0"', + 'mode=""', + "while [ $# -gt 0 ]; do", + ' if [ "$1" = "--model" ]; then', + " shift", + ' model="$1"', + " shift", + " continue", + " fi", + ' if [ "$1" = "--trust" ]; then', + ' seen_trust="1"', + " shift", + " continue", + " fi", + ' if [ "$1" = "--mode" ]; then', + " shift", + ' mode="$1"', + " shift", + " continue", + " fi", + " shift", + "done", + 'stdin_content="$(cat)"', + ...(input.requireModel !== undefined + ? [ + `if [ "$model" != "${input.requireModel}" ]; then`, + ' printf "%s\\n" "unexpected model: $model" >&2', + " exit 11", + "fi", + ] + : []), + ...(input.requireTrust + ? [ + 'if [ "$seen_trust" != "1" ]; then', + ' printf "%s\\n" "missing --trust" >&2', + " exit 12", + "fi", + ] + : []), + ...(input.requireMode !== undefined + ? [ + `if [ "$mode" != "${input.requireMode}" ]; then`, + ' printf "%s\\n" "unexpected mode: $mode" >&2', + " exit 13", + "fi", + ] + : []), + ...(input.stdinMustContain !== undefined + ? [ + `if ! printf "%s" "$stdin_content" | grep -F -- ${JSON.stringify(input.stdinMustContain)} >/dev/null; then`, + ' printf "%s\\n" "stdin missing expected content" >&2', + " exit 14", + "fi", + ] + : []), + ...(input.stderr !== undefined + ? [`printf "%s\\n" ${JSON.stringify(input.stderr)} >&2`] + : []), + "cat <<'__T3CODE_FAKE_AGENT_OUTPUT__'", + JSON.stringify({ + type: "result", + subtype: "success", + is_error: false, + result: input.result, + }), + "__T3CODE_FAKE_AGENT_OUTPUT__", + `exit ${input.exitCode ?? 0}`, + "", + ].join("\n"), ); + yield* fs.chmod(agentPath, 0o755); + return agentPath; }); } -function waitForFileContent(path: string): Effect.Effect { - return Effect.promise(async () => { - const deadline = Date.now() + 5_000; - for (;;) { - try { - return readFileSync(path, "utf8"); - } catch (error) { - if (Date.now() >= deadline) { - throw error instanceof Error ? error : new Error(String(error)); - } - } - await new Promise((resolve) => setTimeout(resolve, 25)); - } - }); +function withFakeAgentEnv( + input: { + result: string; + requireModel?: string; + requireTrust?: boolean; + requireMode?: string; + stdinMustContain?: string; + stderr?: string; + exitCode?: number; + }, + effect: Effect.Effect, +) { + return Effect.acquireUseRelease( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-cursor-text-" }); + const agentPath = yield* makeFakeAgentBinary(tempDir, input); + const serverSettings = yield* ServerSettingsService; + const previousSettings = yield* serverSettings.getSettings; + yield* serverSettings.updateSettings({ + providers: { + cursor: { + binaryPath: agentPath, + }, + }, + }); + return { serverSettings, previousBinaryPath: previousSettings.providers.cursor.binaryPath }; + }), + () => effect, + ({ serverSettings, previousBinaryPath }) => + serverSettings + .updateSettings({ + providers: { + cursor: { + binaryPath: previousBinaryPath, + }, + }, + }) + .pipe(Effect.asVoid), + ); } it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { - it.effect("uses ACP model config options instead of raw CLI model ids", () => { - const requestLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-log-")); - const requestLogPath = path.join(requestLogDir, "requests.ndjson"); - - return withFakeAcpAgent( + it.effect("uses agent CLI model ids instead of ACP bracket notation for commit messages", () => + withFakeAgentEnv( { - T3_ACP_REQUEST_LOG_PATH: requestLogPath, - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ + result: JSON.stringify({ subject: "Add generated commit message", - body: "- verify cursor acp model config path", + body: "- verify agent model mapping", }), + requireModel: "composer-2-fast", + requireTrust: true, + requireMode: "ask", + stdinMustContain: "Staged patch:", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -136,84 +183,25 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", modelSelection: { provider: "cursor", - model: "gpt-5.4", - options: { - reasoning: "xhigh", - fastMode: true, - contextWindow: "1m", - }, + model: "composer-2", + options: { fastMode: true }, }, }); expect(generated.subject).toBe("Add generated commit message"); - expect(generated.body).toBe("- verify cursor acp model config path"); - - const requests = readFileSync(requestLogPath, "utf8") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as { method?: string; params?: Record }); - - expect( - requests.find((request) => request.method === "initialize")?.params?.clientCapabilities, - ).toMatchObject({ - _meta: { - parameterizedModelPicker: true, - }, - }); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "model" && - request.params?.value === "gpt-5.4", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "reasoning" && - request.params?.value === "extra-high", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "context" && - request.params?.value === "1m", - ), - ).toBe(true); - expect( - requests.some( - (request) => - request.method === "session/set_config_option" && - request.params?.configId === "fast" && - request.params?.value === "true", - ), - ).toBe(true); - expect( - requests.find((request) => request.method === "session/prompt")?.params?.prompt, - ).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: "text", - text: expect.stringContaining("Staged patch:"), - }), - ]), - ); - - rmSync(requestLogDir, { recursive: true, force: true }); + expect(generated.body).toBe("- verify agent model mapping"); }), - ); - }); + ), + ); - it.effect("accepts json objects with extra assistant text around them", () => - withFakeAcpAgent( + it.effect("accepts json objects with extra text around them from agent output", () => + withFakeAgentEnv( { - T3_ACP_PROMPT_RESPONSE_TEXT: + result: 'Sure, here is the JSON:\n```json\n{\n "subject": "Update README dummy comment with attribution and date",\n "body": ""\n}\n```\nDone.', + requireModel: "composer-2", + requireTrust: true, + requireMode: "ask", }, Effect.gen(function* () { const textGeneration = yield* TextGeneration; @@ -234,65 +222,4 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => { }), ), ); - - it.effect("generates thread titles through Cursor ACP text generation", () => - withFakeAcpAgent( - { - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - title: '"Trim reconnect spinner status after resume."', - }), - }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateThreadTitle({ - cwd: process.cwd(), - message: "Fix the reconnect spinner after a resumed session.", - modelSelection: { - provider: "cursor", - model: "composer-2", - }, - }); - - expect(generated.title).toBe("Trim reconnect spinner status after resume."); - }), - ), - ); - - it.effect("closes the ACP child process after text generation completes", () => { - const exitLogDir = mkdtempSync(path.join(os.tmpdir(), "t3code-cursor-text-exit-log-")); - const exitLogPath = path.join(exitLogDir, "exit.log"); - - return withFakeAcpAgent( - { - T3_ACP_EXIT_LOG_PATH: exitLogPath, - T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ - subject: "Close runtime after generation", - body: "", - }), - }, - Effect.gen(function* () { - const textGeneration = yield* TextGeneration; - - const generated = yield* textGeneration.generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/cursor-runtime-close", - stagedSummary: "M apps/server/src/git/Layers/CursorTextGeneration.ts", - stagedPatch: - "diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts", - modelSelection: { - provider: "cursor", - model: "composer-2", - }, - }); - - expect(generated.subject).toBe("Close runtime after generation"); - - const exitLog = yield* waitForFileContent(exitLogPath); - expect(exitLog).toContain("exit:0"); - - rmSync(exitLogDir, { recursive: true, force: true }); - }), - ); - }); }); diff --git a/apps/server/src/git/Layers/CursorTextGeneration.ts b/apps/server/src/git/Layers/CursorTextGeneration.ts index 754f3737eb58..2793df6555f8 100644 --- a/apps/server/src/git/Layers/CursorTextGeneration.ts +++ b/apps/server/src/git/Layers/CursorTextGeneration.ts @@ -1,30 +1,29 @@ -import { Effect, Layer, Option, Ref, Schema } from "effect"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { Effect, Layer, Option, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { CursorModelSelection } from "@t3tools/contracts"; import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; -import { TextGenerationError } from "@t3tools/contracts"; -import { - type ThreadTitleGenerationResult, - type TextGenerationShape, - TextGeneration, -} from "../Services/TextGeneration.ts"; +import { TextGenerationError } from "../Errors.ts"; +import { type TextGenerationShape, TextGeneration } from "../Services/TextGeneration.ts"; import { buildBranchNamePrompt, buildCommitMessagePrompt, buildPrContentPrompt, - buildThreadTitlePrompt, } from "../Prompts.ts"; -import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle } from "../Utils.ts"; -import { - applyCursorAcpModelSelection, - makeCursorAcpRuntime, -} from "../../provider/acp/CursorAcpSupport.ts"; +import { normalizeCliError, sanitizeCommitSubject, sanitizePrTitle } from "../Utils.ts"; +import { resolveCursorAgentModel } from "../../provider/Layers/CursorProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; const CURSOR_TIMEOUT_MS = 180_000; +const CursorOutputEnvelope = Schema.Struct({ + type: Schema.String, + subtype: Schema.optional(Schema.String), + is_error: Schema.optional(Schema.Boolean), + result: Schema.optional(Schema.String), +}); + function extractJsonObject(raw: string): string { const trimmed = raw.trim(); if (trimmed.length === 0) { @@ -73,35 +72,25 @@ function extractJsonObject(raw: string): string { return trimmed.slice(start); } -function mapCursorAcpError( - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle", - detail: string, - cause: unknown, -): TextGenerationError { - return new TextGenerationError({ - operation, - detail, - ...(cause !== undefined ? { cause } : {}), - }); -} - -function isTextGenerationError(error: unknown): error is TextGenerationError { - return ( - typeof error === "object" && - error !== null && - "_tag" in error && - error._tag === "TextGenerationError" - ); -} - const makeCursorTextGeneration = Effect.gen(function* () { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverSettingsService = yield* Effect.service(ServerSettingsService); + const readStreamAsString = ( + operation: string, + stream: Stream.Stream, + ): Effect.Effect => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + Effect.mapError((cause) => + normalizeCliError("agent", operation, cause, "Failed to collect process output"), + ), + ); + const runCursorJson = ({ operation, cwd, @@ -109,11 +98,7 @@ const makeCursorTextGeneration = Effect.gen(function* () { outputSchemaJson, modelSelection, }: { - operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; cwd: string; prompt: string; outputSchemaJson: S; @@ -125,47 +110,75 @@ const makeCursorTextGeneration = Effect.gen(function* () { (settings) => settings.providers.cursor, ).pipe(Effect.catch(() => Effect.undefined)); - const outputRef = yield* Ref.make(""); - const runtime = yield* makeCursorAcpRuntime({ - cursorSettings, - childProcessSpawner: commandSpawner, - cwd, - clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); - - yield* runtime.handleSessionUpdate((notification) => { - const update = notification.update; - if (update.sessionUpdate !== "agent_message_chunk") { - return Effect.void; - } - const content = update.content; - if (content.type !== "text") { - return Effect.void; + const runCursorCommand = Effect.gen(function* () { + const command = ChildProcess.make( + cursorSettings?.binaryPath || "agent", + [ + "-p", + "--trust", + "--mode", + "ask", + "--output-format", + "json", + "--model", + resolveCursorAgentModel(modelSelection.model, modelSelection.options), + ], + { + cwd, + shell: process.platform === "win32", + stdin: { + stream: Stream.encodeText(Stream.make(prompt)), + }, + }, + ); + + const child = yield* commandSpawner + .spawn(command) + .pipe( + Effect.mapError((cause) => + normalizeCliError("agent", operation, cause, "Failed to spawn Cursor Agent process"), + ), + ); + + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + readStreamAsString(operation, child.stdout), + readStreamAsString(operation, child.stderr), + child.exitCode.pipe( + Effect.mapError((cause) => + normalizeCliError( + "agent", + operation, + cause, + "Failed to read Cursor Agent exit code", + ), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + const commandOutput = { stdout, stderr, exitCode }; + + if (exitCode !== 0) { + const stderrDetail = stderr.trim(); + const stdoutDetail = stdout.trim(); + const detail = stderrDetail.length > 0 ? stderrDetail : stdoutDetail; + return yield* new TextGenerationError({ + operation, + commandOutput, + detail: + detail.length > 0 + ? `Cursor Agent command failed: ${detail}` + : `Cursor Agent command failed with code ${exitCode}.`, + }); } - return Ref.update(outputRef, (current) => current + content.text); - }); - const promptResult = yield* Effect.gen(function* () { - yield* runtime.start(); - yield* Effect.ignore(runtime.setMode("ask")); - yield* applyCursorAcpModelSelection({ - runtime, - model: modelSelection.model, - modelOptions: modelSelection.options, - mapError: ({ cause, configId, step }) => - mapCursorAcpError( - operation, - step === "set-config-option" - ? `Failed to set Cursor ACP config option "${configId}" for text generation.` - : "Failed to set Cursor ACP base model for text generation.", - cause, - ), - }); + return commandOutput; + }); - return yield* runtime.prompt({ - prompt: [{ type: "text", text: prompt }], - }); - }).pipe( + const commandOutput = yield* runCursorCommand.pipe( + Effect.scoped, Effect.timeoutOption(CURSOR_TIMEOUT_MS), Effect.flatMap( Option.match({ @@ -179,21 +192,41 @@ const makeCursorTextGeneration = Effect.gen(function* () { onSome: (value) => Effect.succeed(value), }), ), - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : mapCursorAcpError(operation, "Cursor ACP request failed.", cause), + ); + + const envelope = yield* Schema.decodeEffect(Schema.fromJsonString(CursorOutputEnvelope))( + commandOutput.stdout, + ).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Cursor Agent returned unexpected output format.", + commandOutput, + cause, + }), + ), ), ); - const rawResult = (yield* Ref.get(outputRef)).trim(); + if ( + envelope.type !== "result" || + envelope.subtype !== "success" || + envelope.is_error === true + ) { + return yield* new TextGenerationError({ + operation, + detail: "Cursor Agent returned an unsuccessful result.", + commandOutput, + }); + } + + const rawResult = envelope.result?.trim(); if (!rawResult) { return yield* new TextGenerationError({ operation, - detail: - promptResult.stopReason === "cancelled" - ? "Cursor ACP request was cancelled." - : "Cursor Agent returned empty output.", + detail: "Cursor Agent returned empty output.", + commandOutput, }); } @@ -206,18 +239,12 @@ const makeCursorTextGeneration = Effect.gen(function* () { operation, detail: "Cursor Agent returned invalid structured output.", cause, + commandOutput, }), ), ), ); - }).pipe( - Effect.mapError((cause) => - isTextGenerationError(cause) - ? cause - : mapCursorAcpError(operation, "Cursor ACP text generation failed.", cause), - ), - Effect.scoped, - ); + }); const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( "CursorTextGeneration.generateCommitMessage", @@ -313,39 +340,10 @@ const makeCursorTextGeneration = Effect.gen(function* () { }; }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "CursorTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, - }); - - if (input.modelSelection.provider !== "cursor") { - return yield* new TextGenerationError({ - operation: "generateThreadTitle", - detail: "Invalid model selection.", - }); - } - - const generated = yield* runCursorJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - }); - - return { - title: sanitizeThreadTitle(generated.title), - } satisfies ThreadTitleGenerationResult; - }); - return { generateCommitMessage, generatePrContent, generateBranchName, - generateThreadTitle, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index dadf2f7e79b4..fe09842b539f 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1703,16 +1703,17 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { return result; }); - return yield* runAction().pipe( - Effect.ensuring(invalidateStatus(input.cwd)), - Effect.tapError((error) => - Effect.flatMap(Ref.get(currentPhase), (phase) => - progress.emit({ + return yield* runAction.pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logError("Failed to run action:", error); + yield* progress.emit({ kind: "action_failed", phase: Option.getOrNull(phase), message: error.message, - }), - ), + }); + return yield* Effect.fail(error); + }), ), ); }, diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 8f5c166d817b..0d52382fdb2e 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -19,7 +19,6 @@ import { import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; import { CursorTextGenerationLive } from "./CursorTextGeneration.ts"; -import { OpenCodeTextGenerationLive } from "./OpenCodeTextGeneration.ts"; // --------------------------------------------------------------------------- // Internal service tags so both concrete layers can coexist. @@ -33,14 +32,10 @@ class ClaudeTextGen extends Context.Service( "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen", ) {} -class CursorTextGen extends Context.Service()( +class CursorTextGen extends ServiceMap.Service()( "t3/git/Layers/RoutingTextGeneration/CursorTextGen", ) {} -class OpenCodeTextGen extends Context.Service()( - "t3/git/Layers/RoutingTextGeneration/OpenCodeTextGen", -) {} - // --------------------------------------------------------------------------- // Routing implementation // --------------------------------------------------------------------------- @@ -49,16 +44,14 @@ const makeRoutingTextGeneration = Effect.gen(function* () { const codex = yield* CodexTextGen; const claude = yield* ClaudeTextGen; const cursor = yield* CursorTextGen; - const openCode = yield* OpenCodeTextGen; - const route = (provider?: TextGenerationProvider): TextGenerationShape => - provider === "claudeAgent" - ? claude - : provider === "opencode" - ? openCode - : provider === "cursor" - ? cursor - : codex; + const providerToService = { + codex, + claudeAgent: claude, + cursor, + }; + + const route = (provider: TextGenerationProvider) => providerToService[provider]; return { generateCommitMessage: (input) => @@ -93,14 +86,6 @@ const InternalCursorLayer = Layer.effect( }), ).pipe(Layer.provide(CursorTextGenerationLive)); -const InternalOpenCodeLayer = Layer.effect( - OpenCodeTextGen, - Effect.gen(function* () { - const svc = yield* TextGeneration; - return svc; - }), -).pipe(Layer.provide(OpenCodeTextGenerationLive)); - export const RoutingTextGenerationLive = Layer.effect( TextGeneration, makeRoutingTextGeneration, @@ -108,5 +93,4 @@ export const RoutingTextGenerationLive = Layer.effect( Layer.provide(InternalCodexLayer), Layer.provide(InternalClaudeLayer), Layer.provide(InternalCursorLayer), - Layer.provide(InternalOpenCodeLayer), ); diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index b97872b0336b..a0af6596ec41 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -13,7 +13,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { makeCursorAdapterLive } from "./CursorAdapter.ts"; -import { resolveCursorDispatchModel } from "./CursorProvider.ts"; +import { resolveCursorAcpModelId } from "./CursorProvider.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const mockAgentPath = path.join(__dirname, "../../../scripts/acp-mock-agent.mjs"); @@ -169,7 +169,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { }), ); - it.effect("selects the Cursor model via CLI argv instead of ACP request payloads", () => + it.effect("selects the Cursor model via ACP config updates instead of CLI argv", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; const serverSettings = yield* ServerSettingsService; @@ -183,7 +183,7 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { ); yield* serverSettings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } }); - const dispatchedModel = resolveCursorDispatchModel("composer-2", { fastMode: true }); + const dispatchedModel = resolveCursorAcpModelId("composer-2", { fastMode: true }); const session = yield* adapter.startSession({ threadId, provider: "cursor", @@ -198,11 +198,12 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { threadId, input: "probe model selection", attachments: [], + modelSelection: { provider: "cursor", model: "composer-2", options: { fastMode: true } }, }); yield* adapter.stopSession(threadId); const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); - assert.deepStrictEqual(argvRuns, [["--model", dispatchedModel, "acp"]]); + assert.deepStrictEqual(argvRuns, [["acp"]]); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const methods = requests @@ -223,6 +224,15 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { } } + const setConfigRequests = requests.filter( + (entry) => entry.method === "session/set_config_option", + ); + assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); + assert.equal( + (setConfigRequests[setConfigRequests.length - 1]?.params as Record)?.value, + dispatchedModel, + ); + const promptRequest = requests.find((entry) => entry.method === "session/prompt"); assert.isDefined(promptRequest); assert.deepStrictEqual( @@ -465,13 +475,17 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { const argvRuns = yield* Effect.promise(() => readArgvLog(argvLogPath)); assert.lengthOf(argvRuns, 1, "session should not restart — only one spawn"); - assert.deepStrictEqual(argvRuns[0], ["--model", "composer-2[fast=false]", "acp"]); + assert.deepStrictEqual(argvRuns[0], ["acp"]); const requests = yield* Effect.promise(() => readJsonLines(requestLogPath)); const setConfigRequests = requests.filter( (entry) => entry.method === "session/set_config_option", ); assert.isAbove(setConfigRequests.length, 0, "should call session/set_config_option"); + assert.equal( + (setConfigRequests[0]?.params as Record)?.value, + "composer-2[fast=false]", + ); const lastSetConfig = setConfigRequests[setConfigRequests.length - 1]; assert.equal( (lastSetConfig?.params as Record)?.value, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index fb43ecd17650..681f7746a5b0 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -56,7 +56,7 @@ import { 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 { resolveCursorDispatchModel } from "./CursorProvider.ts"; +import { resolveCursorAcpModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = "cursor" as const; @@ -1285,7 +1285,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) { const turnId = TurnId.makeUnsafe(crypto.randomUUID()); const turnModelSelection = input.modelSelection?.provider === "cursor" ? input.modelSelection : undefined; - const model = resolveCursorDispatchModel( + const model = resolveCursorAcpModelId( turnModelSelection?.model ?? ctx.session.model, turnModelSelection?.options, ); diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 3f5094b82ed0..331a9090fedd 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -1,36 +1,38 @@ import { describe, expect, it } from "vitest"; -import { getCursorModelCapabilities, resolveCursorDispatchModel } from "./CursorProvider.ts"; +import { + getCursorModelCapabilities, + resolveCursorAgentModel, + resolveCursorAcpModelId, +} from "./CursorProvider.ts"; -describe("resolveCursorDispatchModel", () => { +describe("resolveCursorAcpModelId", () => { it("builds bracket notation from canonical base slugs and capabilities", () => { - expect(resolveCursorDispatchModel("composer-2", { fastMode: true })).toBe( - "composer-2[fast=true]", - ); - expect(resolveCursorDispatchModel("gpt-5.4", undefined)).toBe( + expect(resolveCursorAcpModelId("composer-2", { fastMode: true })).toBe("composer-2[fast=true]"); + expect(resolveCursorAcpModelId("gpt-5.4", undefined)).toBe( "gpt-5.4[reasoning=medium,context=272k,fast=false]", ); expect( - resolveCursorDispatchModel("claude-opus-4-6", { + resolveCursorAcpModelId("claude-opus-4-6", { reasoning: "high", thinking: true, contextWindow: "1m", }), - ).toBe("claude-opus-4-6[effort=high,thinking=true,context=1m]"); + ).toBe("claude-opus-4-6[effort=high,thinking=true,context=1m,fast=false]"); }); it("maps legacy cursor aliases onto the canonical base slug", () => { - expect(resolveCursorDispatchModel("gpt-5.4-1m", undefined)).toBe( + expect(resolveCursorAcpModelId("gpt-5.4-1m", undefined)).toBe( "gpt-5.4[reasoning=medium,context=272k,fast=false]", ); - expect(resolveCursorDispatchModel("auto", undefined)).toBe("default[]"); - expect(resolveCursorDispatchModel("claude-4.6-opus", undefined)).toBe( - "claude-opus-4-6[effort=high,thinking=true,context=200k]", + expect(resolveCursorAcpModelId("auto", undefined)).toBe("default[]"); + expect(resolveCursorAcpModelId("claude-4.6-opus", undefined)).toBe( + "claude-opus-4-6[effort=high,thinking=true,context=200k,fast=false]", ); }); it("passes custom models through unchanged", () => { - expect(resolveCursorDispatchModel("custom/internal-model", undefined)).toBe( + expect(resolveCursorAcpModelId("custom/internal-model", undefined)).toBe( "custom/internal-model[]", ); }); @@ -45,3 +47,30 @@ describe("getCursorModelCapabilities", () => { expect(getCursorModelCapabilities("claude-opus-4-6").supportsThinkingToggle).toBe(true); }); }); + +describe("resolveCursorAgentModel", () => { + it("maps canonical base slugs onto agent CLI model ids", () => { + expect(resolveCursorAgentModel("composer-2", { fastMode: true })).toBe("composer-2-fast"); + expect(resolveCursorAgentModel("gpt-5.3-codex", { reasoning: "xhigh" })).toBe( + "gpt-5.3-codex-xhigh", + ); + expect( + resolveCursorAgentModel("gpt-5.4", { + reasoning: "medium", + fastMode: true, + contextWindow: "272k", + }), + ).toBe("gpt-5.4-medium-fast"); + expect(resolveCursorAgentModel("claude-opus-4-6", { thinking: true })).toBe( + "claude-4.6-opus-high-thinking", + ); + expect(resolveCursorAgentModel("auto", undefined)).toBe("auto"); + }); + + it("passes custom agent model ids through unchanged", () => { + expect(resolveCursorAgentModel("gpt-5.4-mini-medium", undefined)).toBe("gpt-5.4-mini-medium"); + expect(resolveCursorAgentModel("custom/internal-model", undefined)).toBe( + "custom/internal-model", + ); + }); +}); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index c528ea7f1a4f..efb590eb6544 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -182,7 +182,10 @@ export function getCursorModelCapabilities(model: string | null | undefined): Mo ); } -export function resolveCursorDispatchModel( +/** + * Resolve the ACP model ID for a Cursor model to be sent to session/set_config_option + */ +export function resolveCursorAcpModelId( model: string | null | undefined, modelOptions: CursorModelOptions | null | undefined, ): string { @@ -219,6 +222,52 @@ export function resolveCursorDispatchModel( return `${slug}[${traits.join(",")}]`; } +/** + * Resolve the Agent CLI model ID for a Cursor model to be set as `--model` arg for the `agent` command. + * + * Yes... Cursor uses different IDs. No... I don't know why. + */ +export function resolveCursorAgentModel( + model: string | null | undefined, + modelOptions: CursorModelOptions | null | undefined, +): string { + const normalized = normalizeModelSlug(model, "cursor") ?? "default"; + const slug = normalized.includes("[") ? normalized.slice(0, normalized.indexOf("[")) : normalized; + const caps = getCursorModelCapabilities(slug); + const reasoning = resolveEffort(caps, modelOptions?.reasoning); + const thinking = caps.supportsThinkingToggle ? (modelOptions?.thinking ?? true) : undefined; + const fastMode = modelOptions?.fastMode === true; + + switch (slug) { + case "default": + return "auto"; + case "composer-2": + return fastMode ? "composer-2-fast" : "composer-2"; + case "composer-1.5": + return "composer-1.5"; + case "gpt-5.3-codex": { + const suffix = reasoning && reasoning !== "medium" ? `-${reasoning}` : ""; + return `gpt-5.3-codex${suffix}${fastMode ? "-fast" : ""}`; + } + case "gpt-5.3-codex-spark": { + const suffix = reasoning && reasoning !== "medium" ? `-${reasoning}` : ""; + return `gpt-5.3-codex-spark-preview${suffix}`; + } + case "gpt-5.4": + return `gpt-5.4-${reasoning ?? "medium"}${fastMode ? "-fast" : ""}`; + case "claude-opus-4-6": + return thinking ? "claude-4.6-opus-high-thinking" : "claude-4.6-opus-high"; + case "claude-sonnet-4-6": + return thinking ? "claude-4.6-sonnet-medium-thinking" : "claude-4.6-sonnet-medium"; + case "gemini-3.1-pro": + return "gemini-3.1-pro"; + case "grok-4-20": + return thinking ? "grok-4-20-thinking" : "grok-4-20"; + default: + return slug === "default" ? "auto" : slug; + } +} + /** Timeout for `agent about` — it's slower than a simple `--version` probe. */ const ABOUT_TIMEOUT_MS = 8_000; From b6d2ad82a8152712f5597fd9d1a3c01b75d503fa Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:09:12 -0700 Subject: [PATCH 13/42] fix effect lsp issues --- apps/server/src/git/Layers/GitManager.ts | 2 +- apps/server/src/provider/acp/AcpTypes.ts | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index fe09842b539f..22be670a41bf 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1712,7 +1712,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { phase: Option.getOrNull(phase), message: error.message, }); - return yield* Effect.fail(error); + return yield* error; }), ), ); diff --git a/apps/server/src/provider/acp/AcpTypes.ts b/apps/server/src/provider/acp/AcpTypes.ts index cc593a4f796d..2a7ff9138fdf 100644 --- a/apps/server/src/provider/acp/AcpTypes.ts +++ b/apps/server/src/provider/acp/AcpTypes.ts @@ -19,22 +19,19 @@ export const JsonRpcInboundWire = Schema.Struct({ error: Schema.optional(JsonRpcErrorPayload), }); -export const AcpInboundResponse = Schema.Struct({ - _tag: Schema.Literal("response"), +export const AcpInboundResponse = Schema.TaggedStruct("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"), +export const AcpInboundRequest = Schema.TaggedStruct("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"), +export const AcpInboundNotification = Schema.TaggedStruct("notification", { method: Schema.String, params: Schema.optional(Schema.Unknown), }); From 02596b6de6b80084ad5dfaf71c652d50aa23bf5d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:11:27 -0700 Subject: [PATCH 14/42] use constructors --- apps/server/src/provider/acp/AcpTypes.ts | 37 +++++++++++++----------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/apps/server/src/provider/acp/AcpTypes.ts b/apps/server/src/provider/acp/AcpTypes.ts index 2a7ff9138fdf..59dc7788c63b 100644 --- a/apps/server/src/provider/acp/AcpTypes.ts +++ b/apps/server/src/provider/acp/AcpTypes.ts @@ -64,29 +64,32 @@ const jsonRpcWireToInbound = SchemaTransformation.transformOrFail({ ...(err.data !== undefined ? { data: err.data } : {}), } : undefined; - return Effect.succeed({ - _tag: "response" as const, - id, - ...(parsed.result !== undefined ? { result: parsed.result } : {}), - ...(rpcError ? { error: rpcError } : {}), - }); + return Effect.succeed( + AcpInboundResponse.makeUnsafe({ + 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 } : {}), - }); + return Effect.succeed( + AcpInboundRequest.makeUnsafe({ + 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.succeed( + AcpInboundNotification.makeUnsafe({ + method, + ...(parsed.params !== undefined ? { params: parsed.params } : {}), + }), + ); } return Effect.fail( From 4dd3e5f098521cd4b8843d936794553f29d6a490 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:13:59 -0700 Subject: [PATCH 15/42] kewl --- apps/web/src/store.ts | 44 ++++++++++--------------------------------- 1 file changed, 10 insertions(+), 34 deletions(-) diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 67076e4cb59d..1d7860ba54b5 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -1,42 +1,18 @@ -import type { - EnvironmentId, - MessageId, - OrchestrationCheckpointSummary, - OrchestrationEvent, - OrchestrationLatestTurn, - OrchestrationMessage, - OrchestrationProposedPlan, - OrchestrationReadModel, - OrchestrationShellSnapshot, - OrchestrationShellStreamEvent, - OrchestrationSession, - OrchestrationSessionStatus, - OrchestrationThread, - OrchestrationThreadShell, - OrchestrationThreadActivity, - ProjectId, - ScopedProjectRef, - ScopedThreadRef, +import { Fragment, type ReactNode, createElement, useEffect } from "react"; +import { + ProviderKind, + ThreadId, + type OrchestrationReadModel, + type OrchestrationSessionStatus, } from "@t3tools/contracts"; import { ProviderKind } from "@t3tools/contracts"; import type { ThreadId, TurnId } from "@t3tools/contracts"; import { Schema } from "effect"; import { resolveModelSlugForProvider } from "@t3tools/shared/model"; import { create } from "zustand"; -import { - type ChatMessage, - type Project, - type ProposedPlan, - type SidebarThreadSummary, - type Thread, - type ThreadSession, - type ThreadShell, - type ThreadTurnState, - type TurnDiffSummary, -} from "./types"; -import { resolveEnvironmentHttpUrl } from "./environments/runtime"; -import { sanitizeThreadErrorMessage } from "./rpc/transportError"; -import { getThreadFromEnvironmentState } from "./threadDerivation"; +import { type ChatMessage, type Project, type Thread } from "./types"; +import { Debouncer } from "@tanstack/react-pacer"; +import { Schema } from "effect"; export interface EnvironmentState { projectIds: ProjectId[]; @@ -1001,7 +977,7 @@ function toLegacySessionStatus( } function toLegacyProvider(providerName: string | null): ProviderKind { - if (providerName === "codex" || providerName === "claudeAgent" || providerName === "cursor") { + if (Schema.is(ProviderKind)(providerName)) { return providerName; } return "codex"; From d2dea0f22c32cd1d7b28893da27ad0ea3f63ad8b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:17:59 -0700 Subject: [PATCH 16/42] move claude model id lookup --- .../src/git/Layers/ClaudeTextGeneration.ts | 6 ++-- .../src/provider/Layers/ClaudeAdapter.ts | 3 +- .../src/provider/Layers/ClaudeModelId.test.ts | 28 ------------------- .../src/provider/Layers/ClaudeModelId.ts | 10 ------- .../src/provider/Layers/ClaudeProvider.ts | 20 +++++++++++++ 5 files changed, 25 insertions(+), 42 deletions(-) delete mode 100644 apps/server/src/provider/Layers/ClaudeModelId.test.ts delete mode 100644 apps/server/src/provider/Layers/ClaudeModelId.ts diff --git a/apps/server/src/git/Layers/ClaudeTextGeneration.ts b/apps/server/src/git/Layers/ClaudeTextGeneration.ts index 6cd8447df2ff..9c8e19770b61 100644 --- a/apps/server/src/git/Layers/ClaudeTextGeneration.ts +++ b/apps/server/src/git/Layers/ClaudeTextGeneration.ts @@ -28,8 +28,10 @@ import { sanitizeThreadTitle, toJsonSchemaObject, } from "../Utils.ts"; -import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeModelId.ts"; -import { normalizeClaudeModelOptions } from "../../provider/Layers/ClaudeProvider.ts"; +import { + normalizeClaudeModelOptions, + resolveClaudeApiModelId, +} from "../../provider/Layers/ClaudeProvider.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts"; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index f2e895f6420e..09e948437f8c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -61,8 +61,7 @@ import { import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; -import { getClaudeModelCapabilities } from "./ClaudeProvider.ts"; -import { resolveClaudeApiModelId } from "./ClaudeModelId.ts"; +import { getClaudeModelCapabilities, resolveClaudeApiModelId } from "./ClaudeProvider.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, diff --git a/apps/server/src/provider/Layers/ClaudeModelId.test.ts b/apps/server/src/provider/Layers/ClaudeModelId.test.ts deleted file mode 100644 index 5102047fc7d7..000000000000 --- a/apps/server/src/provider/Layers/ClaudeModelId.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveClaudeApiModelId } from "./ClaudeModelId.ts"; - -describe("resolveClaudeApiModelId", () => { - it("appends [1m] for 1m context window", () => { - expect( - resolveClaudeApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "1m" }, - }), - ).toBe("claude-opus-4-6[1m]"); - }); - - it("returns the canonical slug for default context windows", () => { - expect( - resolveClaudeApiModelId({ - provider: "claudeAgent", - model: "claude-opus-4-6", - options: { contextWindow: "200k" }, - }), - ).toBe("claude-opus-4-6"); - expect(resolveClaudeApiModelId({ provider: "claudeAgent", model: "claude-opus-4-6" })).toBe( - "claude-opus-4-6", - ); - }); -}); diff --git a/apps/server/src/provider/Layers/ClaudeModelId.ts b/apps/server/src/provider/Layers/ClaudeModelId.ts deleted file mode 100644 index a4371c80a51f..000000000000 --- a/apps/server/src/provider/Layers/ClaudeModelId.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { ClaudeModelSelection } from "@t3tools/contracts"; - -export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): string { - switch (modelSelection.options?.contextWindow) { - case "1m": - return `${modelSelection.model}[1m]`; - default: - return modelSelection.model; - } -} diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 5274f84285bf..2f7ef1c776bc 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -1,5 +1,6 @@ import type { ClaudeSettings, + ClaudeModelOptions, ClaudeModelSelection, ModelCapabilities, ServerProvider, @@ -172,6 +173,25 @@ export function resolveClaudeApiModelId(modelSelection: ClaudeModelSelection): s return modelSelection.model; } } + +export function normalizeClaudeModelOptions( + model: string | null | undefined, + modelOptions: ClaudeModelOptions | null | undefined, +): ClaudeModelOptions | undefined { + const caps = getClaudeModelCapabilities(model); + const effort = resolveEffort(caps, modelOptions?.effort); + const thinking = + caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; + const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; + const contextWindow = resolveContextWindow(caps, modelOptions?.contextWindow); + const nextOptions: ClaudeModelOptions = { + ...(thinking === false ? { thinking: false } : {}), + ...(effort ? { effort: effort as ClaudeModelOptions["effort"] } : {}), + ...(fastMode ? { fastMode: true } : {}), + ...(contextWindow ? { contextWindow } : {}), + }; + return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; +} export function parseClaudeAuthStatusFromOutput(result: CommandResult): { readonly status: Exclude; readonly auth: Pick; From e39fa964219c1a1ca94f4c032d92557f38f95892 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:19:51 -0700 Subject: [PATCH 17/42] rm unused test --- .../Layers/ProviderCommandReactor.test.ts | 45 ------------------- 1 file changed, 45 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 6647209ca45d..dc17927dcfa0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -904,51 +904,6 @@ describe("ProviderCommandReactor", () => { }); }); - 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", - modelSelection: { 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-shared-slug"), - threadId: ThreadId.makeUnsafe("thread-shared-slug"), - message: { - messageId: asMessageId("user-message-shared-slug"), - role: "user", - text: "first", - attachments: [], - }, - modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, - 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); - expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ - modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, - }); - }); - it("reuses the same provider session when runtime mode is unchanged", async () => { const harness = await createHarness(); const now = new Date().toISOString(); From 8c5fc4e690f6587d92ab5d75edc33ec151c270c9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:37:51 -0700 Subject: [PATCH 18/42] kewl --- .../src/provider/Layers/CursorAdapter.ts | 7 +- apps/server/src/provider/acp/AcpErrors.ts | 65 +++++++++++++------ .../src/provider/acp/AcpJsonRpcConnection.ts | 28 ++++---- 3 files changed, 67 insertions(+), 33 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index 681f7746a5b0..804a806a2e98 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -33,6 +33,7 @@ import { Layer, Queue, Random, + Schema, Stream, } from "effect"; @@ -106,14 +107,14 @@ function mapAcpToAdapterError( method: string, error: AcpError, ): ProviderAdapterError { - if (error instanceof AcpProcessExitedError) { + if (Schema.is(AcpProcessExitedError)(error)) { return new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId, cause: error, }); } - if (error instanceof AcpRpcError) { + if (Schema.is(AcpRpcError)(error)) { return new ProviderAdapterRequestError({ provider: PROVIDER, method, @@ -535,7 +536,7 @@ function updateSessionModeState( function isMethodNotFoundRpcError(error: AcpError): boolean { return ( - error instanceof AcpRpcError && + Schema.is(AcpRpcError)(error) && (error.code === -32601 || error.message.toLowerCase().includes("method not found")) ); } diff --git a/apps/server/src/provider/acp/AcpErrors.ts b/apps/server/src/provider/acp/AcpErrors.ts index 40b35ca3167b..edbc10677d79 100644 --- a/apps/server/src/provider/acp/AcpErrors.ts +++ b/apps/server/src/provider/acp/AcpErrors.ts @@ -1,24 +1,51 @@ -import { Data } from "effect"; +import { Schema } from "effect"; -export class AcpSpawnError extends Data.TaggedError("AcpSpawnError")<{ - readonly message: string; - readonly cause?: unknown; -}> {} +export class AcpSpawnError extends Schema.TaggedErrorClass()("AcpSpawnError", { + command: Schema.optional(Schema.String), + args: Schema.optional(Schema.Array(Schema.String)), + shell: Schema.optional(Schema.Boolean), + cause: Schema.optional(Schema.Defect), +}) { + override get message() { + return `Failed to spawn ACP process: ${this.cause instanceof Error ? this.cause.message : String(this.cause)}`; + } +} -export class AcpParseError extends Data.TaggedError("AcpParseError")<{ - readonly line: string; - readonly cause?: unknown; -}> {} +export class AcpParseError extends Schema.TaggedErrorClass()("AcpParseError", { + line: Schema.String, + cause: Schema.optional(Schema.Defect), +}) { + override get message() { + return `Failed to parse ACP message: ${this.line}`; + } +} +export class AcpRpcError extends Schema.TaggedErrorClass()("AcpRpcError", { + code: Schema.Number, + errorMessage: Schema.optional(Schema.String), + data: Schema.optional(Schema.Unknown), +}) { + override get message() { + return `Failed to send ACP RPC message (code: ${this.code}, message: ${this.errorMessage}, data: ${JSON.stringify(this.data)})`; + } +} -export class AcpRpcError extends Data.TaggedError("AcpRpcError")<{ - readonly code: number; - readonly message: string; - readonly data?: unknown; -}> {} +export class AcpProcessExitedError extends Schema.TaggedErrorClass()( + "AcpProcessExitedError", + { + code: Schema.NullOr(Schema.Number), + signal: Schema.NullOr(Schema.String), + }, +) { + override get message() { + return `ACP process exited with code ${this.code} and signal ${this.signal}`; + } +} -export class AcpProcessExitedError extends Data.TaggedError("AcpProcessExitedError")<{ - readonly code: number | null; - readonly signal: NodeJS.Signals | null; -}> {} +export const AcpError = Schema.Union([ + AcpSpawnError, + AcpParseError, + AcpRpcError, + AcpProcessExitedError, +]); -export type AcpError = AcpSpawnError | AcpParseError | AcpRpcError | AcpProcessExitedError; +export type AcpError = typeof AcpError.Type; diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.ts index cc44e46af961..bb73a26f49ea 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.ts @@ -1,14 +1,25 @@ 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 { + Cause, + Deferred, + Effect, + Exit, + Queue, + Ref, + Schema, + Scope, + Semaphore, + Stream, +} from "effect"; import { + AcpError, AcpParseError, AcpProcessExitedError, AcpRpcError, AcpSpawnError, - type AcpError, } from "./AcpErrors.ts"; import { decodeAcpInboundFromJsonLine, @@ -43,24 +54,21 @@ export interface AcpJsonRpcConnection { export function spawnAcpChildProcess( input: AcpSpawnInput, ): Effect.Effect { + const shell = process.platform === "win32"; 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", + shell, }); 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, - }), + catch: (cause) => new AcpSpawnError({ command: input.command, args: input.args, shell, cause }), }); } @@ -109,7 +117,6 @@ export const attachAcpJsonRpcConnection = ( }, catch: (cause) => new AcpSpawnError({ - message: cause instanceof Error ? cause.message : String(cause), cause, }), }); @@ -176,7 +183,6 @@ export const attachAcpJsonRpcConnection = ( def, new AcpRpcError({ code: msg.error.code, - message: msg.error.message, ...(msg.error.data !== undefined ? { data: msg.error.data } : {}), }), ); @@ -203,7 +209,7 @@ export const attachAcpJsonRpcConnection = ( 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* respondError(msg.id, Schema.is(AcpError)(left) ? left.message : String(left)); } }); From 74a6a32df73017bb175385f0f1e9fc694ff3a383 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 19:51:36 -0700 Subject: [PATCH 19/42] nit --- .../Layers/ProviderSessionDirectory.ts | 17 +- apps/server/src/provider/acp/AcpTypes.ts | 2 +- apps/server/src/provider/acp/index.ts | 3 - .../CompactComposerControlsMenu.browser.tsx | 39 +--- .../chat/ProviderModelPicker.browser.tsx | 83 +------ .../components/chat/TraitsPicker.browser.tsx | 210 ------------------ .../chat/composerProviderRegistry.tsx | 11 +- 7 files changed, 22 insertions(+), 343 deletions(-) delete mode 100644 apps/server/src/provider/acp/index.ts diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 79fb4d2a271f..4cb7147180ad 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -24,14 +24,15 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex" || providerName === "claudeAgent" || providerName === "cursor") { - return Effect.succeed(providerName); - } - return Effect.fail( - new ProviderSessionDirectoryPersistenceError({ - operation, - detail: `Unknown persisted provider '${providerName}'.`, - }), + return Schema.decodeUnknownEffect(ProviderKind)(providerName).pipe( + Effect.mapError( + (cause) => + new ProviderSessionDirectoryPersistenceError({ + operation, + detail: `Unknown persisted provider '${providerName}'.`, + cause, + }), + ), ); } diff --git a/apps/server/src/provider/acp/AcpTypes.ts b/apps/server/src/provider/acp/AcpTypes.ts index 59dc7788c63b..6917c70bc432 100644 --- a/apps/server/src/provider/acp/AcpTypes.ts +++ b/apps/server/src/provider/acp/AcpTypes.ts @@ -94,7 +94,7 @@ const jsonRpcWireToInbound = SchemaTransformation.transformOrFail({ return Effect.fail( new SchemaIssue.InvalidValue(Option.some(parsed), { - title: "Unrecognized JSON-RPC inbound message shape", + message: "Unrecognized JSON-RPC inbound message shape", }), ); }, diff --git a/apps/server/src/provider/acp/index.ts b/apps/server/src/provider/acp/index.ts deleted file mode 100644 index ca1d664a7022..000000000000 --- a/apps/server/src/provider/acp/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./AcpErrors.ts"; -export * from "./AcpTypes.ts"; -export * from "./AcpJsonRpcConnection.ts"; diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index 4386c9c7ace7..a1bdf6c1710e 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -113,26 +113,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str }, }, ] - : provider === "cursor" - ? [ - { - slug: "gpt-5.3-codex", - name: "Codex 5.3", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra high" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - promptInjectedEffortLevels: [], - }, - }, - ] - : []; + : []; const screen = await render( { }); }); - it("shows Cursor reasoning controls for GPT-5.3 Codex family", async () => { - const mounted = await mountMenu({ - modelSelection: { provider: "cursor", model: "gpt-5.3-codex" }, - }); - - 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 () => { await using _ = await mountMenu({ modelSelection: { diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 9ec2f37196c0..1f6323c8aaa1 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -112,58 +112,6 @@ const TEST_PROVIDERS: ReadonlyArray = [ }, ], }, - { - provider: "cursor", - enabled: true, - installed: true, - version: "1.0.0", - status: "ready", - authStatus: "authenticated", - checkedAt: new Date().toISOString(), - models: [ - { - slug: "composer-2", - name: "Composer 2", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "gpt-5.3-codex", - name: "Codex 5.3", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - effort("low"), - effort("medium", true), - effort("high"), - effort("xhigh"), - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - { - slug: "claude-opus-4-6", - name: "Claude Opus 4.6", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: true, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }, ]; function buildCodexProvider(models: ServerProvider["models"]): ServerProvider { @@ -192,14 +140,12 @@ async function mountPicker(props: { document.body.append(host); const onProviderModelChange = vi.fn(); const providers = props.providers ?? TEST_PROVIDERS; - const modelOptionsByProvider = { - ...getCustomModelOptionsByProvider( - DEFAULT_UNIFIED_SETTINGS, - providers, - props.provider, - props.model, - ), - }; + const modelOptionsByProvider = getCustomModelOptionsByProvider( + DEFAULT_UNIFIED_SETTINGS, + providers, + props.provider, + props.model, + ); const screen = await render( { } }); - it("uses canonical Cursor slugs from the server-provided model options", async () => { - const mounted = await mountPicker({ - provider: "cursor", - model: "claude-opus-4-6", - 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/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index bf3071006dcc..61af13353163 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -4,7 +4,6 @@ import { type ModelSelection, ClaudeModelOptions, CodexModelOptions, - CursorModelOptions, DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, OpenCodeModelOptions, @@ -627,212 +626,3 @@ describe("TraitsPicker (Codex)", () => { }); }); }); - -// ── Cursor TraitsPicker tests ───────────────────────────────────────── - -async function mountCursorPicker(props?: { - model?: string; - options?: CursorModelOptions; - models?: ServerProvider["models"]; -}) { - const threadId = ThreadId.makeUnsafe("thread-cursor-traits"); - const model = props?.model ?? "gpt-5.4"; - const cursorProvider = { - provider: "cursor", - enabled: true, - installed: true, - version: "0.1.0", - status: "ready", - authStatus: "authenticated", - checkedAt: "2026-01-01T00:00:00.000Z", - models: props?.models ?? [ - { - slug: "gpt-5.4", - name: "GPT-5.4", - isCustom: false, - capabilities: { - reasoningEffortLevels: [ - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - ], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [ - { value: "272k", label: "272k", isDefault: true }, - { value: "1m", label: "1M" }, - ], - promptInjectedEffortLevels: [], - }, - }, - ], - } satisfies ServerProvider; - - const draftsByThreadId: Record = { - [threadId]: { - prompt: "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: { - cursor: { - provider: "cursor", - model, - ...(props?.options ? { options: props.options } : {}), - }, - }, - activeProvider: "cursor", - runtimeMode: null, - interactionMode: null, - }, - }; - - useComposerDraftStore.setState({ - draftsByThreadId, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: { - [ProjectId.makeUnsafe("project-cursor-traits")]: threadId, - }, - }); - const host = document.createElement("div"); - document.body.append(host); - const screen = await render( - {}} - />, - { container: host }, - ); - - const cleanup = async () => { - await screen.unmount(); - host.remove(); - }; - - return { - [Symbol.asyncDispose]: cleanup, - cleanup, - }; -} - -describe("TraitsPicker (Cursor)", () => { - afterEach(() => { - document.body.innerHTML = ""; - localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY); - useComposerDraftStore.setState({ - draftsByThreadId: {}, - draftThreadsByThreadId: {}, - projectDraftThreadIdByProjectId: {}, - stickyModelSelectionByProvider: {}, - }); - }); - - it("shows the selected Cursor context window in the trigger label", async () => { - await using _ = await mountCursorPicker({ - options: { contextWindow: "1m" }, - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Medium · 1M"); - }); - }); - - it("persists Cursor context window changes", async () => { - await using _ = await mountCursorPicker(); - - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "1M" }).click(); - - expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toMatchObject({ - provider: "cursor", - model: "gpt-5.4", - options: { - contextWindow: "1m", - }, - }); - }); - - it("does not render for models with no trait capabilities", async () => { - await using _ = await mountCursorPicker({ - model: "default", - models: [ - { - slug: "default", - name: "Auto", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: false, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }); - - expect(document.querySelector("button")).toBeNull(); - }); - - it("renders for fast-only Cursor models with a usable label", async () => { - await using _ = await mountCursorPicker({ - model: "composer-2", - models: [ - { - slug: "composer-2", - name: "Composer 2", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }); - - await vi.waitFor(() => { - expect(document.body.textContent ?? "").toContain("Normal"); - }); - }); - - it("persists fast-mode changes for fast-only Cursor models", async () => { - await using _ = await mountCursorPicker({ - model: "composer-2", - models: [ - { - slug: "composer-2", - name: "Composer 2", - isCustom: false, - capabilities: { - reasoningEffortLevels: [], - supportsFastMode: true, - supportsThinkingToggle: false, - contextWindowOptions: [], - promptInjectedEffortLevels: [], - }, - }, - ], - }); - - await page.getByRole("button").click(); - await page.getByRole("menuitemradio", { name: "on" }).click(); - - expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.cursor).toMatchObject({ - provider: "cursor", - model: "composer-2", - options: { - fastMode: true, - }, - }); - }); -}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 1e05470eaa27..96666febb1c2 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -123,12 +123,11 @@ function getProviderStateFromCapabilities( : null; // Normalize options for dispatch - const normalizedOptions = - provider === "codex" - ? normalizeCodexModelOptionsWithCapabilities(caps, providerOptions) - : provider === "cursor" - ? normalizeCursorModelOptionsWithCapabilities(caps, providerOptions) - : normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions); + const normalizedOptions = { + codex: normalizeCodexModelOptionsWithCapabilities(caps, providerOptions), + cursor: normalizeCursorModelOptionsWithCapabilities(caps, providerOptions), + claudeAgent: normalizeClaudeModelOptionsWithCapabilities(caps, providerOptions), + }[provider]; // Ultrathink styling (driven by capabilities data, not provider identity) const ultrathinkActive = From 93338cd619ab9199b9af5c6344890bc2b6dcbfd7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 26 Mar 2026 20:42:50 -0700 Subject: [PATCH 20/42] kewl --- .../src/provider/Layers/ProviderService.ts | 133 ++---------------- 1 file changed, 9 insertions(+), 124 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index a38a24655fee..fb15eb2a372b 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -158,130 +158,15 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const directory = yield* ProviderSessionDirectory; const runtimeEventPubSub = yield* PubSub.unbounded(); - const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - Effect.succeed(event).pipe( - Effect.tap((canonicalEvent) => - canonicalEventLogger - ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) - : Effect.void, - ), - Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), - Effect.asVoid, - ); - - const upsertSessionBinding = ( - session: ProviderSession, - threadId: ThreadId, - extra?: { - readonly modelSelection?: unknown; - readonly lastRuntimeEvent?: string; - readonly lastRuntimeEventAt?: string; - }, - ) => - directory.upsert({ - threadId, - provider: session.provider, - runtimeMode: session.runtimeMode, - status: toRuntimeStatus(session), - ...(session.resumeCursor !== undefined ? { resumeCursor: session.resumeCursor } : {}), - runtimePayload: toRuntimePayloadFromSession(session, extra), - }); - - const providers = yield* registry.listProviders(); - const adapters = yield* Effect.forEach(providers, (provider) => registry.getByProvider(provider)); - const processRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => - increment(providerRuntimeEventsTotal, { - provider: event.provider, - eventType: event.type, - }).pipe(Effect.andThen(publishRuntimeEvent(event))); - - yield* Effect.forEach(adapters, (adapter) => - Stream.runForEach(adapter.streamEvents, processRuntimeEvent).pipe(Effect.forkScoped), - ).pipe(Effect.asVoid); - - const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { - readonly binding: ProviderRuntimeBinding; - readonly operation: string; - }) { - yield* Effect.annotateCurrentSpan({ - "provider.operation": "recover-session", - "provider.kind": input.binding.provider, - "provider.thread_id": input.binding.threadId, - }); - return yield* Effect.gen(function* () { - const adapter = yield* registry.getByProvider(input.binding.provider); - const hasResumeCursor = - input.binding.resumeCursor !== null && input.binding.resumeCursor !== undefined; - const hasActiveSession = yield* adapter.hasSession(input.binding.threadId); - if (hasActiveSession) { - const activeSessions = yield* adapter.listSessions(); - const existing = activeSessions.find( - (session) => session.threadId === input.binding.threadId, - ); - if (existing) { - yield* upsertSessionBinding(existing, input.binding.threadId); - yield* analytics.record("provider.session.recovered", { - provider: existing.provider, - strategy: "adopt-existing", - hasResumeCursor: existing.resumeCursor !== undefined, - }); - return { adapter, session: existing } as const; - } - } - - if (!hasResumeCursor) { - return yield* toValidationError( - input.operation, - `Cannot recover thread '${input.binding.threadId}' because no provider resume state is persisted.`, - ); - } - - const persistedCwd = readPersistedCwd(input.binding.runtimePayload); - const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); - - const resumed = yield* adapter.startSession({ - threadId: input.binding.threadId, - provider: input.binding.provider, - ...(persistedCwd ? { cwd: persistedCwd } : {}), - ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), - ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), - runtimeMode: input.binding.runtimeMode ?? "full-access", - }); - if (resumed.provider !== adapter.provider) { - return yield* toValidationError( - input.operation, - `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, - ); - } - - yield* upsertSessionBinding(resumed, input.binding.threadId); - yield* analytics.record("provider.session.recovered", { - provider: resumed.provider, - strategy: "resume-thread", - hasResumeCursor: resumed.resumeCursor !== undefined, - }); - return { adapter, session: resumed } as const; - }).pipe( - withMetrics({ - counter: providerSessionsTotal, - attributes: providerMetricAttributes(input.binding.provider, { - operation: "recover", - }), - }), - ); - }); - - const resolveRoutableSession = Effect.fn("resolveRoutableSession")(function* (input: { - readonly threadId: ThreadId; - readonly operation: string; - readonly allowRecovery: boolean; - }) { - const bindingOption = yield* directory.getBinding(input.threadId); - const binding = Option.getOrUndefined(bindingOption); - if (!binding) { - return yield* toValidationError( - input.operation, - `Cannot route thread '${input.threadId}' because no persisted provider binding exists.`, + const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => + Effect.succeed(event).pipe( + Effect.tap((canonicalEvent) => + canonicalEventLogger + ? canonicalEventLogger.write(canonicalEvent, canonicalEvent.threadId) + : Effect.void, + ), + Effect.flatMap((canonicalEvent) => PubSub.publish(runtimeEventPubSub, canonicalEvent)), + Effect.asVoid, ); } const adapter = yield* registry.getByProvider(binding.provider); From eb44e0f8e4601d5cd6548b22c4dcc13c9bc4b9eb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 27 Mar 2026 01:24:34 -0700 Subject: [PATCH 21/42] Add ACP agent registry support - Wire ACP agent registry and client into server startup - Mark ACP session failures on active sendTurn errors - Add ACP adapter coverage and native event logging --- .../orchestrationEngine.integration.test.ts | 2 +- .../src/git/Layers/RoutingTextGeneration.ts | 3 +- apps/server/src/main.ts | 460 ++++ .../Layers/ProviderCommandReactor.test.ts | 69 +- .../Layers/ProviderCommandReactor.ts | 53 +- .../src/provider/Layers/AcpAdapter.test.ts | 185 ++ apps/server/src/provider/Layers/AcpAdapter.ts | 625 ++++++ .../src/provider/Layers/AcpAgentRegistry.ts | 80 + .../Layers/ProviderAdapterRegistry.test.ts | 23 +- .../Layers/ProviderAdapterRegistry.ts | 3 +- .../provider/Layers/ProviderRegistry.test.ts | 10 - .../src/provider/Services/AcpAdapter.ts | 12 + .../src/provider/Services/AcpAgentRegistry.ts | 12 + .../provider/Services/AcpRegistryClient.ts | 12 + .../provider/acp/AcpJsonRpcConnection.test.ts | 2 +- .../src/provider/acp/AcpRegistryClient.ts | 91 + apps/server/src/serverLayers.ts | 6 + apps/server/src/wsServer.test.ts | 1989 +++++++++++++++++ apps/server/src/wsServer.ts | 1072 +++++++++ apps/web/src/components/ChatView.browser.tsx | 1 + apps/web/src/components/ChatView.tsx | 188 +- .../components/KeybindingsToast.browser.tsx | 2 + .../CompactComposerControlsMenu.browser.tsx | 34 +- .../components/chat/ProviderModelPicker.tsx | 202 +- .../chat/composerProviderRegistry.tsx | 10 + apps/web/src/composerDraftStore.test.ts | 21 +- apps/web/src/composerDraftStore.ts | 570 ++++- apps/web/src/lib/serverReactQuery.ts | 35 + apps/web/src/modelSelection.ts | 43 +- apps/web/src/providerModels.ts | 3 + apps/web/src/routes/_chat.settings.tsx | 688 +++++- apps/web/src/session-logic.ts | 3 +- apps/web/src/wsNativeApi.ts | 237 ++ packages/contracts/src/acp.ts | 106 + packages/contracts/src/index.ts | 32 +- packages/contracts/src/ipc.ts | 4 +- packages/contracts/src/model.ts | 5 + packages/contracts/src/orchestration.ts | 11 +- packages/contracts/src/provider.test.ts | 8 +- packages/contracts/src/provider.ts | 5 +- packages/contracts/src/providerRuntime.ts | 4 +- packages/contracts/src/server.ts | 24 +- packages/contracts/src/settings.ts | 27 +- packages/contracts/src/ws.ts | 259 +++ 44 files changed, 7013 insertions(+), 218 deletions(-) create mode 100644 apps/server/src/main.ts create mode 100644 apps/server/src/provider/Layers/AcpAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/AcpAdapter.ts create mode 100644 apps/server/src/provider/Layers/AcpAgentRegistry.ts create mode 100644 apps/server/src/provider/Services/AcpAdapter.ts create mode 100644 apps/server/src/provider/Services/AcpAgentRegistry.ts create mode 100644 apps/server/src/provider/Services/AcpRegistryClient.ts create mode 100644 apps/server/src/provider/acp/AcpRegistryClient.ts create mode 100644 apps/server/src/wsServer.test.ts create mode 100644 apps/server/src/wsServer.ts create mode 100644 apps/web/src/lib/serverReactQuery.ts create mode 100644 apps/web/src/wsNativeApi.ts create mode 100644 packages/contracts/src/acp.ts create mode 100644 packages/contracts/src/ws.ts diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index a7f845672cad..866bb251acb0 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -106,7 +106,7 @@ function withRealCodexHarness( const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => Effect.gen(function* () { const createdAt = nowIso(); - const provider = harness.adapterHarness?.provider ?? "codex"; + const provider = (harness.adapterHarness?.provider ?? "codex") as Exclude; const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; yield* harness.engine.dispatch({ diff --git a/apps/server/src/git/Layers/RoutingTextGeneration.ts b/apps/server/src/git/Layers/RoutingTextGeneration.ts index 0d52382fdb2e..fcb54bfbbfb9 100644 --- a/apps/server/src/git/Layers/RoutingTextGeneration.ts +++ b/apps/server/src/git/Layers/RoutingTextGeneration.ts @@ -51,7 +51,8 @@ const makeRoutingTextGeneration = Effect.gen(function* () { cursor, }; - const route = (provider: TextGenerationProvider) => providerToService[provider]; + const route = (provider: string) => + providerToService[provider as TextGenerationProvider] ?? providerToService.codex; return { generateCommitMessage: (input) => diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts new file mode 100644 index 000000000000..d2371ba95e75 --- /dev/null +++ b/apps/server/src/main.ts @@ -0,0 +1,460 @@ +/** + * CliConfig - CLI/runtime bootstrap service definitions. + * + * Defines startup-only service contracts used while resolving process config + * and constructing server runtime layers. + * + * @module CliConfig + */ +import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; +import { Command, Flag } from "effect/unstable/cli"; +import { NetService } from "@t3tools/shared/Net"; +import { + DEFAULT_PORT, + deriveServerPaths, + resolveStaticDir, + ServerConfig, + type RuntimeMode, + type ServerConfigShape, +} from "./config"; +import { fixPath, resolveBaseDir } from "./os-jank"; +import { Open } from "./open"; +import * as SqlitePersistence from "./persistence/Layers/Sqlite"; +import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; +import { AcpAgentRegistryLive } from "./provider/Layers/AcpAgentRegistry"; +import { AcpRegistryClientLive } from "./provider/acp/AcpRegistryClient"; +import { Server } from "./wsServer"; +import { ServerLoggerLive } from "./serverLogger"; +import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { readBootstrapEnvelope } from "./bootstrap"; +import { ServerSettingsLive } from "./serverSettings"; + +export class StartupError extends Data.TaggedError("StartupError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); + +const BootstrapEnvelopeSchema = Schema.Struct({ + mode: Schema.optional(Schema.String), + port: Schema.optional(PortSchema), + host: Schema.optional(Schema.String), + t3Home: Schema.optional(Schema.String), + devUrl: Schema.optional(Schema.URLFromString), + noBrowser: Schema.optional(Schema.Boolean), + authToken: Schema.optional(Schema.String), + autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), + logWebSocketEvents: Schema.optional(Schema.Boolean), +}); + +interface CliInput { + readonly mode: Option.Option; + readonly port: Option.Option; + readonly host: Option.Option; + readonly t3Home: Option.Option; + readonly devUrl: Option.Option; + readonly noBrowser: Option.Option; + readonly authToken: Option.Option; + readonly bootstrapFd: Option.Option; + readonly autoBootstrapProjectFromCwd: Option.Option; + readonly logWebSocketEvents: Option.Option; +} + +/** + * CliConfigShape - Startup helpers required while building server layers. + */ +export interface CliConfigShape { + /** + * Current process working directory. + */ + readonly cwd: string; + + /** + * Apply OS-specific PATH normalization. + */ + readonly fixPath: Effect.Effect; + + /** + * Resolve static web asset directory for server mode. + */ + readonly resolveStaticDir: Effect.Effect; +} + +/** + * CliConfig - Service tag for startup CLI/runtime helpers. + */ +export class CliConfig extends ServiceMap.Service()( + "t3/main/CliConfig", +) { + static readonly layer = Layer.effect( + CliConfig, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return { + cwd: process.cwd(), + fixPath: Effect.sync(fixPath), + resolveStaticDir: resolveStaticDir().pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + } satisfies CliConfigShape; + }), + ); +} + +const CliEnvConfig = Config.all({ + mode: Config.string("T3CODE_MODE").pipe( + Config.option, + Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))), + Config.map(Option.getOrUndefined), + ), + port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), + host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), + t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), + devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), + noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + authToken: Config.string("T3CODE_AUTH_TOKEN").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), +}); + +const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => + Option.getOrElse(Option.filter(flag, Boolean), () => envValue); + +const resolveOptionPrecedence = ( + ...values: ReadonlyArray> +): Option.Option => Option.firstSomeOf(values); + +const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535; +const isRuntimeMode = (value: string): value is RuntimeMode => + value === "web" || value === "desktop"; + +const ServerConfigLive = (input: CliInput) => + Layer.effect( + ServerConfig, + Effect.gen(function* () { + const cliConfig = yield* CliConfig; + const { findAvailablePort } = yield* NetService; + const env = yield* CliEnvConfig.asEffect().pipe( + Effect.mapError( + (cause) => + new StartupError({ message: "Failed to read environment configuration", cause }), + ), + ); + + const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd; + const bootstrapEnvelope = + bootstrapFd !== undefined + ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) + : Option.none(); + + const mode: RuntimeMode = Option.getOrElse( + resolveOptionPrecedence( + input.mode, + Option.fromUndefinedOr(env.mode), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode), + ), + ), + () => "web", + ); + const port = yield* Option.match( + resolveOptionPrecedence( + input.port, + Option.fromUndefinedOr(env.port), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort), + ), + ), + { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (mode === "desktop") { + return Effect.succeed(DEFAULT_PORT); + } + return findAvailablePort(DEFAULT_PORT); + }, + }, + ); + + const devUrl = Option.getOrElse( + resolveOptionPrecedence( + input.devUrl, + Option.fromUndefinedOr(env.devUrl), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.devUrl), + ), + ), + () => undefined, + ); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined( + resolveOptionPrecedence( + input.t3Home, + Option.fromUndefinedOr(env.t3Home), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.t3Home), + ), + ), + ), + ); + const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + const noBrowser = resolveBooleanFlag( + input.noBrowser, + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.noBrowser), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.noBrowser), + ), + ), + () => mode === "desktop", + ), + ); + const authToken = resolveOptionPrecedence( + input.authToken, + Option.fromUndefinedOr(env.authToken), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.authToken), + ), + ); + const autoBootstrapProjectFromCwd = resolveBooleanFlag( + input.autoBootstrapProjectFromCwd, + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), + ), + ), + () => mode === "web", + ), + ); + const logWebSocketEvents = resolveBooleanFlag( + input.logWebSocketEvents, + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.logWebSocketEvents), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.logWebSocketEvents), + ), + ), + () => Boolean(devUrl), + ), + ); + const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; + const host = Option.getOrElse( + resolveOptionPrecedence( + input.host, + Option.fromUndefinedOr(env.host), + Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)), + ), + () => (mode === "desktop" ? "127.0.0.1" : undefined), + ); + + const config: ServerConfigShape = { + mode, + port, + cwd: cliConfig.cwd, + host, + baseDir, + ...derivedPaths, + staticDir, + devUrl, + noBrowser, + authToken: Option.getOrUndefined(authToken), + autoBootstrapProjectFromCwd, + logWebSocketEvents, + } satisfies ServerConfigShape; + + return config; + }), + ); + +const LayerLive = (input: CliInput) => + Layer.empty.pipe( + Layer.provideMerge(makeServerRuntimeServicesLayer()), + Layer.provideMerge(makeServerProviderLayer()), + Layer.provideMerge(ProviderRegistryLive), + Layer.provideMerge(AcpAgentRegistryLive), + Layer.provideMerge(AcpRegistryClientLive), + Layer.provideMerge(SqlitePersistence.layerConfig), + Layer.provideMerge(ServerLoggerLive), + Layer.provideMerge(AnalyticsServiceLayerLive), + Layer.provideMerge(ServerSettingsLive), + Layer.provideMerge(ServerConfigLive(input)), + ); + +const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +const formatHostForUrl = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + +export const recordStartupHeartbeat = Effect.gen(function* () { + const analytics = yield* AnalyticsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + + const { threadCount, projectCount } = yield* projectionSnapshotQuery.getSnapshot().pipe( + Effect.map((snapshot) => ({ + threadCount: snapshot.threads.length, + projectCount: snapshot.projects.length, + })), + Effect.catch((cause) => + Effect.logWarning("failed to gather startup snapshot for telemetry", { cause }).pipe( + Effect.as({ + threadCount: 0, + projectCount: 0, + }), + ), + ), + ); + + yield* analytics.record("server.boot.heartbeat", { + threadCount, + projectCount, + }); +}); + +const makeServerRuntimeProgram = (input: CliInput) => + Effect.gen(function* () { + const { start, stopSignal } = yield* Server; + const openDeps = yield* Open; + + const config = yield* ServerConfig; + + if (!config.devUrl && !config.staticDir) { + yield* Effect.logWarning( + "web bundle missing and no VITE_DEV_SERVER_URL; web UI unavailable", + { + hint: "Run `bun run --cwd apps/web build` or set VITE_DEV_SERVER_URL for dev mode.", + }, + ); + } + + yield* start; + yield* Effect.forkChild(recordStartupHeartbeat); + + const localUrl = `http://localhost:${config.port}`; + const bindUrl = + config.host && !isWildcardHost(config.host) + ? `http://${formatHostForUrl(config.host)}:${config.port}` + : localUrl; + const { authToken, devUrl, ...safeConfig } = config; + yield* Effect.logInfo("T3 Code running", { + ...safeConfig, + devUrl: devUrl?.toString(), + authEnabled: Boolean(authToken), + }); + + if (!config.noBrowser) { + const target = config.devUrl?.toString() ?? bindUrl; + yield* openDeps.openBrowser(target).pipe( + Effect.catch(() => + Effect.logInfo("browser auto-open unavailable", { + hint: `Open ${target} in your browser.`, + }), + ), + ); + } + + return yield* stopSignal; + }).pipe(Effect.provide(LayerLive(input))); + +const makeServerProgram = (input: CliInput) => + Effect.gen(function* () { + const cliConfig = yield* CliConfig; + yield* cliConfig.fixPath; + return yield* makeServerRuntimeProgram(input); + }); + +/** + * These flags mirrors the environment variables and the config shape. + */ + +const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( + Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), + Flag.optional, +); +const portFlag = Flag.integer("port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription("Port for the HTTP/WebSocket server."), + Flag.optional, +); +const hostFlag = Flag.string("host").pipe( + Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), + Flag.optional, +); +const t3HomeFlag = Flag.string("home-dir").pipe( + Flag.withDescription("Base directory for all T3 Code data (equivalent to T3CODE_HOME)."), + Flag.optional, +); +const devUrlFlag = Flag.string("dev-url").pipe( + Flag.withSchema(Schema.URLFromString), + Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), + Flag.optional, +); +const noBrowserFlag = Flag.boolean("no-browser").pipe( + Flag.withDescription("Disable automatic browser opening."), + Flag.optional, +); +const authTokenFlag = Flag.string("auth-token").pipe( + Flag.withDescription("Auth token required for WebSocket connections."), + Flag.withAlias("token"), + Flag.optional, +); +const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( + Flag.withSchema(Schema.Int), + Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), + Flag.optional, +); +const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( + Flag.withDescription( + "Create a project for the current working directory on startup when missing.", + ), + Flag.optional, +); +const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( + Flag.withDescription( + "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", + ), + Flag.withAlias("log-ws-events"), + Flag.optional, +); + +export const t3Cli = Command.make("t3", { + mode: modeFlag, + port: portFlag, + host: hostFlag, + t3Home: t3HomeFlag, + devUrl: devUrlFlag, + noBrowser: noBrowserFlag, + authToken: authTokenFlag, + bootstrapFd: bootstrapFdFlag, + autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, + logWebSocketEvents: logWebSocketEventsFlag, +}).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), +); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index dc17927dcfa0..7ea50a14b936 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -1273,23 +1273,30 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("full-access"); }); - it("rejects provider changes after a thread is already bound to a session provider", async () => { - const harness = await createHarness(); + it("marks the thread session errored when sendTurn fails after the session is running", async () => { + const harness = await createHarness({ + threadModelSelection: { provider: "acp", model: "default", agentServerId: "mistral-vibe" }, + }); const now = new Date().toISOString(); await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-switch-1"), - threadId: ThreadId.make("thread-1"), + commandId: CommandId.makeUnsafe("cmd-turn-start-send-failure-1"), + threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-provider-switch-1"), + messageId: asMessageId("user-message-send-failure-1"), role: "user", text: "first", attachments: [], }, + modelSelection: { + provider: "acp", + model: "default", + agentServerId: "mistral-vibe", + }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", + runtimeMode: "full-access", createdAt: now, }), ); @@ -1297,50 +1304,62 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.startSession.mock.calls.length === 1); await waitFor(() => harness.sendTurn.mock.calls.length === 1); + harness.sendTurn.mockImplementationOnce( + (_: unknown) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "acp", + method: "session/prompt", + detail: "Invalid API key. Please check your API key and try again.", + }), + ) as unknown as Effect.Effect<{ threadId: ThreadId; turnId: TurnId }, never, never>, + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.turn.start", - commandId: CommandId.make("cmd-turn-start-provider-switch-2"), - threadId: ThreadId.make("thread-1"), + commandId: CommandId.makeUnsafe("cmd-turn-start-send-failure-2"), + threadId: ThreadId.makeUnsafe("thread-1"), message: { - messageId: asMessageId("user-message-provider-switch-2"), + messageId: asMessageId("user-message-send-failure-2"), role: "user", text: "second", attachments: [], }, modelSelection: { - provider: "claudeAgent", - model: "claude-opus-4-6", + provider: "acp", + model: "default", + agentServerId: "mistral-vibe", }, interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", + runtimeMode: "full-access", createdAt: now, }), ); await waitFor(async () => { const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - return ( - thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? - false + const thread = readModel.threads.find( + (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), ); + return thread?.session?.status === "error"; }); - expect(harness.startSession.mock.calls.length).toBe(1); - expect(harness.sendTurn.mock.calls.length).toBe(1); - expect(harness.stopSession.mock.calls.length).toBe(0); - const readModel = await Effect.runPromise(harness.engine.getReadModel()); - const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); - expect(thread?.session?.threadId).toBe("thread-1"); - expect(thread?.session?.providerName).toBe("codex"); - expect(thread?.session?.runtimeMode).toBe("approval-required"); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); + expect(thread?.session).toMatchObject({ + threadId: "thread-1", + status: "error", + providerName: "acp", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: expect.stringContaining("Invalid API key"), + }); expect( thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), ).toMatchObject({ payload: { - detail: expect.stringContaining("cannot switch to 'claudeAgent'"), + detail: expect.stringContaining("Invalid API key"), }, }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ce7274b21b57..8e711ffd54db 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -205,16 +205,29 @@ const make = Effect.gen(function* () { createdAt: input.createdAt, }); - const formatFailureDetail = (cause: Cause.Cause): string => { - const failReason = cause.reasons.find(Cause.isFailReason); - const providerError = Schema.is(ProviderAdapterRequestError)(failReason?.error) - ? failReason.error - : undefined; - if (providerError) { - return providerError.detail; - } - return Cause.pretty(cause); - }; + const setThreadSessionErrorIfActive = (input: { + readonly threadId: ThreadId; + readonly detail: string; + readonly createdAt: string; + }) => + Effect.gen(function* () { + const thread = yield* resolveThread(input.threadId); + if (!thread?.session || thread.session.status === "stopped") { + return; + } + + yield* setThreadSession({ + threadId: input.threadId, + session: { + ...thread.session, + status: "error", + activeTurnId: null, + lastError: input.detail, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }); const setThreadSession = (input: { readonly threadId: ThreadId; @@ -645,8 +658,24 @@ const make = Effect.gen(function* () { interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( - Effect.map(Option.some), - Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))), + Effect.catchCause((cause) => { + const detail = Cause.pretty(cause); + return Effect.gen(function* () { + yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.start.failed", + summary: "Provider turn start failed", + detail, + turnId: null, + createdAt: event.payload.createdAt, + }); + yield* setThreadSessionErrorIfActive({ + threadId: event.payload.threadId, + detail, + createdAt: event.payload.createdAt, + }); + }); + }), ); if (Option.isNone(sendTurnRequest)) { diff --git a/apps/server/src/provider/Layers/AcpAdapter.test.ts b/apps/server/src/provider/Layers/AcpAdapter.test.ts new file mode 100644 index 000000000000..30dfb969df7f --- /dev/null +++ b/apps/server/src/provider/Layers/AcpAdapter.test.ts @@ -0,0 +1,185 @@ +import { assert, beforeEach, describe, it, vi } from "vitest"; + +import { type AcpAgentServer, ThreadId } from "@t3tools/contracts"; +import { Effect, Fiber, Layer, Queue, Stream } from "effect"; + +import type { AcpInboundMessage } from "../acp/AcpTypes.ts"; +import { AcpAdapter } from "../Services/AcpAdapter.ts"; +import { AcpAgentRegistry } from "../Services/AcpAgentRegistry.ts"; + +const transportState: { + requestCalls: Array<{ method: string; params: unknown }>; + notifyCalls: Array<{ method: string; params: unknown }>; + notificationQueue: Queue.Queue | null; +} = { + requestCalls: [], + notifyCalls: [], + notificationQueue: null, +}; + +vi.mock("../acp/AcpJsonRpcConnection.ts", async () => { + const { Effect, Queue, Stream } = await import("effect"); + + return { + spawnAcpChildProcess: vi.fn(() => + Effect.succeed({ + stdin: { end: vi.fn() }, + stdout: {}, + kill: vi.fn(), + }), + ), + attachAcpJsonRpcConnection: vi.fn(() => + Effect.gen(function* () { + const notificationQueue = yield* Queue.unbounded(); + transportState.notificationQueue = notificationQueue; + + return { + request: (method: string, params?: unknown) => + Effect.gen(function* () { + transportState.requestCalls.push({ method, params }); + switch (method) { + case "initialize": + return { protocolVersion: 1 }; + case "session/new": + return { sessionId: "session-1" }; + case "session/prompt": + yield* Queue.offer(notificationQueue, { + _tag: "notification", + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "assistant_message_chunk", + content: { type: "text", text: "hi" }, + }, + }, + }); + return { stopReason: "end_turn" }; + case "session/cancel": + return {}; + default: + return {}; + } + }), + notify: (method: string, params?: unknown) => + Effect.sync(() => { + transportState.notifyCalls.push({ method, params }); + }), + registerHandler: () => Effect.void, + notifications: Stream.fromQueue(notificationQueue), + }; + }), + ), + disposeAcpChild: vi.fn(() => undefined), + }; +}); + +import { makeAcpAdapterLive } from "./AcpAdapter.ts"; + +describe("AcpAdapterLive", () => { + beforeEach(() => { + transportState.requestCalls = []; + transportState.notifyCalls = []; + transportState.notificationQueue = null; + }); + + it("writes native ACP observability records for inbound notifications and responses", async () => { + const nativeEvents: Array<{ + event?: { + provider?: string; + kind?: string; + method?: string; + }; + }> = []; + const threadId = ThreadId.makeUnsafe("thread-acp-native"); + const agentServer: AcpAgentServer = { + id: "agent-1", + name: "Agent 1", + enabled: true, + source: "manual", + distributionType: "manual", + launch: { command: "npx", args: ["-y", "agent-1"] }, + }; + + const adapterLayer = makeAcpAdapterLive({ + nativeEventLogger: { + filePath: "memory://acp-native-events", + write: (event, loggedThreadId) => { + nativeEvents.push(event as (typeof nativeEvents)[number]); + assert.equal(loggedThreadId, threadId); + return Effect.void; + }, + close: () => Effect.void, + }, + }).pipe( + Layer.provide( + Layer.succeed(AcpAgentRegistry, { + getAgentServers: Effect.succeed([agentServer]), + listStatuses: Effect.succeed([]), + }), + ), + ); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const adapter = yield* AcpAdapter; + const session = yield* adapter.startSession({ + threadId, + provider: "acp", + modelSelection: { provider: "acp", agentServerId: "agent-1", model: "default" }, + runtimeMode: "full-access", + }); + const deltaEventFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "content.delta"), + Stream.runHead, + Effect.forkChild, + ); + const turn = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + attachments: [], + }); + yield* adapter.interruptTurn(session.threadId); + const deltaEvent = yield* Fiber.join(deltaEventFiber); + return { turn, deltaEvent }; + }).pipe(Effect.provide(adapterLayer)), + ); + + assert.equal(result.deltaEvent._tag, "Some"); + if (result.deltaEvent._tag === "Some") { + assert.equal(result.deltaEvent.value.type, "content.delta"); + if (result.deltaEvent.value.type === "content.delta") { + assert.equal(result.deltaEvent.value.payload.delta, "hi"); + } + } + assert.equal( + transportState.requestCalls.map((call) => call.method).join(","), + "initialize,session/new,session/prompt", + ); + assert.deepEqual(transportState.notifyCalls, [ + { + method: "session/cancel", + params: { sessionId: "session-1" }, + }, + ]); + assert.equal(nativeEvents.length > 0, true); + assert.equal( + nativeEvents.some( + (record) => + record.event?.provider === "acp" && + record.event?.kind === "notification" && + record.event?.method === "session/update", + ), + true, + ); + assert.equal( + nativeEvents.some( + (record) => + record.event?.provider === "acp" && + record.event?.kind === "response" && + record.event?.method === "session/prompt", + ), + true, + ); + }); +}); diff --git a/apps/server/src/provider/Layers/AcpAdapter.ts b/apps/server/src/provider/Layers/AcpAdapter.ts new file mode 100644 index 000000000000..e99792757dbc --- /dev/null +++ b/apps/server/src/provider/Layers/AcpAdapter.ts @@ -0,0 +1,625 @@ +import type { ChildProcessWithoutNullStreams } from "node:child_process"; +import nodePath from "node:path"; + +import { + ApprovalRequestId, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + RuntimeRequestId, + TurnId, + type ThreadId, +} from "@t3tools/contracts"; +import { Cause, Deferred, Effect, Exit, Fiber, Layer, Queue, Schema, Stream } from "effect"; + +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.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 { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { AcpAgentRegistry } from "../Services/AcpAgentRegistry.ts"; +import { AcpAdapter, type AcpAdapterShape } from "../Services/AcpAdapter.ts"; + +const PROVIDER = "acp" as const; +const ACP_RESUME_VERSION = 1 as const; + +export interface AcpAdapterLiveOptions { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +interface AcpSessionContext { + threadId: ThreadId; + session: ProviderSession; + child: ChildProcessWithoutNullStreams; + conn: AcpJsonRpcConnection; + acpSessionId: string; + notificationFiber?: Fiber.Fiber; + pendingApprovals: Map< + ApprovalRequestId, + { readonly decision: Deferred.Deferred } + >; + turns: Array<{ id: TurnId; items: ReadonlyArray }>; + activeTurnId?: TurnId; + stopped: boolean; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw) || raw.schemaVersion !== ACP_RESUME_VERSION) { + return undefined; + } + return typeof raw.sessionId === "string" && raw.sessionId.trim().length > 0 + ? { sessionId: raw.sessionId.trim() } + : undefined; +} + +function mapAcpToAdapterError(threadId: ThreadId, method: string, error: AcpError) { + if (Schema.is(AcpProcessExitedError)(error)) { + return new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId, cause: error }); + } + if (Schema.is(AcpRpcError)(error)) { + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: error.message, + cause: error, + }); + } + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: error.message, + cause: error, + }); +} + +function extractTextChunk(params: unknown): string | undefined { + if (!isRecord(params)) { + return undefined; + } + const update = isRecord(params.update) ? params.update : params; + if (typeof update.sessionUpdate !== "string") { + return undefined; + } + if ( + update.sessionUpdate !== "agent_message_chunk" && + update.sessionUpdate !== "assistant_message_chunk" + ) { + return undefined; + } + const content = update.content; + return isRecord(content) && content.type === "text" && typeof content.text === "string" + ? content.text + : undefined; +} + +function extractPermissionDetail(params: unknown): string | undefined { + if (!isRecord(params) || !isRecord(params.toolCall)) { + return undefined; + } + if (typeof params.toolCall.title === "string" && params.toolCall.title.trim().length > 0) { + return params.toolCall.title.trim(); + } + return undefined; +} + +function approvalOutcome(decision: ProviderApprovalDecision): string { + switch (decision) { + case "acceptForSession": + return "allow-always"; + case "accept": + return "allow-once"; + case "decline": + case "cancel": + default: + return "reject-once"; + } +} + +function makeAcpAdapter(options?: AcpAdapterLiveOptions) { + return Effect.gen(function* () { + const agentRegistry = yield* AcpAgentRegistry; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const runtimeEventQueue = yield* Queue.unbounded(); + const sessions = new Map(); + + const nowIso = Effect.sync(() => new Date().toISOString()); + const makeEventStamp = () => + Effect.gen(function* () { + return { + eventId: EventId.makeUnsafe(crypto.randomUUID()), + createdAt: yield* nowIso, + }; + }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEventQueue, event); + + const logNativeInbound = ( + threadId: ThreadId, + input: { + readonly kind: "notification" | "request" | "response" | "error"; + readonly method: string; + readonly payload: unknown; + }, + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = new Date().toISOString(); + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: crypto.randomUUID(), + kind: input.kind, + provider: PROVIDER, + createdAt: observedAt, + method: input.method, + threadId, + payload: input.payload, + }, + }, + threadId, + ); + }); + + const requestWithNativeLogging = ( + threadId: ThreadId, + conn: AcpJsonRpcConnection, + method: string, + params?: unknown, + ) => + Effect.gen(function* () { + const result = yield* Effect.exit(conn.request(method, params)); + if (Exit.isSuccess(result)) { + yield* logNativeInbound(threadId, { + kind: "response", + method, + payload: result.value, + }); + return result.value; + } + const squashed = Cause.squash(result.cause); + yield* logNativeInbound(threadId, { + kind: "error", + method, + payload: + squashed instanceof Error + ? { message: squashed.message, name: squashed.name } + : { message: String(squashed ?? "Unknown ACP error") }, + }); + return yield* Effect.failCause(result.cause); + }); + + const stopSessionInternal = (ctx: AcpSessionContext) => + 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 requireSession = (threadId: ThreadId) => { + const ctx = sessions.get(threadId); + if (!ctx) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + if (ctx.stopped) { + return Effect.fail(new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId })); + } + return Effect.succeed(ctx); + }; + + const startSession: AcpAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + const acpSelection = input.modelSelection; + if (acpSelection?.provider !== "acp") { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "ACP sessions require an ACP model selection with agentServerId.", + }); + } + const agentServers = yield* agentRegistry.getAgentServers.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const agentServer = agentServers.find( + (candidate) => candidate.id === acpSelection.agentServerId, + ); + if (!agentServer || !agentServer.enabled) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Unknown or disabled ACP agent '${acpSelection.agentServerId}'.`, + }); + } + const cwd = input.cwd?.trim() ? nodePath.resolve(input.cwd.trim()) : undefined; + const child = yield* spawnAcpChildProcess({ + command: agentServer.launch.command, + args: [...agentServer.launch.args], + ...(cwd ? { cwd } : {}), + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const conn = yield* attachAcpJsonRpcConnection(child).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to attach ACP transport.", + cause, + }), + ), + ); + + const ctx: AcpSessionContext = { + threadId: input.threadId, + session: {} as ProviderSession, + child, + conn, + acpSessionId: "", + pendingApprovals: new Map(), + turns: [], + stopped: false, + }; + + yield* conn.registerHandler("session/request_permission", (params) => + Effect.gen(function* () { + yield* logNativeInbound(input.threadId, { + kind: "request", + method: "session/request_permission", + payload: params, + }); + const requestId = ApprovalRequestId.makeUnsafe(crypto.randomUUID()); + const decision = yield* Deferred.make(); + ctx.pendingApprovals.set(requestId, { decision }); + yield* offerRuntimeEvent({ + type: "request.opened", + ...(yield* makeEventStamp()), + provider: PROVIDER, + agentServerId: agentServer.id, + threadId: input.threadId, + turnId: ctx.activeTurnId, + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { + requestType: "command_execution_approval", + detail: extractPermissionDetail(params) ?? "ACP permission request", + args: params, + }, + raw: { source: "acp.jsonrpc", method: "session/request_permission", payload: params }, + }); + const resolved = yield* Deferred.await(decision); + ctx.pendingApprovals.delete(requestId); + yield* offerRuntimeEvent({ + type: "request.resolved", + ...(yield* makeEventStamp()), + provider: PROVIDER, + agentServerId: agentServer.id, + threadId: input.threadId, + turnId: ctx.activeTurnId, + requestId: RuntimeRequestId.makeUnsafe(requestId), + payload: { requestType: "command_execution_approval", decision: resolved }, + }); + return { outcome: { outcome: "selected", optionId: approvalOutcome(resolved) } }; + }), + ); + + yield* requestWithNativeLogging(input.threadId, conn, "initialize", { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "t3-code", version: "0.0.0" }, + }).pipe( + Effect.mapError((cause) => mapAcpToAdapterError(input.threadId, "initialize", cause)), + ); + + const resume = parseResume(input.resumeCursor); + const created = resume + ? yield* Effect.exit( + requestWithNativeLogging(input.threadId, conn, "session/load", { + sessionId: resume.sessionId, + ...(cwd ? { cwd } : {}), + }), + ).pipe( + Effect.flatMap((result) => + Exit.isSuccess(result) + ? Effect.succeed(result.value) + : requestWithNativeLogging(input.threadId, conn, "session/new", { + ...(cwd ? { cwd } : {}), + mcpServers: [], + }).pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(input.threadId, "session/new", cause), + ), + ), + ), + ) + : yield* requestWithNativeLogging(input.threadId, conn, "session/new", { + ...(cwd ? { cwd } : {}), + mcpServers: [], + }).pipe( + Effect.mapError((cause) => + mapAcpToAdapterError(input.threadId, "session/new", cause), + ), + ); + + const sessionId = + resume?.sessionId ?? + (isRecord(created) && typeof created.sessionId === "string" + ? created.sessionId + : undefined); + if (!sessionId) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/new", + detail: "session/new missing sessionId", + }); + } + + const session: ProviderSession = { + provider: PROVIDER, + agentServerId: agentServer.id, + status: "ready", + runtimeMode: input.runtimeMode, + ...(cwd ? { cwd } : {}), + threadId: input.threadId, + resumeCursor: { schemaVersion: ACP_RESUME_VERSION, sessionId }, + createdAt: yield* nowIso, + updatedAt: yield* nowIso, + }; + + ctx.session = session; + ctx.acpSessionId = sessionId; + + const notificationFiber = yield* Stream.runDrain( + Stream.mapEffect(conn.notifications, (message: AcpInboundMessage) => + Effect.gen(function* () { + if (message._tag !== "notification") { + return; + } + yield* logNativeInbound(input.threadId, { + kind: "notification", + method: message.method, + payload: message.params, + }); + if (message.method !== "session/update") { + return; + } + const text = extractTextChunk(message.params); + if (!text) { + return; + } + yield* offerRuntimeEvent({ + type: "content.delta", + ...(yield* makeEventStamp()), + provider: PROVIDER, + agentServerId: agentServer.id, + threadId: input.threadId, + turnId: ctx.activeTurnId, + payload: { streamKind: "assistant_text", delta: text }, + raw: { source: "acp.jsonrpc", method: "session/update", payload: message.params }, + }); + }), + ), + ).pipe(Effect.forkChild); + + ctx.notificationFiber = notificationFiber; + sessions.set(input.threadId, ctx); + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + agentServerId: agentServer.id, + threadId: input.threadId, + payload: { message: `${agentServer.name} ACP session started` }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + agentServerId: agentServer.id, + threadId: input.threadId, + payload: { state: "ready", reason: "ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + agentServerId: agentServer.id, + threadId: input.threadId, + payload: { providerThreadId: sessionId }, + }); + + return session; + }); + + const sendTurn: AcpAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + if ( + (!input.input || input.input.trim().length === 0) && + (!input.attachments || input.attachments.length === 0) + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + const turnId = TurnId.makeUnsafe(crypto.randomUUID()); + ctx.activeTurnId = turnId; + ctx.session = { ...ctx.session, activeTurnId: turnId, updatedAt: yield* nowIso }; + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + agentServerId: ctx.session.agentServerId, + threadId: input.threadId, + turnId, + payload: {}, + }); + const promptParts = input.input?.trim() ? [{ type: "text", text: input.input.trim() }] : []; + const result = yield* requestWithNativeLogging(input.threadId, ctx.conn, "session/prompt", { + sessionId: ctx.acpSessionId, + prompt: promptParts, + }).pipe( + Effect.mapError((cause) => mapAcpToAdapterError(input.threadId, "session/prompt", cause)), + ); + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + agentServerId: ctx.session.agentServerId, + threadId: input.threadId, + turnId, + payload: { + state: "completed", + stopReason: + isRecord(result) && typeof result.stopReason === "string" ? result.stopReason : null, + }, + }); + return { threadId: input.threadId, turnId, resumeCursor: ctx.session.resumeCursor }; + }); + + const interruptTurn: AcpAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* Effect.ignore( + ctx.conn.notify("session/cancel", { + sessionId: ctx.acpSessionId, + }), + ); + }); + + const respondToRequest: AcpAdapterShape["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: AcpAdapterShape["respondToUserInput"] = ( + _threadId, + _requestId, + _answers: ProviderUserInputAnswers, + ) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_user_input", + detail: "Generic ACP adapter does not support structured user input yet.", + }), + ); + + const stopSession: AcpAdapterShape["stopSession"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.tap(() => Queue.shutdown(runtimeEventQueue)), + ), + ); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "unsupported" }, + startSession, + sendTurn, + interruptTurn, + respondToRequest, + respondToUserInput, + stopSession, + listSessions: () => + Effect.sync(() => Array.from(sessions.values(), (ctx) => ({ ...ctx.session }))), + hasSession: (threadId) => + Effect.sync(() => { + const session = sessions.get(threadId); + return session !== undefined && !session.stopped; + }), + readThread: (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }), + rollbackThread: (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }), + stopAll: () => Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }), + streamEvents: Stream.fromQueue(runtimeEventQueue), + } satisfies AcpAdapterShape; + }); +} + +export const AcpAdapterLive = Layer.effect(AcpAdapter, makeAcpAdapter()); + +export function makeAcpAdapterLive(options?: AcpAdapterLiveOptions) { + return Layer.effect(AcpAdapter, makeAcpAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/AcpAgentRegistry.ts b/apps/server/src/provider/Layers/AcpAgentRegistry.ts new file mode 100644 index 000000000000..710af8ed1ce8 --- /dev/null +++ b/apps/server/src/provider/Layers/AcpAgentRegistry.ts @@ -0,0 +1,80 @@ +import nodeFs from "node:fs"; +import nodePath from "node:path"; + +import { type AcpAgentServer, type ServerAcpAgentStatus } from "@t3tools/contracts"; +import { Effect, Layer } from "effect"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { AcpAgentRegistry, type AcpAgentRegistryShape } from "../Services/AcpAgentRegistry.ts"; + +function commandExists(command: string): boolean { + if (nodePath.isAbsolute(command)) { + try { + nodeFs.accessSync(command, nodeFs.constants.X_OK); + return true; + } catch { + return false; + } + } + + const pathValue = process.env.PATH ?? ""; + const pathEntries = pathValue.split(nodePath.delimiter).filter(Boolean); + const executableNames = + process.platform === "win32" + ? [command, `${command}.exe`, `${command}.cmd`, `${command}.bat`] + : [command]; + + return pathEntries.some((entry) => + executableNames.some((name) => { + try { + nodeFs.accessSync(nodePath.join(entry, name), nodeFs.constants.X_OK); + return true; + } catch { + return false; + } + }), + ); +} + +function toStatus(agent: AcpAgentServer, checkedAt: string): ServerAcpAgentStatus { + const installed = commandExists(agent.launch.command); + return { + agentServerId: agent.id, + displayName: agent.name, + enabled: agent.enabled, + installed, + status: agent.enabled ? (installed ? "ready" : "error") : "disabled", + authStatus: "unknown", + checkedAt, + version: agent.importedVersion ?? null, + ...(installed + ? {} + : { + message: + agent.distributionType === "binaryUnsupported" + ? "Binary ACP agents are discoverable but require manual setup in this version." + : `Command '${agent.launch.command}' was not found.`, + }), + }; +} + +const makeAcpAgentRegistry = Effect.gen(function* () { + const settings = yield* ServerSettingsService; + + const getAgentServers: AcpAgentRegistryShape["getAgentServers"] = settings.getSettings.pipe( + Effect.map((serverSettings) => serverSettings.providers.acp.agentServers), + ); + + const listStatuses: AcpAgentRegistryShape["listStatuses"] = Effect.gen(function* () { + const agents = yield* getAgentServers; + const checkedAt = new Date().toISOString(); + return agents.map((agent) => toStatus(agent, checkedAt)); + }); + + return { + getAgentServers, + listStatuses, + } satisfies AcpAgentRegistryShape; +}); + +export const AcpAgentRegistryLive = Layer.effect(AcpAgentRegistry, makeAcpAgentRegistry); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index d92293bfa1eb..ac485f743e12 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -4,6 +4,8 @@ import { assertFailure } from "@effect/vitest/utils"; import { Effect, Layer, Stream } from "effect"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { AcpAdapter, type AcpAdapterShape } from "../Services/AcpAdapter.ts"; import { ClaudeAdapter, ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts"; @@ -63,6 +65,23 @@ const fakeCursorAdapter: CursorAdapterShape = { streamEvents: Stream.empty, }; +const fakeAcpAdapter: AcpAdapterShape = { + provider: "acp", + capabilities: { sessionModelSwitch: "unsupported" }, + 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( @@ -71,6 +90,8 @@ const layer = it.layer( Layer.succeed(CodexAdapter, fakeCodexAdapter), Layer.succeed(ClaudeAdapter, fakeClaudeAdapter), Layer.succeed(CursorAdapter, fakeCursorAdapter), + Layer.succeed(AcpAdapter, fakeAcpAdapter), + ServerSettingsService.layerTest(), ), ), NodeServices.layer, @@ -89,7 +110,7 @@ layer("ProviderAdapterRegistryLive", (it) => { assert.equal(cursor, fakeCursorAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent", "cursor"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "cursor", "acp"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 78ba916461da..eeca5ffc7dd3 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -11,6 +11,7 @@ import { Effect, Layer } from "effect"; import { ProviderUnsupportedError, type ProviderAdapterError } from "../Errors.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import { AcpAdapter } from "../Services/AcpAdapter.ts"; import { ProviderAdapterRegistry, type ProviderAdapterRegistryShape, @@ -28,7 +29,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption const adapters = options?.adapters !== undefined ? options.adapters - : [yield* CodexAdapter, yield* ClaudeAdapter, yield* CursorAdapter]; + : [yield* CodexAdapter, yield* ClaudeAdapter, yield* CursorAdapter, yield* AcpAdapter]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); const getByProvider: ProviderAdapterRegistryShape["getByProvider"] = (provider) => { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 14583e68426a..ad221f7a5160 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -766,16 +766,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest()))( } return { stdout: "", stderr: "spawn ENOENT", code: 1 }; } - if (joined === "about --format json") { - return { - stdout: JSON.stringify({ - cliVersion: "2026.04.09-f2b0fcd", - userEmail: null, - }), - stderr: "", - code: 0, - }; - } if (joined === "about") { return { stdout: "", stderr: "spawn ENOENT", code: 1 }; } diff --git a/apps/server/src/provider/Services/AcpAdapter.ts b/apps/server/src/provider/Services/AcpAdapter.ts new file mode 100644 index 000000000000..88870466506b --- /dev/null +++ b/apps/server/src/provider/Services/AcpAdapter.ts @@ -0,0 +1,12 @@ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface AcpAdapterShape extends ProviderAdapterShape { + readonly provider: "acp"; +} + +export class AcpAdapter extends ServiceMap.Service()( + "t3/provider/Services/AcpAdapter", +) {} diff --git a/apps/server/src/provider/Services/AcpAgentRegistry.ts b/apps/server/src/provider/Services/AcpAgentRegistry.ts new file mode 100644 index 000000000000..ba2b701a71cf --- /dev/null +++ b/apps/server/src/provider/Services/AcpAgentRegistry.ts @@ -0,0 +1,12 @@ +import type { AcpAgentServer, ServerAcpAgentStatus } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface AcpAgentRegistryShape { + readonly listStatuses: Effect.Effect, Error>; + readonly getAgentServers: Effect.Effect, Error>; +} + +export class AcpAgentRegistry extends ServiceMap.Service()( + "t3/provider/Services/AcpAgentRegistry", +) {} diff --git a/apps/server/src/provider/Services/AcpRegistryClient.ts b/apps/server/src/provider/Services/AcpRegistryClient.ts new file mode 100644 index 000000000000..1e68b45a8df5 --- /dev/null +++ b/apps/server/src/provider/Services/AcpRegistryClient.ts @@ -0,0 +1,12 @@ +import type { AcpRegistryListResult } from "@t3tools/contracts"; +import { ServiceMap } from "effect"; +import type { Effect } from "effect"; + +export interface AcpRegistryClientShape { + readonly listAgents: Effect.Effect; +} + +export class AcpRegistryClient extends ServiceMap.Service< + AcpRegistryClient, + AcpRegistryClientShape +>()("t3/provider/Services/AcpRegistryClient") {} diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 3ad8bbb7111e..a5e630686e4e 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -32,7 +32,7 @@ describe("AcpJsonRpcConnection", () => { cwd: process.cwd(), mcpServers: [], }); - expect(newResult).toEqual({ sessionId: "mock-session-1" }); + expect(newResult).toMatchObject({ sessionId: "mock-session-1" }); const promptResult = yield* conn.request("session/prompt", { sessionId: "mock-session-1", diff --git a/apps/server/src/provider/acp/AcpRegistryClient.ts b/apps/server/src/provider/acp/AcpRegistryClient.ts new file mode 100644 index 000000000000..ef2a45433d63 --- /dev/null +++ b/apps/server/src/provider/acp/AcpRegistryClient.ts @@ -0,0 +1,91 @@ +import { + AcpRegistryIndex, + type AcpRegistryAgent, + type AcpRegistryListResult, +} from "@t3tools/contracts"; +import { Effect, Layer, Schema } from "effect"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { AcpRegistryClient, type AcpRegistryClientShape } from "../Services/AcpRegistryClient.ts"; + +class AcpRegistryClientError extends Schema.TaggedErrorClass()( + "AcpRegistryClientError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +function toLaunchSpec(agent: AcpRegistryAgent) { + if (agent.distribution.npx) { + return { + supported: true as const, + distributionType: "npx" as const, + launch: { + command: "npx", + args: ["-y", agent.distribution.npx.package, ...(agent.distribution.npx.args ?? [])], + }, + }; + } + if (agent.distribution.uvx) { + return { + supported: true as const, + distributionType: "uvx" as const, + launch: { + command: "uvx", + args: [agent.distribution.uvx.package, ...(agent.distribution.uvx.args ?? [])], + }, + }; + } + return { + supported: false as const, + distributionType: "binaryUnsupported" as const, + launch: null, + }; +} + +const makeAcpRegistryClient = Effect.gen(function* () { + const settings = yield* ServerSettingsService; + + const listAgents: AcpRegistryClientShape["listAgents"] = settings.getSettings.pipe( + Effect.flatMap((serverSettings) => + Effect.tryPromise({ + try: async () => { + const response = await fetch(serverSettings.providers.acp.registryUrl); + if (!response.ok) { + throw new Error(`Registry request failed with status ${response.status}`); + } + return response.json(); + }, + catch: (cause) => + new AcpRegistryClientError({ + detail: cause instanceof Error ? cause.message : "Failed to fetch ACP registry", + ...(cause !== undefined ? { cause } : {}), + }), + }), + ), + Effect.flatMap((raw) => Schema.decodeUnknownEffect(AcpRegistryIndex)(raw)), + Effect.map( + (registry): AcpRegistryListResult => ({ + registryVersion: registry.version, + agents: registry.agents + .map((agent) => { + const resolved = toLaunchSpec(agent); + return { + agent, + supported: resolved.supported, + distributionType: resolved.distributionType, + launch: resolved.launch, + }; + }) + .toSorted((left, right) => left.agent.name.localeCompare(right.agent.name)), + }), + ), + ); + + return { + listAgents, + } satisfies AcpRegistryClientShape; +}); + +export const AcpRegistryClientLive = Layer.effect(AcpRegistryClient, makeAcpRegistryClient); diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 6537511e730b..d5e3024fd5e7 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -20,6 +20,8 @@ import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; import { makeCodexAdapterLive } from "./provider/Layers/CodexAdapter"; import { makeCursorAdapterLive } from "./provider/Layers/CursorAdapter"; +import { makeAcpAdapterLive } from "./provider/Layers/AcpAdapter"; +import { AcpAgentRegistryLive } from "./provider/Layers/AcpAgentRegistry"; import { ProviderAdapterRegistryLive } from "./provider/Layers/ProviderAdapterRegistry"; import { makeProviderServiceLive } from "./provider/Layers/ProviderService"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory"; @@ -82,10 +84,14 @@ export function makeServerProviderLayer(): Layer.Layer< const cursorAdapterLayer = makeCursorAdapterLive( nativeEventLogger ? { nativeEventLogger } : undefined, ); + const acpAdapterLayer = makeAcpAdapterLive( + nativeEventLogger ? { nativeEventLogger } : undefined, + ).pipe(Layer.provide(AcpAgentRegistryLive)); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), Layer.provide(cursorAdapterLayer), + Layer.provide(acpAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); return makeProviderServiceLive( diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts new file mode 100644 index 000000000000..066a133c13db --- /dev/null +++ b/apps/server/src/wsServer.test.ts @@ -0,0 +1,1989 @@ +import * as Http from "node:http"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effect"; +import { describe, expect, it, afterEach, vi } from "vitest"; +import { createServer } from "./wsServer"; +import WebSocket from "ws"; +import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config"; +import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; + +import { + DEFAULT_TERMINAL_ID, + DEFAULT_SERVER_SETTINGS, + EDITORS, + EventId, + ORCHESTRATION_WS_CHANNELS, + ORCHESTRATION_WS_METHODS, + ProviderItemId, + type ServerSettings, + ThreadId, + TurnId, + WS_CHANNELS, + WS_METHODS, + type WebSocketResponse, + type ProviderRuntimeEvent, + type ServerProvider, + type KeybindingsConfig, + type ResolvedKeybindingsConfig, + type WsPushChannel, + type WsPushMessage, + type WsPush, +} from "@t3tools/contracts"; +import { compileResolvedKeybindingRule, DEFAULT_KEYBINDINGS } from "./keybindings"; +import type { + TerminalClearInput, + TerminalCloseInput, + TerminalEvent, + TerminalOpenInput, + TerminalResizeInput, + TerminalSessionSnapshot, + TerminalWriteInput, +} from "@t3tools/contracts"; +import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; +import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; +import { SqlClient, SqlError } from "effect/unstable/sql"; +import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; +import { ProviderRegistry, type ProviderRegistryShape } from "./provider/Services/ProviderRegistry"; +import { + AcpAgentRegistry, + type AcpAgentRegistryShape, +} from "./provider/Services/AcpAgentRegistry.ts"; +import { + AcpRegistryClient, + type AcpRegistryClientShape, +} from "./provider/Services/AcpRegistryClient.ts"; +import { Open, type OpenShape } from "./open"; +import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; +import type { GitCoreShape } from "./git/Services/GitCore.ts"; +import { GitCore } from "./git/Services/GitCore.ts"; +import { GitCommandError, GitManagerError } from "./git/Errors.ts"; +import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { ServerSettingsService } from "./serverSettings.ts"; + +const asEventId = (value: string): EventId => EventId.makeUnsafe(value); +const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(value); +const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); +const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); + +const defaultOpenService: OpenShape = { + openBrowser: () => Effect.void, + openInEditor: () => Effect.void, +}; + +const defaultAcpAgentRegistryService: AcpAgentRegistryShape = { + listStatuses: Effect.succeed([]), + getAgentServers: Effect.succeed([]), +}; + +const defaultAcpRegistryClientService: AcpRegistryClientShape = { + listAgents: Effect.succeed({ + registryVersion: "1.0.0", + agents: [], + }), +}; + +const defaultProviderStatuses: ReadonlyArray = [ + { + provider: "codex", + enabled: true, + installed: true, + version: "0.116.0", + status: "ready", + authStatus: "authenticated", + checkedAt: "2026-01-01T00:00:00.000Z", + models: [], + }, +]; + +const defaultProviderRegistryService: ProviderRegistryShape = { + getProviders: Effect.succeed(defaultProviderStatuses), + refresh: () => Effect.succeed(defaultProviderStatuses), + streamChanges: Stream.empty, +}; + +const defaultServerSettings = DEFAULT_SERVER_SETTINGS; + +class MockTerminalManager implements TerminalManagerShape { + private readonly sessions = new Map(); + private readonly listeners = new Set<(event: TerminalEvent) => void>(); + + private key(threadId: string, terminalId: string): string { + return `${threadId}\u0000${terminalId}`; + } + + emitEvent(event: TerminalEvent): void { + for (const listener of this.listeners) { + listener(event); + } + } + + subscriptionCount(): number { + return this.listeners.size; + } + + readonly open: TerminalManagerShape["open"] = (input: TerminalOpenInput) => + Effect.sync(() => { + const now = new Date().toISOString(); + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const snapshot: TerminalSessionSnapshot = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + status: "running", + pid: 4242, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: now, + }; + this.sessions.set(this.key(input.threadId, terminalId), snapshot); + queueMicrotask(() => { + this.emitEvent({ + type: "started", + threadId: input.threadId, + terminalId, + createdAt: now, + snapshot, + }); + }); + return snapshot; + }); + + readonly write: TerminalManagerShape["write"] = (input: TerminalWriteInput) => + Effect.sync(() => { + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const existing = this.sessions.get(this.key(input.threadId, terminalId)); + if (!existing) { + throw new Error(`Unknown terminal thread: ${input.threadId}`); + } + queueMicrotask(() => { + this.emitEvent({ + type: "output", + threadId: input.threadId, + terminalId, + createdAt: new Date().toISOString(), + data: input.data, + }); + }); + }); + + readonly resize: TerminalManagerShape["resize"] = (_input: TerminalResizeInput) => Effect.void; + + readonly clear: TerminalManagerShape["clear"] = (input: TerminalClearInput) => + Effect.sync(() => { + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + queueMicrotask(() => { + this.emitEvent({ + type: "cleared", + threadId: input.threadId, + terminalId, + createdAt: new Date().toISOString(), + }); + }); + }); + + readonly restart: TerminalManagerShape["restart"] = (input: TerminalOpenInput) => + Effect.sync(() => { + const now = new Date().toISOString(); + const terminalId = input.terminalId ?? DEFAULT_TERMINAL_ID; + const snapshot: TerminalSessionSnapshot = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + status: "running", + pid: 5252, + history: "", + exitCode: null, + exitSignal: null, + updatedAt: now, + }; + this.sessions.set(this.key(input.threadId, terminalId), snapshot); + queueMicrotask(() => { + this.emitEvent({ + type: "restarted", + threadId: input.threadId, + terminalId, + createdAt: now, + snapshot, + }); + }); + return snapshot; + }); + + readonly close: TerminalManagerShape["close"] = (input: TerminalCloseInput) => + Effect.sync(() => { + if (input.terminalId) { + this.sessions.delete(this.key(input.threadId, input.terminalId)); + return; + } + for (const key of this.sessions.keys()) { + if (key.startsWith(`${input.threadId}\u0000`)) { + this.sessions.delete(key); + } + } + }); + + readonly subscribe: TerminalManagerShape["subscribe"] = (listener) => + Effect.sync(() => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }); + + readonly dispose: TerminalManagerShape["dispose"] = Effect.void; +} + +// --------------------------------------------------------------------------- +// WebSocket test harness +// +// Incoming messages are split into two channels: +// - pushChannel: server push envelopes (type === "push") +// - responseChannel: request/response envelopes (have an "id" field) +// +// This means sendRequest never has to skip push messages and waitForPush +// never has to skip response messages, eliminating a class of ordering bugs. +// --------------------------------------------------------------------------- + +interface MessageChannel { + queue: T[]; + waiters: Array<{ + resolve: (value: T) => void; + reject: (error: Error) => void; + timeoutId: ReturnType | null; + }>; +} + +interface SocketChannels { + push: MessageChannel; + response: MessageChannel; +} + +const channelsBySocket = new WeakMap(); + +function enqueue(channel: MessageChannel, item: T) { + const waiter = channel.waiters.shift(); + if (waiter) { + if (waiter.timeoutId !== null) clearTimeout(waiter.timeoutId); + waiter.resolve(item); + return; + } + channel.queue.push(item); +} + +function dequeue(channel: MessageChannel, timeoutMs: number): Promise { + const queued = channel.queue.shift(); + if (queued !== undefined) { + return Promise.resolve(queued); + } + + return new Promise((resolve, reject) => { + const waiter = { + resolve, + reject, + timeoutId: setTimeout(() => { + const index = channel.waiters.indexOf(waiter); + if (index >= 0) channel.waiters.splice(index, 1); + reject(new Error(`Timed out waiting for WebSocket message after ${timeoutMs}ms`)); + }, timeoutMs) as ReturnType, + }; + channel.waiters.push(waiter); + }); +} + +function isWsPushEnvelope(message: unknown): message is WsPush { + if (typeof message !== "object" || message === null) return false; + if (!("type" in message) || !("channel" in message)) return false; + return (message as { type?: unknown }).type === "push"; +} + +function asWebSocketResponse(message: unknown): WebSocketResponse | null { + if (typeof message !== "object" || message === null) return null; + if (!("id" in message)) return null; + const id = (message as { id?: unknown }).id; + if (typeof id !== "string") return null; + return message as WebSocketResponse; +} + +function connectWsOnce(port: number, token?: string): Promise { + return new Promise((resolve, reject) => { + const query = token ? `?token=${encodeURIComponent(token)}` : ""; + const ws = new WebSocket(`ws://127.0.0.1:${port}/${query}`); + const channels: SocketChannels = { + push: { queue: [], waiters: [] }, + response: { queue: [], waiters: [] }, + }; + channelsBySocket.set(ws, channels); + + ws.on("message", (raw) => { + const parsed = JSON.parse(String(raw)); + if (isWsPushEnvelope(parsed)) { + enqueue(channels.push, parsed); + } else { + const response = asWebSocketResponse(parsed); + if (response) { + enqueue(channels.response, response); + } + } + }); + + ws.once("open", () => resolve(ws)); + ws.once("error", () => reject(new Error("WebSocket connection failed"))); + }); +} + +async function connectWs(port: number, token?: string, attempts = 5): Promise { + let lastError: unknown = new Error("WebSocket connection failed"); + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + return await connectWsOnce(port, token); + } catch (error) { + lastError = error; + if (attempt < attempts - 1) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + } + + throw lastError; +} + +/** Connect and wait for the server.welcome push. Returns [ws, welcomeData]. */ +async function connectAndAwaitWelcome( + port: number, + token?: string, +): Promise<[WebSocket, WsPushMessage]> { + const ws = await connectWs(port, token); + const welcome = await waitForPush(ws, WS_CHANNELS.serverWelcome); + return [ws, welcome]; +} + +async function sendRequest( + ws: WebSocket, + method: string, + params?: unknown, +): Promise { + const channels = channelsBySocket.get(ws); + if (!channels) throw new Error("WebSocket not initialized"); + + const id = crypto.randomUUID(); + const body = + method === ORCHESTRATION_WS_METHODS.dispatchCommand + ? { _tag: method, command: params } + : params && typeof params === "object" && !Array.isArray(params) + ? { _tag: method, ...(params as Record) } + : { _tag: method }; + ws.send(JSON.stringify({ id, body })); + + // Response channel only contains responses — no push filtering needed + while (true) { + const response = await dequeue(channels.response, 60_000); + if (response.id === id || response.id === "unknown") { + return response; + } + } +} + +async function waitForPush( + ws: WebSocket, + channel: C, + predicate?: (push: WsPushMessage) => boolean, + maxMessages = 120, + idleTimeoutMs = 5_000, +): Promise> { + const channels = channelsBySocket.get(ws); + if (!channels) throw new Error("WebSocket not initialized"); + + for (let remaining = maxMessages; remaining > 0; remaining--) { + const push = await dequeue(channels.push, idleTimeoutMs); + if (push.channel !== channel) continue; + const typed = push as WsPushMessage; + if (!predicate || predicate(typed)) return typed; + } + throw new Error(`Timed out waiting for push on ${channel}`); +} + +async function rewriteKeybindingsAndWaitForPush( + ws: WebSocket, + keybindingsPath: string, + contents: string, + predicate: (push: WsPushMessage) => boolean, + attempts = 3, +): Promise> { + let lastError: unknown; + for (let attempt = 0; attempt < attempts; attempt++) { + fs.writeFileSync(keybindingsPath, contents, "utf8"); + try { + return await waitForPush(ws, WS_CHANNELS.serverConfigUpdated, predicate, 20, 3_000); + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +async function requestPath( + port: number, + requestPath: string, +): Promise<{ statusCode: number; body: string }> { + return new Promise((resolve, reject) => { + const req = Http.request( + { + hostname: "127.0.0.1", + port, + path: requestPath, + method: "GET", + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + res.on("end", () => { + resolve({ + statusCode: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + }, + ); + req.once("error", reject); + req.end(); + }); +} + +function compileKeybindings(bindings: KeybindingsConfig): ResolvedKeybindingsConfig { + const resolved: Array = []; + for (const binding of bindings) { + const compiled = compileResolvedKeybindingRule(binding); + if (!compiled) { + throw new Error(`Unexpected invalid keybinding in test setup: ${binding.command}`); + } + resolved.push(compiled); + } + return resolved; +} + +const DEFAULT_RESOLVED_KEYBINDINGS = compileKeybindings([...DEFAULT_KEYBINDINGS]); +const VALID_EDITOR_IDS = new Set(EDITORS.map((editor) => editor.id)); + +function expectAvailableEditors(value: unknown): void { + expect(Array.isArray(value)).toBe(true); + for (const editorId of value as unknown[]) { + expect(typeof editorId).toBe("string"); + expect(VALID_EDITOR_IDS.has(editorId as (typeof EDITORS)[number]["id"])).toBe(true); + } +} + +function ensureParentDir(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +function deriveServerPathsSync(baseDir: string, devUrl: URL | undefined) { + return Effect.runSync( + deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer)), + ); +} + +describe("WebSocket Server", () => { + let server: Http.Server | null = null; + let serverScope: Scope.Closeable | null = null; + const connections: WebSocket[] = []; + const tempDirs: string[] = []; + + function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + async function createTestServer( + options: { + persistenceLayer?: Layer.Layer< + SqlClient.SqlClient, + SqlError.SqlError | MigrationError | PlatformError.PlatformError + >; + cwd?: string; + autoBootstrapProjectFromCwd?: boolean; + logWebSocketEvents?: boolean; + devUrl?: string; + authToken?: string; + baseDir?: string; + staticDir?: string; + providerLayer?: Layer.Layer; + providerRegistry?: ProviderRegistryShape; + open?: OpenShape; + gitManager?: GitManagerShape; + gitCore?: Pick; + terminalManager?: TerminalManagerShape; + serverSettings?: Partial; + } = {}, + ): Promise { + if (serverScope) { + throw new Error("Test server is already running"); + } + + const baseDir = options.baseDir ?? makeTempDir("t3code-ws-base-"); + const devUrl = options.devUrl ? new URL(options.devUrl) : undefined; + const derivedPaths = deriveServerPathsSync(baseDir, devUrl); + const scope = await Effect.runPromise(Scope.make("sequential")); + const persistenceLayer = options.persistenceLayer ?? SqlitePersistenceMemory; + const providerLayer = options.providerLayer ?? makeServerProviderLayer(); + const providerRegistryLayer = Layer.succeed( + ProviderRegistry, + options.providerRegistry ?? defaultProviderRegistryService, + ); + const openLayer = Layer.succeed(Open, options.open ?? defaultOpenService); + const serverConfigLayer = Layer.succeed(ServerConfig, { + mode: "web", + port: 0, + host: undefined, + cwd: options.cwd ?? "/test/project", + baseDir, + ...derivedPaths, + staticDir: options.staticDir, + devUrl, + noBrowser: true, + authToken: options.authToken, + autoBootstrapProjectFromCwd: options.autoBootstrapProjectFromCwd ?? false, + logWebSocketEvents: options.logWebSocketEvents ?? Boolean(options.devUrl), + } satisfies ServerConfigShape); + const infrastructureLayer = providerLayer.pipe(Layer.provideMerge(persistenceLayer)); + const runtimeOverrides = Layer.mergeAll( + options.gitManager ? Layer.succeed(GitManager, options.gitManager) : Layer.empty, + options.gitCore + ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) + : Layer.empty, + options.terminalManager + ? Layer.succeed(TerminalManager, options.terminalManager) + : Layer.empty, + ); + + const runtimeLayer = Layer.merge( + Layer.merge( + makeServerRuntimeServicesLayer().pipe(Layer.provide(infrastructureLayer)), + infrastructureLayer, + ), + runtimeOverrides, + ); + const dependenciesLayer = Layer.empty.pipe( + Layer.provideMerge(runtimeLayer), + Layer.provideMerge(providerRegistryLayer), + Layer.provideMerge(Layer.succeed(AcpAgentRegistry, defaultAcpAgentRegistryService)), + Layer.provideMerge(Layer.succeed(AcpRegistryClient, defaultAcpRegistryClientService)), + Layer.provideMerge(openLayer), + Layer.provideMerge(ServerSettingsService.layerTest(options.serverSettings)), + Layer.provideMerge(serverConfigLayer), + Layer.provideMerge(AnalyticsService.layerTest), + Layer.provideMerge(NodeServices.layer), + ); + const runtimeServices = await Effect.runPromise( + Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), + ); + + try { + const runtime = await Effect.runPromise( + createServer().pipe(Effect.provide(runtimeServices), Scope.provide(scope)), + ); + serverScope = scope; + return runtime; + } catch (error) { + await Effect.runPromise(Scope.close(scope, Exit.void)); + throw error; + } + } + + async function closeTestServer() { + if (!serverScope) return; + const scope = serverScope; + serverScope = null; + await Effect.runPromise(Scope.close(scope, Exit.void)); + } + + afterEach(async () => { + for (const ws of connections) { + ws.close(); + } + connections.length = 0; + await closeTestServer(); + server = null; + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); + }); + + it("sends welcome message on connect", async () => { + server = await createTestServer({ cwd: "/test/project" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const [ws, welcome] = await connectAndAwaitWelcome(port); + connections.push(ws); + + expect(welcome.type).toBe("push"); + expect(welcome.data).toEqual({ + cwd: "/test/project", + projectName: "project", + }); + }); + + it("serves persisted attachments from stateDir", async () => { + const baseDir = makeTempDir("t3code-state-attachments-"); + const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); + const attachmentPath = path.join(attachmentsDir, "thread-a", "message-a", "0.png"); + fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); + fs.writeFileSync(attachmentPath, Buffer.from("hello-attachment")); + + server = await createTestServer({ cwd: "/test/project", baseDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("image/png"); + const bytes = Buffer.from(await response.arrayBuffer()); + expect(bytes).toEqual(Buffer.from("hello-attachment")); + }); + + it("serves persisted attachments for URL-encoded paths", async () => { + const baseDir = makeTempDir("t3code-state-attachments-encoded-"); + const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); + const attachmentPath = path.join( + attachmentsDir, + "thread%20folder", + "message%20folder", + "file%20name.png", + ); + fs.mkdirSync(path.dirname(attachmentPath), { recursive: true }); + fs.writeFileSync(attachmentPath, Buffer.from("hello-encoded-attachment")); + + server = await createTestServer({ cwd: "/test/project", baseDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const response = await fetch( + `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain("image/png"); + const bytes = Buffer.from(await response.arrayBuffer()); + expect(bytes).toEqual(Buffer.from("hello-encoded-attachment")); + }); + + it("serves static index for root path", async () => { + const baseDir = makeTempDir("t3code-state-static-root-"); + const staticDir = makeTempDir("t3code-static-root-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

static-root

", "utf8"); + + server = await createTestServer({ cwd: "/test/project", baseDir, staticDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const response = await fetch(`http://127.0.0.1:${port}/`); + expect(response.status).toBe(200); + expect(await response.text()).toContain("static-root"); + }); + + it("rejects static path traversal attempts", async () => { + const baseDir = makeTempDir("t3code-state-static-traversal-"); + const staticDir = makeTempDir("t3code-static-traversal-"); + fs.writeFileSync(path.join(staticDir, "index.html"), "

safe

", "utf8"); + + server = await createTestServer({ cwd: "/test/project", baseDir, staticDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const response = await requestPath(port, "/..%2f..%2fetc/passwd"); + expect(response.statusCode).toBe(400); + expect(response.body).toBe("Invalid static file path"); + }); + + it("bootstraps the cwd project on startup when enabled", async () => { + server = await createTestServer({ + cwd: "/test/bootstrap-workspace", + autoBootstrapProjectFromCwd: true, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const [ws, welcome] = await connectAndAwaitWelcome(port); + connections.push(ws); + expect(welcome.data).toEqual( + expect.objectContaining({ + cwd: "/test/bootstrap-workspace", + projectName: "bootstrap-workspace", + bootstrapProjectId: expect.any(String), + bootstrapThreadId: expect.any(String), + }), + ); + + const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); + expect(snapshotResponse.error).toBeUndefined(); + const snapshot = snapshotResponse.result as { + projects: Array<{ + id: string; + workspaceRoot: string; + title: string; + defaultModelSelection: { + provider: string; + model: string; + } | null; + }>; + threads: Array<{ + id: string; + projectId: string; + title: string; + modelSelection: { + provider: string; + model: string; + }; + branch: string | null; + worktreePath: string | null; + }>; + }; + const bootstrapProjectId = (welcome.data as { bootstrapProjectId?: string }).bootstrapProjectId; + const bootstrapThreadId = (welcome.data as { bootstrapThreadId?: string }).bootstrapThreadId; + expect(bootstrapProjectId).toBeDefined(); + expect(bootstrapThreadId).toBeDefined(); + + expect(snapshot.projects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: bootstrapProjectId, + workspaceRoot: "/test/bootstrap-workspace", + title: "bootstrap-workspace", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + }), + ]), + ); + expect(snapshot.threads).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: bootstrapThreadId, + projectId: bootstrapProjectId, + title: "New thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + branch: null, + worktreePath: null, + }), + ]), + ); + }); + + it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { + const baseDir = makeTempDir("t3code-state-bootstrap-existing-"); + const { dbPath } = deriveServerPathsSync(baseDir, undefined); + const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe( + Layer.provide(NodeServices.layer), + ); + const cwd = "/test/bootstrap-existing"; + + server = await createTestServer({ + cwd, + baseDir, + persistenceLayer, + autoBootstrapProjectFromCwd: true, + }); + let addr = server.address(); + let port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const [firstWs, firstWelcome] = await connectAndAwaitWelcome(port); + connections.push(firstWs); + const firstBootstrapProjectId = (firstWelcome.data as { bootstrapProjectId?: string }) + .bootstrapProjectId; + const firstBootstrapThreadId = (firstWelcome.data as { bootstrapThreadId?: string }) + .bootstrapThreadId; + expect(firstBootstrapProjectId).toBeDefined(); + expect(firstBootstrapThreadId).toBeDefined(); + + firstWs.close(); + await closeTestServer(); + server = null; + + server = await createTestServer({ + cwd, + baseDir, + persistenceLayer, + autoBootstrapProjectFromCwd: true, + }); + addr = server.address(); + port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const [secondWs, secondWelcome] = await connectAndAwaitWelcome(port); + connections.push(secondWs); + expect(secondWelcome.data).toEqual( + expect.objectContaining({ + cwd, + projectName: "bootstrap-existing", + bootstrapProjectId: firstBootstrapProjectId, + bootstrapThreadId: firstBootstrapThreadId, + }), + ); + }); + + it("logs outbound websocket push events in dev mode", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => { + // Keep test output clean while verifying websocket logs. + }); + + server = await createTestServer({ + cwd: "/test/project", + devUrl: "http://localhost:5173", + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + expect(port).toBeGreaterThan(0); + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + expect( + logSpy.mock.calls.some(([message]) => { + if (typeof message !== "string") return false; + return ( + message.includes("[ws]") && + message.includes("outgoing push") && + message.includes(`channel="${WS_CHANNELS.serverWelcome}"`) + ); + }), + ).toBe(true); + }); + + it("responds to server.getConfig", async () => { + const baseDir = makeTempDir("t3code-state-get-config-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); + fs.writeFileSync(keybindingsPath, "[]", "utf8"); + + server = await createTestServer({ cwd: "/my/workspace", baseDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindingsConfigPath: keybindingsPath, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + issues: [], + providers: defaultProviderStatuses, + acpAgentServers: [], + availableEditors: expect.any(Array), + settings: defaultServerSettings, + }); + expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + }); + + it("bootstraps default keybindings file when missing", async () => { + const baseDir = makeTempDir("t3code-state-bootstrap-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + expect(fs.existsSync(keybindingsPath)).toBe(false); + + server = await createTestServer({ cwd: "/my/workspace", baseDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindingsConfigPath: keybindingsPath, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + issues: [], + providers: defaultProviderStatuses, + acpAgentServers: [], + availableEditors: expect.any(Array), + settings: defaultServerSettings, + }); + expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + + const persistedConfig = JSON.parse( + fs.readFileSync(keybindingsPath, "utf8"), + ) as KeybindingsConfig; + expect(persistedConfig).toEqual(DEFAULT_KEYBINDINGS); + }); + + it("falls back to defaults and reports malformed keybindings config issues", async () => { + const baseDir = makeTempDir("t3code-state-malformed-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); + fs.writeFileSync(keybindingsPath, "{ not-json", "utf8"); + + server = await createTestServer({ cwd: "/my/workspace", baseDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindingsConfigPath: keybindingsPath, + keybindings: DEFAULT_RESOLVED_KEYBINDINGS, + issues: [ + { + kind: "keybindings.malformed-config", + message: expect.stringContaining("expected JSON array"), + }, + ], + providers: defaultProviderStatuses, + acpAgentServers: [], + availableEditors: expect.any(Array), + settings: defaultServerSettings, + }); + expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + expect(fs.readFileSync(keybindingsPath, "utf8")).toBe("{ not-json"); + }); + + it("ignores invalid keybinding entries but keeps valid entries and reports issues", async () => { + const baseDir = makeTempDir("t3code-state-partial-invalid-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); + fs.writeFileSync( + keybindingsPath, + JSON.stringify([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+d+o", command: "terminal.new" }, + { key: "mod+x", command: "not-a-real-command" }, + ]), + "utf8", + ); + + server = await createTestServer({ cwd: "/my/workspace", baseDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + const result = response.result as { + cwd: string; + keybindingsConfigPath: string; + keybindings: ResolvedKeybindingsConfig; + issues: Array<{ kind: string; index?: number; message: string }>; + providers: ReadonlyArray; + availableEditors: unknown; + }; + expect(result.cwd).toBe("/my/workspace"); + expect(result.keybindingsConfigPath).toBe(keybindingsPath); + expect(result.issues).toEqual([ + { + kind: "keybindings.invalid-entry", + index: 1, + message: expect.any(String), + }, + { + kind: "keybindings.invalid-entry", + index: 2, + message: expect.any(String), + }, + ]); + expect(result.keybindings).toHaveLength(DEFAULT_RESOLVED_KEYBINDINGS.length); + expect(result.keybindings.some((entry) => entry.command === "terminal.toggle")).toBe(true); + expect(result.keybindings.some((entry) => entry.command === "terminal.new")).toBe(true); + expect(result.providers).toEqual(defaultProviderStatuses); + expectAvailableEditors(result.availableEditors); + }); + + it("pushes server.configUpdated issues when keybindings file changes", async () => { + const baseDir = makeTempDir("t3code-state-keybindings-watch-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); + fs.writeFileSync(keybindingsPath, "[]", "utf8"); + + server = await createTestServer({ cwd: "/my/workspace", baseDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const malformedPush = await rewriteKeybindingsAndWaitForPush( + ws, + keybindingsPath, + "{ not-json", + (push) => + Array.isArray(push.data.issues) && + Boolean(push.data.issues[0]) && + push.data.issues[0]!.kind === "keybindings.malformed-config", + ); + expect(malformedPush.data).toEqual({ + issues: [{ kind: "keybindings.malformed-config", message: expect.any(String) }], + }); + + const successPush = await rewriteKeybindingsAndWaitForPush( + ws, + keybindingsPath, + "[]", + (push) => Array.isArray(push.data.issues) && push.data.issues.length === 0, + ); + expect(successPush.data).toEqual({ issues: [] }); + }); + + it("routes shell.openInEditor through the injected open service", async () => { + const openCalls: Array<{ cwd: string; editor: string }> = []; + const openService: OpenShape = { + openBrowser: () => Effect.void, + openInEditor: (input) => { + openCalls.push({ cwd: input.cwd, editor: input.editor }); + return Effect.void; + }, + }; + + server = await createTestServer({ cwd: "/my/workspace", open: openService }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.shellOpenInEditor, { + cwd: "/my/workspace", + editor: "cursor", + }); + expect(response.error).toBeUndefined(); + expect(openCalls).toEqual([{ cwd: "/my/workspace", editor: "cursor" }]); + }); + + it("reads keybindings from the configured state directory", async () => { + const baseDir = makeTempDir("t3code-state-keybindings-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); + fs.writeFileSync( + keybindingsPath, + JSON.stringify([ + { key: "cmd+j", command: "terminal.toggle" }, + { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, + { key: "mod+n", command: "terminal.new", when: "terminalFocus" }, + ]), + "utf8", + ); + server = await createTestServer({ cwd: "/my/workspace", baseDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(response.error).toBeUndefined(); + const persistedConfig = JSON.parse( + fs.readFileSync(keybindingsPath, "utf8"), + ) as KeybindingsConfig; + expect(response.result).toEqual({ + cwd: "/my/workspace", + keybindingsConfigPath: keybindingsPath, + keybindings: compileKeybindings(persistedConfig), + issues: [], + providers: defaultProviderStatuses, + acpAgentServers: [], + availableEditors: expect.any(Array), + settings: defaultServerSettings, + }); + expectAvailableEditors((response.result as { availableEditors: unknown }).availableEditors); + }); + + it("upserts keybinding rules and updates cached server config", async () => { + const baseDir = makeTempDir("t3code-state-upsert-keybinding-"); + const { keybindingsConfigPath: keybindingsPath } = deriveServerPathsSync(baseDir, undefined); + ensureParentDir(keybindingsPath); + fs.writeFileSync( + keybindingsPath, + JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), + "utf8", + ); + + server = await createTestServer({ cwd: "/my/workspace", baseDir }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { + key: "mod+shift+r", + command: "script.run-tests.run", + }); + expect(upsertResponse.error).toBeUndefined(); + const persistedConfig = JSON.parse( + fs.readFileSync(keybindingsPath, "utf8"), + ) as KeybindingsConfig; + const persistedCommands = new Set(persistedConfig.map((entry) => entry.command)); + for (const defaultRule of DEFAULT_KEYBINDINGS) { + expect(persistedCommands.has(defaultRule.command)).toBe(true); + } + expect(persistedCommands.has("script.run-tests.run")).toBe(true); + expect(upsertResponse.result).toEqual({ + keybindings: compileKeybindings(persistedConfig), + issues: [], + }); + + const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(configResponse.error).toBeUndefined(); + expect(configResponse.result).toEqual({ + cwd: "/my/workspace", + keybindingsConfigPath: keybindingsPath, + keybindings: compileKeybindings(persistedConfig), + issues: [], + providers: defaultProviderStatuses, + acpAgentServers: [], + availableEditors: expect.any(Array), + settings: defaultServerSettings, + }); + expectAvailableEditors( + (configResponse.result as { availableEditors: unknown }).availableEditors, + ); + }); + + it("returns error for unknown methods", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, "nonexistent.method"); + expect(response.error).toBeDefined(); + expect(response.error!.message).toContain("Invalid request format"); + }); + + it("returns error when requesting turn diff for unknown thread", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { + threadId: "thread-missing", + fromTurnCount: 1, + toTurnCount: 2, + }); + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("Thread 'thread-missing' not found."); + }); + + it("returns error when requesting turn diff with an inverted range", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { + threadId: "thread-any", + fromTurnCount: 2, + toTurnCount: 1, + }); + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain( + "fromTurnCount must be less than or equal to toTurnCount", + ); + }); + + it("returns error when requesting full thread diff for unknown thread", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getFullThreadDiff, { + threadId: "thread-missing", + toTurnCount: 2, + }); + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("Thread 'thread-missing' not found."); + }); + + it("returns retryable error when requested turn exceeds current checkpoint turn count", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const workspaceRoot = makeTempDir("t3code-ws-diff-project-"); + const createdAt = new Date().toISOString(); + const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "project.create", + commandId: "cmd-diff-project-create", + projectId: "project-diff", + title: "Diff Project", + workspaceRoot, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }); + expect(createProjectResponse.error).toBeUndefined(); + const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.create", + commandId: "cmd-diff-thread-create", + threadId: "thread-diff", + projectId: "project-diff", + title: "Diff Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }); + expect(createThreadResponse.error).toBeUndefined(); + + const response = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getTurnDiff, { + threadId: "thread-diff", + fromTurnCount: 0, + toTurnCount: 1, + }); + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("exceeds current turn count"); + }); + + it("keeps orchestration domain push behavior for provider runtime events", async () => { + const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); + const emitRuntimeEvent = (event: ProviderRuntimeEvent) => { + Effect.runSync(PubSub.publish(runtimeEventPubSub, event)); + }; + const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + const providerService: ProviderServiceShape = { + startSession: (threadId) => + Effect.succeed({ + provider: "codex", + status: "ready", + runtimeMode: "full-access", + threadId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + sendTurn: ({ threadId }) => + Effect.succeed({ + threadId, + turnId: asTurnId("provider-turn-1"), + }), + interruptTurn: () => unsupported(), + respondToRequest: () => unsupported(), + respondToUserInput: () => unsupported(), + stopSession: () => unsupported(), + listSessions: () => Effect.succeed([]), + getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), + rollbackConversation: () => unsupported(), + streamEvents: Stream.fromPubSub(runtimeEventPubSub), + }; + const providerLayer = Layer.succeed(ProviderService, providerService); + + server = await createTestServer({ + cwd: "/test", + providerLayer, + serverSettings: { enableAssistantStreaming: true }, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const workspaceRoot = makeTempDir("t3code-ws-project-"); + const createdAt = new Date().toISOString(); + const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "project.create", + commandId: "cmd-ws-project-create", + projectId: "project-1", + title: "WS Project", + workspaceRoot, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }); + expect(createProjectResponse.error).toBeUndefined(); + const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.create", + commandId: "cmd-ws-runtime-thread-create", + threadId: "thread-1", + projectId: "project-1", + title: "Thread 1", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }); + expect(createThreadResponse.error).toBeUndefined(); + + const startTurnResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.turn.start", + commandId: "cmd-ws-runtime-turn-start", + threadId: "thread-1", + message: { + messageId: "msg-ws-runtime-1", + role: "user", + text: "hello", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: "default", + createdAt, + }); + expect(startTurnResponse.error).toBeUndefined(); + + await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { + const event = push.data as { type?: string }; + return event.type === "thread.session-set"; + }); + + emitRuntimeEvent({ + type: "content.delta", + eventId: asEventId("evt-ws-runtime-message-delta"), + provider: "codex", + threadId: asThreadId("thread-1"), + createdAt: new Date().toISOString(), + turnId: asTurnId("turn-1"), + itemId: asProviderItemId("item-1"), + payload: { + streamKind: "assistant_text", + delta: "hello from runtime", + }, + } as unknown as ProviderRuntimeEvent); + + const domainPush = await waitForPush(ws, ORCHESTRATION_WS_CHANNELS.domainEvent, (push) => { + const event = push.data as { type?: string; payload?: { messageId?: string; text?: string } }; + return ( + event.type === "thread.message-sent" && event.payload?.messageId === "assistant:item-1" + ); + }); + + const domainEvent = domainPush.data as { + type: string; + payload: { messageId: string; text: string }; + }; + expect(domainEvent.type).toBe("thread.message-sent"); + expect(domainEvent.payload.messageId).toBe("assistant:item-1"); + expect(domainEvent.payload.text).toBe("hello from runtime"); + }); + + it("routes terminal RPC methods and broadcasts terminal events", async () => { + const cwd = makeTempDir("t3code-ws-terminal-cwd-"); + const terminalManager = new MockTerminalManager(); + server = await createTestServer({ + cwd: "/test", + terminalManager, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const open = await sendRequest(ws, WS_METHODS.terminalOpen, { + threadId: "thread-1", + cwd, + cols: 100, + rows: 24, + }); + expect(open.error).toBeUndefined(); + expect((open.result as TerminalSessionSnapshot).threadId).toBe("thread-1"); + expect((open.result as TerminalSessionSnapshot).terminalId).toBe(DEFAULT_TERMINAL_ID); + + const write = await sendRequest(ws, WS_METHODS.terminalWrite, { + threadId: "thread-1", + data: "echo hello\n", + }); + expect(write.error).toBeUndefined(); + + const resize = await sendRequest(ws, WS_METHODS.terminalResize, { + threadId: "thread-1", + cols: 120, + rows: 30, + }); + expect(resize.error).toBeUndefined(); + + const clear = await sendRequest(ws, WS_METHODS.terminalClear, { + threadId: "thread-1", + }); + expect(clear.error).toBeUndefined(); + + const restart = await sendRequest(ws, WS_METHODS.terminalRestart, { + threadId: "thread-1", + cwd, + cols: 120, + rows: 30, + }); + expect(restart.error).toBeUndefined(); + + const close = await sendRequest(ws, WS_METHODS.terminalClose, { + threadId: "thread-1", + deleteHistory: true, + }); + expect(close.error).toBeUndefined(); + + const manualEvent: TerminalEvent = { + type: "output", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + createdAt: new Date().toISOString(), + data: "manual test output\n", + }; + terminalManager.emitEvent(manualEvent); + + const push = await waitForPush( + ws, + WS_CHANNELS.terminalEvent, + (candidate) => (candidate.data as TerminalEvent).type === "output", + ); + expect(push.type).toBe("push"); + expect(push.channel).toBe(WS_CHANNELS.terminalEvent); + }); + + it("detaches terminal event listener on stop for injected manager", async () => { + const terminalManager = new MockTerminalManager(); + server = await createTestServer({ + cwd: "/test", + terminalManager, + }); + + expect(terminalManager.subscriptionCount()).toBe(1); + + await closeTestServer(); + server = null; + + expect(terminalManager.subscriptionCount()).toBe(0); + }); + + it("returns validation errors for invalid terminal open params", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.terminalOpen, { + threadId: "", + cwd: "", + cols: 1, + rows: 1, + }); + expect(response.error).toBeDefined(); + }); + + it("handles invalid JSON gracefully", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + // Send garbage + ws.send("not json at all"); + + // Error response goes to the response channel + const channels = channelsBySocket.get(ws)!; + let response: WebSocketResponse | null = null; + for (let attempt = 0; attempt < 5; attempt += 1) { + const message = await dequeue(channels.response, 5_000); + if (message.id === "unknown") { + response = message; + break; + } + if (message.error) { + response = message; + break; + } + } + expect(response).toBeDefined(); + expect(response!.error).toBeDefined(); + expect(response!.error!.message).toContain("Invalid request format"); + }); + + it("catches websocket message handler rejections and keeps the socket usable", async () => { + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => { + unhandledRejections.push(reason); + }; + process.on("unhandledRejection", onUnhandledRejection); + + const brokenOpenService: OpenShape = { + openBrowser: () => Effect.void, + openInEditor: () => + Effect.sync(() => BigInt(1)).pipe(Effect.map((result) => result as unknown as void)), + }; + + try { + server = await createTestServer({ cwd: "/test", open: brokenOpenService }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + ws.send( + JSON.stringify({ + id: "req-broken-open", + body: { + _tag: WS_METHODS.shellOpenInEditor, + cwd: "/tmp", + editor: "cursor", + }, + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(unhandledRejections).toHaveLength(0); + + const workspace = makeTempDir("t3code-ws-handler-still-usable-"); + fs.writeFileSync(path.join(workspace, "file.txt"), "ok\n", "utf8"); + const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { + cwd: workspace, + query: "file", + limit: 5, + }); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual( + expect.objectContaining({ + entries: expect.arrayContaining([ + expect.objectContaining({ + path: "file.txt", + kind: "file", + }), + ]), + }), + ); + } finally { + process.off("unhandledRejection", onUnhandledRejection); + } + }); + + it("returns errors for removed projects CRUD methods", async () => { + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const listResponse = await sendRequest(ws, WS_METHODS.projectsList); + expect(listResponse.result).toBeUndefined(); + expect(listResponse.error?.message).toContain("Invalid request format"); + + const addResponse = await sendRequest(ws, WS_METHODS.projectsAdd, { + cwd: "/tmp/project-a", + }); + expect(addResponse.result).toBeUndefined(); + expect(addResponse.error?.message).toContain("Invalid request format"); + + const removeResponse = await sendRequest(ws, WS_METHODS.projectsRemove, { + id: "project-a", + }); + expect(removeResponse.result).toBeUndefined(); + expect(removeResponse.error?.message).toContain("Invalid request format"); + }); + + it("supports projects.searchEntries", async () => { + const workspace = makeTempDir("t3code-ws-workspace-entries-"); + fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); + fs.writeFileSync( + path.join(workspace, "src", "components", "Composer.tsx"), + "export {};", + "utf8", + ); + fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); + fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); + fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { + cwd: workspace, + query: "comp", + limit: 10, + }); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + entries: expect.arrayContaining([ + expect.objectContaining({ path: "src/components", kind: "directory" }), + expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }), + ]), + truncated: false, + }); + }); + + it("supports projects.writeFile within the workspace root", async () => { + const workspace = makeTempDir("t3code-ws-write-file-"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { + cwd: workspace, + relativePath: "plans/effect-rpc.md", + contents: "# Plan\n\n- step 1\n", + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + relativePath: "plans/effect-rpc.md", + }); + expect(fs.readFileSync(path.join(workspace, "plans", "effect-rpc.md"), "utf8")).toBe( + "# Plan\n\n- step 1\n", + ); + }); + + it("rejects projects.writeFile paths outside the workspace root", async () => { + const workspace = makeTempDir("t3code-ws-write-file-reject-"); + + server = await createTestServer({ cwd: "/test" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsWriteFile, { + cwd: workspace, + relativePath: "../escape.md", + contents: "# no\n", + }); + + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain( + "Workspace file path must stay within the project root.", + ); + expect(fs.existsSync(path.join(workspace, "..", "escape.md"))).toBe(false); + }); + + it("routes git core methods over websocket", async () => { + const listBranches = vi.fn(() => + Effect.succeed({ + branches: [], + isRepo: false, + hasOriginRemote: false, + }), + ); + const initRepo = vi.fn(() => Effect.void); + const pullCurrentBranch = vi.fn(() => + Effect.fail( + new GitCommandError({ + operation: "GitCore.test.pullCurrentBranch", + detail: "No upstream configured", + command: "git pull", + cwd: "/repo/path", + }), + ), + ); + + server = await createTestServer({ + cwd: "/test", + gitCore: { + listBranches, + initRepo, + pullCurrentBranch, + }, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const listResponse = await sendRequest(ws, WS_METHODS.gitListBranches, { cwd: "/repo/path" }); + expect(listResponse.error).toBeUndefined(); + expect(listResponse.result).toEqual({ branches: [], isRepo: false, hasOriginRemote: false }); + expect(listBranches).toHaveBeenCalledWith({ cwd: "/repo/path" }); + + const initResponse = await sendRequest(ws, WS_METHODS.gitInit, { cwd: "/repo/path" }); + expect(initResponse.error).toBeUndefined(); + expect(initRepo).toHaveBeenCalledWith({ cwd: "/repo/path" }); + + const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" }); + expect(pullResponse.result).toBeUndefined(); + expect(pullResponse.error?.message).toContain("No upstream configured"); + expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); + }); + + it("supports git.status over websocket", async () => { + const statusResult = { + branch: "feature/test", + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "src/index.ts", insertions: 7, deletions: 2 }], + insertions: 7, + deletions: 2, + }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + pr: null, + }; + + const status = vi.fn(() => Effect.succeed(statusResult)); + const runStackedAction = vi.fn(() => Effect.void as any); + const resolvePullRequest = vi.fn(() => Effect.void as any); + const preparePullRequestThread = vi.fn(() => Effect.void as any); + const gitManager: GitManagerShape = { + status, + resolvePullRequest, + preparePullRequestThread, + runStackedAction, + }; + + server = await createTestServer({ cwd: "/test", gitManager }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.gitStatus, { + cwd: "/test", + }); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual(statusResult); + expect(status).toHaveBeenCalledWith({ cwd: "/test" }); + }); + + it("supports git pull request routing over websocket", async () => { + const resolvePullRequestResult = { + pullRequest: { + number: 42, + title: "PR thread flow", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseBranch: "main", + headBranch: "feature/pr-threads", + state: "open" as const, + }, + }; + const preparePullRequestThreadResult = { + ...resolvePullRequestResult, + branch: "feature/pr-threads", + worktreePath: "/tmp/pr-threads", + }; + + const gitManager: GitManagerShape = { + status: vi.fn(() => Effect.void as any), + resolvePullRequest: vi.fn(() => Effect.succeed(resolvePullRequestResult)), + preparePullRequestThread: vi.fn(() => Effect.succeed(preparePullRequestThreadResult)), + runStackedAction: vi.fn(() => Effect.void as any), + }; + + server = await createTestServer({ cwd: "/test", gitManager }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const resolveResponse = await sendRequest(ws, WS_METHODS.gitResolvePullRequest, { + cwd: "/test", + reference: "#42", + }); + expect(resolveResponse.error).toBeUndefined(); + expect(resolveResponse.result).toEqual(resolvePullRequestResult); + + const prepareResponse = await sendRequest(ws, WS_METHODS.gitPreparePullRequestThread, { + cwd: "/test", + reference: "42", + mode: "worktree", + }); + expect(prepareResponse.error).toBeUndefined(); + expect(prepareResponse.result).toEqual(preparePullRequestThreadResult); + expect(gitManager.resolvePullRequest).toHaveBeenCalledWith({ + cwd: "/test", + reference: "#42", + }); + expect(gitManager.preparePullRequestThread).toHaveBeenCalledWith({ + cwd: "/test", + reference: "42", + mode: "worktree", + }); + }); + + it("returns errors from git.runStackedAction", async () => { + const runStackedAction = vi.fn(() => + Effect.fail( + new GitManagerError({ + operation: "GitManager.test.runStackedAction", + detail: "Cannot push from detached HEAD.", + }), + ), + ); + const gitManager: GitManagerShape = { + status: vi.fn(() => Effect.void as any), + resolvePullRequest: vi.fn(() => Effect.void as any), + preparePullRequestThread: vi.fn(() => Effect.void as any), + runStackedAction, + }; + + server = await createTestServer({ cwd: "/test", gitManager }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const response = await sendRequest(ws, WS_METHODS.gitRunStackedAction, { + actionId: "client-action-1", + cwd: "/test", + action: "commit_push", + modelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, + }); + expect(response.result).toBeUndefined(); + expect(response.error?.message).toContain("detached HEAD"); + expect(runStackedAction).toHaveBeenCalledWith( + { + actionId: "client-action-1", + cwd: "/test", + action: "commit_push", + }, + expect.objectContaining({ + actionId: "client-action-1", + progressReporter: expect.any(Object), + }), + ); + }); + + it("publishes git action progress only to the initiating websocket", async () => { + const runStackedAction = vi.fn( + (_input, options) => + options?.progressReporter + ?.publish({ + actionId: options.actionId ?? "action-1", + cwd: "/test", + action: "commit", + kind: "phase_started", + phase: "commit", + label: "Committing...", + }) + .pipe( + Effect.flatMap(() => + Effect.succeed({ + action: "commit" as const, + branch: { status: "skipped_not_requested" as const }, + commit: { + status: "created" as const, + commitSha: "abc1234", + subject: "Test commit", + }, + push: { status: "skipped_not_requested" as const }, + pr: { status: "skipped_not_requested" as const }, + }), + ), + ) ?? Effect.void, + ); + const gitManager: GitManagerShape = { + status: vi.fn(() => Effect.void as any), + resolvePullRequest: vi.fn(() => Effect.void as any), + preparePullRequestThread: vi.fn(() => Effect.void as any), + runStackedAction, + }; + + server = await createTestServer({ cwd: "/test", gitManager }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [initiatingWs] = await connectAndAwaitWelcome(port); + const [otherWs] = await connectAndAwaitWelcome(port); + connections.push(initiatingWs, otherWs); + + const responsePromise = sendRequest(initiatingWs, WS_METHODS.gitRunStackedAction, { + actionId: "client-action-2", + cwd: "/test", + action: "commit", + modelSelection: { + provider: "codex", + model: "gpt-5.4-mini", + }, + }); + const progressPush = await waitForPush(initiatingWs, WS_CHANNELS.gitActionProgress); + + expect(progressPush.data).toEqual({ + actionId: "client-action-2", + cwd: "/test", + action: "commit", + kind: "phase_started", + phase: "commit", + label: "Committing...", + }); + + await expect( + waitForPush(otherWs, WS_CHANNELS.gitActionProgress, undefined, 10, 100), + ).rejects.toThrow("Timed out waiting for WebSocket message after 100ms"); + await expect(responsePromise).resolves.toEqual( + expect.objectContaining({ + result: expect.objectContaining({ + action: "commit", + }), + }), + ); + }); + + it("rejects websocket connections without a valid auth token", async () => { + server = await createTestServer({ cwd: "/test", authToken: "secret-token" }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + await expect(connectWs(port)).rejects.toThrow("WebSocket connection failed"); + + const [authorizedWs] = await connectAndAwaitWelcome(port, "secret-token"); + connections.push(authorizedWs); + }); +}); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts new file mode 100644 index 000000000000..d79d6f06c164 --- /dev/null +++ b/apps/server/src/wsServer.ts @@ -0,0 +1,1072 @@ +/** + * Server - HTTP/WebSocket server service interface. + * + * Owns startup and shutdown lifecycle of the HTTP server, static asset serving, + * and WebSocket request routing. + * + * @module Server + */ +import http from "node:http"; +import type { Duplex } from "node:stream"; + +import Mime from "@effect/platform-node/Mime"; +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + type ClientOrchestrationCommand, + type OrchestrationCommand, + ORCHESTRATION_WS_CHANNELS, + ORCHESTRATION_WS_METHODS, + PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, + ProjectId, + ThreadId, + WS_CHANNELS, + WS_METHODS, + WebSocketRequest, + type WsResponse as WsResponseMessage, + WsResponse, + type WsPushEnvelopeBase, +} from "@t3tools/contracts"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Path, + Ref, + Result, + Schema, + Scope, + ServiceMap, + Stream, + Struct, +} from "effect"; +import { WebSocketServer, type WebSocket } from "ws"; + +import { createLogger } from "./logger"; +import { GitManager } from "./git/Services/GitManager.ts"; +import { TerminalManager } from "./terminal/Services/Manager.ts"; +import { Keybindings } from "./keybindings"; +import { ServerSettingsService } from "./serverSettings"; +import { searchWorkspaceEntries } from "./workspaceEntries"; +import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine"; +import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; +import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; +import { ProviderService } from "./provider/Services/ProviderService"; +import { ProviderRegistry } from "./provider/Services/ProviderRegistry"; +import { AcpAgentRegistry } from "./provider/Services/AcpAgentRegistry.ts"; +import { AcpRegistryClient } from "./provider/Services/AcpRegistryClient.ts"; +import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery"; +import { clamp } from "effect/Number"; +import { Open, resolveAvailableEditors } from "./open"; +import { ServerConfig } from "./config"; +import { GitCore } from "./git/Services/GitCore.ts"; +import { tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; +import { + ATTACHMENTS_ROUTE_PREFIX, + normalizeAttachmentRelativePath, + resolveAttachmentRelativePath, +} from "./attachmentPaths"; + +import { + createAttachmentId, + resolveAttachmentPath, + resolveAttachmentPathById, +} from "./attachmentStore.ts"; +import { parseBase64DataUrl } from "./imageMime.ts"; +import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; +import { expandHomePath } from "./os-jank.ts"; +import { makeServerPushBus } from "./wsServer/pushBus.ts"; +import { makeServerReadiness } from "./wsServer/readiness.ts"; +import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; + +/** + * ServerShape - Service API for server lifecycle control. + */ +export interface ServerShape { + /** + * Start HTTP and WebSocket listeners. + */ + readonly start: Effect.Effect< + http.Server, + ServerLifecycleError, + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + >; + + /** + * Wait for process shutdown signals. + */ + readonly stopSignal: Effect.Effect; +} + +/** + * Server - Service tag for HTTP/WebSocket lifecycle management. + */ +export class Server extends ServiceMap.Service()("t3/wsServer/Server") {} + +const isServerNotRunningError = (error: Error): boolean => { + const maybeCode = (error as NodeJS.ErrnoException).code; + return ( + maybeCode === "ERR_SERVER_NOT_RUNNING" || error.message.toLowerCase().includes("not running") + ); +}; + +function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { + socket.end( + `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + + "Connection: close\r\n" + + "Content-Type: text/plain\r\n" + + `Content-Length: ${Buffer.byteLength(message)}\r\n` + + "\r\n" + + message, + ); +} + +function websocketRawToString(raw: unknown): string | null { + if (typeof raw === "string") { + return raw; + } + if (raw instanceof Uint8Array) { + return Buffer.from(raw).toString("utf8"); + } + if (raw instanceof ArrayBuffer) { + return Buffer.from(new Uint8Array(raw)).toString("utf8"); + } + if (Array.isArray(raw)) { + const chunks: string[] = []; + for (const chunk of raw) { + if (typeof chunk === "string") { + chunks.push(chunk); + continue; + } + if (chunk instanceof Uint8Array) { + chunks.push(Buffer.from(chunk).toString("utf8")); + continue; + } + if (chunk instanceof ArrayBuffer) { + chunks.push(Buffer.from(new Uint8Array(chunk)).toString("utf8")); + continue; + } + return null; + } + return chunks.join(""); + } + return null; +} + +function toPosixRelativePath(input: string): string { + return input.replaceAll("\\", "/"); +} + +function resolveWorkspaceWritePath(params: { + workspaceRoot: string; + relativePath: string; + path: Path.Path; +}): Effect.Effect<{ absolutePath: string; relativePath: string }, RouteRequestError> { + const normalizedInputPath = params.relativePath.trim(); + if (params.path.isAbsolute(normalizedInputPath)) { + return Effect.fail( + new RouteRequestError({ + message: "Workspace file path must be relative to the project root.", + }), + ); + } + + const absolutePath = params.path.resolve(params.workspaceRoot, normalizedInputPath); + const relativeToRoot = toPosixRelativePath( + params.path.relative(params.workspaceRoot, absolutePath), + ); + if ( + relativeToRoot.length === 0 || + relativeToRoot === "." || + relativeToRoot.startsWith("../") || + relativeToRoot === ".." || + params.path.isAbsolute(relativeToRoot) + ) { + return Effect.fail( + new RouteRequestError({ + message: "Workspace file path must stay within the project root.", + }), + ); + } + + return Effect.succeed({ + absolutePath, + relativePath: relativeToRoot, + }); +} + +function stripRequestTag(body: T) { + return Struct.omit(body, ["_tag"]); +} + +const encodeWsResponse = Schema.encodeEffect(Schema.fromJsonString(WsResponse)); +const decodeWebSocketRequest = decodeJsonResult(WebSocketRequest); + +export type ServerCoreRuntimeServices = + | OrchestrationEngineService + | ProjectionSnapshotQuery + | CheckpointDiffQuery + | OrchestrationReactor + | ProviderService + | ProviderRegistry + | AcpAgentRegistry + | AcpRegistryClient; + +export type ServerRuntimeServices = + | ServerCoreRuntimeServices + | GitManager + | GitCore + | TerminalManager + | Keybindings + | ServerSettingsService + | Open + | AnalyticsService; + +export class ServerLifecycleError extends Schema.TaggedErrorClass()( + "ServerLifecycleError", + { + operation: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) {} + +class RouteRequestError extends Schema.TaggedErrorClass()("RouteRequestError", { + message: Schema.String, +}) {} + +export const createServer = Effect.fn(function* (): Effect.fn.Return< + http.Server, + ServerLifecycleError, + Scope.Scope | ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path +> { + const serverConfig = yield* ServerConfig; + const { + port, + cwd, + keybindingsConfigPath, + staticDir, + devUrl, + authToken, + host, + logWebSocketEvents, + autoBootstrapProjectFromCwd, + } = serverConfig; + const availableEditors = resolveAvailableEditors(); + + const gitManager = yield* GitManager; + const terminalManager = yield* TerminalManager; + const keybindingsManager = yield* Keybindings; + const serverSettingsManager = yield* ServerSettingsService; + const providerRegistry = yield* ProviderRegistry; + const acpAgentRegistry = yield* AcpAgentRegistry; + const acpRegistryClient = yield* AcpRegistryClient; + const git = yield* GitCore; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + yield* keybindingsManager.syncDefaultKeybindingsOnStartup.pipe( + Effect.catch((error) => + Effect.logWarning("failed to sync keybindings defaults on startup", { + path: error.configPath, + detail: error.detail, + cause: error.cause, + }), + ), + ); + + const providersRef = yield* Ref.make(yield* providerRegistry.getProviders); + + const clients = yield* Ref.make(new Set()); + const logger = createLogger("ws"); + const readiness = yield* makeServerReadiness; + + function logOutgoingPush(push: WsPushEnvelopeBase, recipients: number) { + if (!logWebSocketEvents) return; + logger.event("outgoing push", { + channel: push.channel, + sequence: push.sequence, + recipients, + payload: push.data, + }); + } + + const pushBus = yield* makeServerPushBus({ + clients, + logOutgoingPush, + }); + yield* readiness.markPushBusReady; + yield* keybindingsManager.start.pipe( + Effect.mapError( + (cause) => new ServerLifecycleError({ operation: "keybindingsRuntimeStart", cause }), + ), + ); + yield* readiness.markKeybindingsReady; + yield* serverSettingsManager.start.pipe( + Effect.mapError( + (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }), + ), + ); + + const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { + readonly command: ClientOrchestrationCommand; + }) { + const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) { + const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim())); + const workspaceStat = yield* fileSystem + .stat(normalizedWorkspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!workspaceStat) { + return yield* new RouteRequestError({ + message: `Project directory does not exist: ${normalizedWorkspaceRoot}`, + }); + } + if (workspaceStat.type !== "Directory") { + return yield* new RouteRequestError({ + message: `Project path is not a directory: ${normalizedWorkspaceRoot}`, + }); + } + return normalizedWorkspaceRoot; + }); + + if (input.command.type === "project.create") { + return { + ...input.command, + workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), + } satisfies OrchestrationCommand; + } + + if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { + return { + ...input.command, + workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), + } satisfies OrchestrationCommand; + } + + if (input.command.type !== "thread.turn.start") { + return input.command as OrchestrationCommand; + } + const turnStartCommand = input.command; + + const normalizedAttachments = yield* Effect.forEach( + turnStartCommand.message.attachments, + (attachment) => + Effect.gen(function* () { + const parsed = parseBase64DataUrl(attachment.dataUrl); + if (!parsed || !parsed.mimeType.startsWith("image/")) { + return yield* new RouteRequestError({ + message: `Invalid image attachment payload for '${attachment.name}'.`, + }); + } + + const bytes = Buffer.from(parsed.base64, "base64"); + if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + return yield* new RouteRequestError({ + message: `Image attachment '${attachment.name}' is empty or too large.`, + }); + } + + const attachmentId = createAttachmentId(turnStartCommand.threadId); + if (!attachmentId) { + return yield* new RouteRequestError({ + message: "Failed to create a safe attachment id.", + }); + } + + const persistedAttachment = { + type: "image" as const, + id: attachmentId, + name: attachment.name, + mimeType: parsed.mimeType.toLowerCase(), + sizeBytes: bytes.byteLength, + }; + + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment: persistedAttachment, + }); + if (!attachmentPath) { + return yield* new RouteRequestError({ + message: `Failed to resolve persisted path for '${attachment.name}'.`, + }); + } + + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( + Effect.mapError( + () => + new RouteRequestError({ + message: `Failed to create attachment directory for '${attachment.name}'.`, + }), + ), + ); + yield* fileSystem.writeFile(attachmentPath, bytes).pipe( + Effect.mapError( + () => + new RouteRequestError({ + message: `Failed to persist attachment '${attachment.name}'.`, + }), + ), + ); + + return persistedAttachment; + }), + { concurrency: 1 }, + ); + + return { + ...turnStartCommand, + message: { + ...turnStartCommand.message, + attachments: normalizedAttachments, + }, + } satisfies OrchestrationCommand; + }); + + // HTTP server — serves static files or redirects to Vite dev server + const httpServer = http.createServer((req, res) => { + const respond = ( + statusCode: number, + headers: Record, + body?: string | Uint8Array, + ) => { + res.writeHead(statusCode, headers); + res.end(body); + }; + + void Effect.runPromise( + Effect.gen(function* () { + const url = new URL(req.url ?? "/", `http://localhost:${port}`); + if (tryHandleProjectFaviconRequest(url, res)) { + return; + } + + if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { + const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); + const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); + if (!normalizedRelativePath) { + respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); + return; + } + + const isIdLookup = + !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes("."); + const filePath = isIdLookup + ? resolveAttachmentPathById({ + attachmentsDir: serverConfig.attachmentsDir, + attachmentId: normalizedRelativePath, + }) + : resolveAttachmentRelativePath({ + attachmentsDir: serverConfig.attachmentsDir, + relativePath: normalizedRelativePath, + }); + if (!filePath) { + respond( + isIdLookup ? 404 : 400, + { "Content-Type": "text/plain" }, + isIdLookup ? "Not Found" : "Invalid attachment path", + ); + return; + } + + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": "public, max-age=31536000, immutable", + }); + const streamExit = yield* Stream.runForEach(fileSystem.stream(filePath), (chunk) => + Effect.sync(() => { + if (!res.destroyed) { + res.write(chunk); + } + }), + ).pipe(Effect.exit); + if (Exit.isFailure(streamExit)) { + if (!res.destroyed) { + res.destroy(); + } + return; + } + if (!res.writableEnded) { + res.end(); + } + return; + } + + // In dev mode, redirect to Vite dev server + if (devUrl) { + respond(302, { Location: devUrl.href }); + return; + } + + // Serve static files from the web app build + if (!staticDir) { + respond( + 503, + { "Content-Type": "text/plain" }, + "No static directory configured and no dev URL set.", + ); + return; + } + + const staticRoot = path.resolve(staticDir); + const staticRequestPath = url.pathname === "/" ? "/index.html" : url.pathname; + const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, ""); + const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith(".."); + const staticRelativePath = path.normalize(rawStaticRelativePath).replace(/^[/\\]+/, ""); + const hasPathTraversalSegment = staticRelativePath.startsWith(".."); + if ( + staticRelativePath.length === 0 || + hasRawLeadingParentSegment || + hasPathTraversalSegment || + staticRelativePath.includes("\0") + ) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + + const isWithinStaticRoot = (candidate: string) => + candidate === staticRoot || + candidate.startsWith( + staticRoot.endsWith(path.sep) ? staticRoot : `${staticRoot}${path.sep}`, + ); + + let filePath = path.resolve(staticRoot, staticRelativePath); + if (!isWithinStaticRoot(filePath)) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + + const ext = path.extname(filePath); + if (!ext) { + filePath = path.resolve(filePath, "index.html"); + if (!isWithinStaticRoot(filePath)) { + respond(400, { "Content-Type": "text/plain" }, "Invalid static file path"); + return; + } + } + + const fileInfo = yield* fileSystem + .stat(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!fileInfo || fileInfo.type !== "File") { + const indexPath = path.resolve(staticRoot, "index.html"); + const indexData = yield* fileSystem + .readFile(indexPath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!indexData) { + respond(404, { "Content-Type": "text/plain" }, "Not Found"); + return; + } + respond(200, { "Content-Type": "text/html; charset=utf-8" }, indexData); + return; + } + + const contentType = Mime.getType(filePath) ?? "application/octet-stream"; + const data = yield* fileSystem + .readFile(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!data) { + respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); + return; + } + respond(200, { "Content-Type": contentType }, data); + }), + ).catch(() => { + if (!res.headersSent) { + respond(500, { "Content-Type": "text/plain" }, "Internal Server Error"); + } + }); + }); + + // WebSocket server — upgrades from the HTTP server + const wss = new WebSocketServer({ noServer: true }); + + const closeWebSocketServer = Effect.callback((resume) => { + wss.close((error) => { + if (error && !isServerNotRunningError(error)) { + resume( + Effect.fail( + new ServerLifecycleError({ operation: "closeWebSocketServer", cause: error }), + ), + ); + } else { + resume(Effect.void); + } + }); + }); + + const closeAllClients = Ref.get(clients).pipe( + Effect.flatMap(Effect.forEach((client) => Effect.sync(() => client.close()))), + Effect.flatMap(() => Ref.set(clients, new Set())), + ); + + const listenOptions = host ? { host, port } : { port }; + + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionReadModelQuery = yield* ProjectionSnapshotQuery; + const checkpointDiffQuery = yield* CheckpointDiffQuery; + const orchestrationReactor = yield* OrchestrationReactor; + const { openInEditor } = yield* Open; + + const subscriptionsScope = yield* Scope.make("sequential"); + yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); + + yield* Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => + pushBus.publishAll(ORCHESTRATION_WS_CHANNELS.domainEvent, event), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(keybindingsManager.streamChanges, (event) => + pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: event.issues, + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(serverSettingsManager.streamChanges, (settings) => + pushBus.publishAll(WS_CHANNELS.serverConfigUpdated, { + issues: [], + settings, + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Stream.runForEach(providerRegistry.streamChanges, (providers) => + Effect.gen(function* () { + yield* Ref.set(providersRef, providers); + yield* pushBus.publishAll(WS_CHANNELS.serverProvidersUpdated, { + providers, + }); + }), + ).pipe(Effect.forkIn(subscriptionsScope)); + + yield* Scope.provide(orchestrationReactor.start, subscriptionsScope); + yield* readiness.markOrchestrationSubscriptionsReady; + + let welcomeBootstrapProjectId: ProjectId | undefined; + let welcomeBootstrapThreadId: ThreadId | undefined; + + if (autoBootstrapProjectFromCwd) { + yield* Effect.gen(function* () { + const snapshot = yield* projectionReadModelQuery.getSnapshot(); + const existingProject = snapshot.projects.find( + (project) => project.workspaceRoot === cwd && project.deletedAt === null, + ); + let bootstrapProjectId: ProjectId; + let bootstrapProjectDefaultModelSelection; + + if (!existingProject) { + const createdAt = new Date().toISOString(); + bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); + const bootstrapProjectTitle = path.basename(cwd) || "project"; + bootstrapProjectDefaultModelSelection = { + provider: "codex" as const, + model: "gpt-5-codex", + }; + yield* orchestrationEngine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + projectId: bootstrapProjectId, + title: bootstrapProjectTitle, + workspaceRoot: cwd, + defaultModelSelection: bootstrapProjectDefaultModelSelection, + createdAt, + }); + } else { + bootstrapProjectId = existingProject.id; + bootstrapProjectDefaultModelSelection = existingProject.defaultModelSelection ?? { + provider: "codex" as const, + model: "gpt-5-codex", + }; + } + + const existingThread = snapshot.threads.find( + (thread) => thread.projectId === bootstrapProjectId && thread.deletedAt === null, + ); + if (!existingThread) { + const createdAt = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe(crypto.randomUUID()); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + threadId, + projectId: bootstrapProjectId, + title: "New thread", + modelSelection: bootstrapProjectDefaultModelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + welcomeBootstrapProjectId = bootstrapProjectId; + welcomeBootstrapThreadId = threadId; + } else { + welcomeBootstrapProjectId = bootstrapProjectId; + welcomeBootstrapThreadId = existingThread.id; + } + }).pipe( + Effect.mapError( + (cause) => new ServerLifecycleError({ operation: "autoBootstrapProject", cause }), + ), + ); + } + + const runtimeServices = yield* Effect.services< + ServerRuntimeServices | ServerConfig | FileSystem.FileSystem | Path.Path + >(); + const runPromise = Effect.runPromiseWith(runtimeServices); + + const unsubscribeTerminalEvents = yield* terminalManager.subscribe( + (event) => void Effect.runPromise(pushBus.publishAll(WS_CHANNELS.terminalEvent, event)), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => unsubscribeTerminalEvents())); + yield* readiness.markTerminalSubscriptionsReady; + + yield* NodeHttpServer.make(() => httpServer, listenOptions).pipe( + Effect.mapError((cause) => new ServerLifecycleError({ operation: "httpServerListen", cause })), + ); + yield* readiness.markHttpListening; + + yield* Effect.addFinalizer(() => + Effect.all([closeAllClients, closeWebSocketServer.pipe(Effect.ignoreCause({ log: true }))]), + ); + + const routeRequest = Effect.fnUntraced(function* (ws: WebSocket, request: WebSocketRequest) { + switch (request.body._tag) { + case ORCHESTRATION_WS_METHODS.getSnapshot: + return yield* projectionReadModelQuery.getSnapshot(); + + case ORCHESTRATION_WS_METHODS.dispatchCommand: { + const { command } = request.body; + const normalizedCommand = yield* normalizeDispatchCommand({ command }); + return yield* orchestrationEngine.dispatch(normalizedCommand); + } + + case ORCHESTRATION_WS_METHODS.getTurnDiff: { + const body = stripRequestTag(request.body); + return yield* checkpointDiffQuery.getTurnDiff(body); + } + + case ORCHESTRATION_WS_METHODS.getFullThreadDiff: { + const body = stripRequestTag(request.body); + return yield* checkpointDiffQuery.getFullThreadDiff(body); + } + + case ORCHESTRATION_WS_METHODS.replayEvents: { + const { fromSequenceExclusive } = request.body; + return yield* Stream.runCollect( + orchestrationEngine.readEvents( + clamp(fromSequenceExclusive, { + maximum: Number.MAX_SAFE_INTEGER, + minimum: 0, + }), + ), + ).pipe(Effect.map((events) => Array.from(events))); + } + + case WS_METHODS.projectsSearchEntries: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise({ + try: () => searchWorkspaceEntries(body), + catch: (cause) => + new RouteRequestError({ + message: `Failed to search workspace entries: ${String(cause)}`, + }), + }); + } + + case WS_METHODS.projectsWriteFile: { + const body = stripRequestTag(request.body); + const target = yield* resolveWorkspaceWritePath({ + workspaceRoot: body.cwd, + relativePath: body.relativePath, + path, + }); + yield* fileSystem + .makeDirectory(path.dirname(target.absolutePath), { recursive: true }) + .pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to prepare workspace path: ${String(cause)}`, + }), + ), + ); + yield* fileSystem.writeFileString(target.absolutePath, body.contents).pipe( + Effect.mapError( + (cause) => + new RouteRequestError({ + message: `Failed to write workspace file: ${String(cause)}`, + }), + ), + ); + return { relativePath: target.relativePath }; + } + + case WS_METHODS.shellOpenInEditor: { + const body = stripRequestTag(request.body); + return yield* openInEditor(body); + } + + case WS_METHODS.gitStatus: { + const body = stripRequestTag(request.body); + return yield* gitManager.status(body); + } + + case WS_METHODS.gitPull: { + const body = stripRequestTag(request.body); + return yield* git.pullCurrentBranch(body.cwd); + } + + case WS_METHODS.gitRunStackedAction: { + const body = stripRequestTag(request.body); + return yield* gitManager.runStackedAction(body, { + actionId: body.actionId, + progressReporter: { + publish: (event) => + pushBus.publishClient(ws, WS_CHANNELS.gitActionProgress, event).pipe(Effect.asVoid), + }, + }); + } + + case WS_METHODS.gitResolvePullRequest: { + const body = stripRequestTag(request.body); + return yield* gitManager.resolvePullRequest(body); + } + + case WS_METHODS.gitPreparePullRequestThread: { + const body = stripRequestTag(request.body); + return yield* gitManager.preparePullRequestThread(body); + } + + case WS_METHODS.gitListBranches: { + const body = stripRequestTag(request.body); + return yield* git.listBranches(body); + } + + case WS_METHODS.gitCreateWorktree: { + const body = stripRequestTag(request.body); + return yield* git.createWorktree(body); + } + + case WS_METHODS.gitRemoveWorktree: { + const body = stripRequestTag(request.body); + return yield* git.removeWorktree(body); + } + + case WS_METHODS.gitCreateBranch: { + const body = stripRequestTag(request.body); + return yield* git.createBranch(body); + } + + case WS_METHODS.gitCheckout: { + const body = stripRequestTag(request.body); + return yield* Effect.scoped(git.checkoutBranch(body)); + } + + case WS_METHODS.gitInit: { + const body = stripRequestTag(request.body); + return yield* git.initRepo(body); + } + + case WS_METHODS.terminalOpen: { + const body = stripRequestTag(request.body); + return yield* terminalManager.open(body); + } + + case WS_METHODS.terminalWrite: { + const body = stripRequestTag(request.body); + return yield* terminalManager.write(body); + } + + case WS_METHODS.terminalResize: { + const body = stripRequestTag(request.body); + return yield* terminalManager.resize(body); + } + + case WS_METHODS.terminalClear: { + const body = stripRequestTag(request.body); + return yield* terminalManager.clear(body); + } + + case WS_METHODS.terminalRestart: { + const body = stripRequestTag(request.body); + return yield* terminalManager.restart(body); + } + + case WS_METHODS.terminalClose: { + const body = stripRequestTag(request.body); + return yield* terminalManager.close(body); + } + + case WS_METHODS.serverGetConfig: { + const keybindingsConfig = yield* keybindingsManager.loadConfigState; + const settings = yield* serverSettingsManager.getSettings; + const providers = yield* Ref.get(providersRef); + const acpAgentServers = yield* acpAgentRegistry.listStatuses; + return { + cwd, + keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + acpAgentServers, + availableEditors, + settings, + }; + } + + case WS_METHODS.serverRefreshProviders: { + const providers = yield* providerRegistry.refresh(); + yield* Ref.set(providersRef, providers); + return { providers }; + } + + case WS_METHODS.serverUpsertKeybinding: { + const body = stripRequestTag(request.body); + const keybindingsConfig = yield* keybindingsManager.upsertKeybindingRule(body); + return { keybindings: keybindingsConfig, issues: [] }; + } + + case WS_METHODS.serverGetSettings: { + return yield* serverSettingsManager.getSettings; + } + + case WS_METHODS.serverUpdateSettings: { + const body = stripRequestTag(request.body); + return yield* serverSettingsManager.updateSettings(body.patch); + } + + case WS_METHODS.serverListAcpRegistry: { + return yield* acpRegistryClient.listAgents; + } + + default: { + const _exhaustiveCheck: never = request.body; + return yield* new RouteRequestError({ + message: `Unknown method: ${String(_exhaustiveCheck)}`, + }); + } + } + }); + + const handleMessage = Effect.fnUntraced(function* (ws: WebSocket, raw: unknown) { + const sendWsResponse = (response: WsResponseMessage) => + encodeWsResponse(response).pipe( + Effect.tap((encodedResponse) => Effect.sync(() => ws.send(encodedResponse))), + Effect.asVoid, + ); + + const messageText = websocketRawToString(raw); + if (messageText === null) { + return yield* sendWsResponse({ + id: "unknown", + error: { message: "Invalid request format: Failed to read message" }, + }); + } + + const request = decodeWebSocketRequest(messageText); + if (Result.isFailure(request)) { + return yield* sendWsResponse({ + id: "unknown", + error: { message: `Invalid request format: ${formatSchemaError(request.failure)}` }, + }); + } + + const result = yield* Effect.exit(routeRequest(ws, request.success)); + if (Exit.isFailure(result)) { + return yield* sendWsResponse({ + id: request.success.id, + error: { message: Cause.pretty(result.cause) }, + }); + } + + return yield* sendWsResponse({ + id: request.success.id, + result: result.value, + }); + }); + + httpServer.on("upgrade", (request, socket, head) => { + socket.on("error", () => {}); // Prevent unhandled `EPIPE`/`ECONNRESET` from crashing the process if the client disconnects mid-handshake + + if (authToken) { + let providedToken: string | null = null; + try { + const url = new URL(request.url ?? "/", `http://localhost:${port}`); + providedToken = url.searchParams.get("token"); + } catch { + rejectUpgrade(socket, 400, "Invalid WebSocket URL"); + return; + } + + if (providedToken !== authToken) { + rejectUpgrade(socket, 401, "Unauthorized WebSocket connection"); + return; + } + } + + wss.handleUpgrade(request, socket, head, (ws) => { + wss.emit("connection", ws, request); + }); + }); + + wss.on("connection", (ws) => { + const segments = cwd.split(/[/\\]/).filter(Boolean); + const projectName = segments[segments.length - 1] ?? "project"; + + const welcomeData = { + cwd, + projectName, + ...(welcomeBootstrapProjectId ? { bootstrapProjectId: welcomeBootstrapProjectId } : {}), + ...(welcomeBootstrapThreadId ? { bootstrapThreadId: welcomeBootstrapThreadId } : {}), + }; + // Send welcome before adding to broadcast set so publishAll calls + // cannot reach this client before the welcome arrives. + void runPromise( + readiness.awaitServerReady.pipe( + Effect.flatMap(() => pushBus.publishClient(ws, WS_CHANNELS.serverWelcome, welcomeData)), + Effect.flatMap((delivered) => + delivered ? Ref.update(clients, (clients) => clients.add(ws)) : Effect.void, + ), + ), + ); + + ws.on("message", (raw) => { + void runPromise(handleMessage(ws, raw).pipe(Effect.ignoreCause({ log: true }))); + }); + + ws.on("close", () => { + void runPromise( + Ref.update(clients, (clients) => { + clients.delete(ws); + return clients; + }), + ); + }); + + ws.on("error", () => { + void runPromise( + Ref.update(clients, (clients) => { + clients.delete(ws); + return clients; + }), + ); + }); + }); + + return httpServer; +}); + +export const ServerLive = Layer.succeed(Server, { + start: createServer(), + stopSignal: Effect.never, +} satisfies ServerShape); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 41f627332e3a..5282bd037143 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -178,6 +178,7 @@ function createBaseServerConfig(): ServerConfig { skills: [], }, ], + acpAgentServers: [], availableEditors: [], observability: { logsDirectoryPath: "/repo/project/.t3/logs", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7bcfbeea7128..cd8b264b61e8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -104,7 +104,12 @@ import { import { newCommandId, newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; -import { resolveAppModelSelection } from "../modelSelection"; +import { + getModelSelectionOptions, + isBuiltInProviderKind, + resolveAppModelSelection, + resolveBuiltInSelectableProvider, +} from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings } from "../logicalProject"; import { @@ -1031,19 +1036,71 @@ export default function ChatView(props: ChatViewProps) { const activeEnvRuntimeState = useSavedEnvironmentRuntimeStore((s) => activeThread?.environmentId ? s.byId[activeThread.environmentId] : null, ); - // Use the server config for the thread's environment. For the primary - // environment fall back to the global atom; for remote environments use - // the runtime state stored by the environment manager. - const serverConfig = - primaryEnvironmentId && activeThread?.environmentId === primaryEnvironmentId - ? primaryServerConfig - : (activeEnvRuntimeState?.serverConfig ?? primaryServerConfig); - const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; + const lockedProvider: ProviderKind | null = hasThreadStarted + ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) + : null; + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const providerStatuses = serverConfigQuery.data?.providers ?? EMPTY_PROVIDERS; + const acpAgents = settings.providers.acp.enabled + ? settings.providers.acp.agentServers.filter((agent) => agent.enabled) + : []; const unlockedSelectedProvider = resolveSelectableProvider( providerStatuses, selectedProviderByThreadId ?? threadProvider ?? "codex", ); const selectedProvider: ProviderKind = lockedProvider ?? unlockedSelectedProvider; + const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ + threadId, + providers: providerStatuses, + selectedProvider, + threadModelSelection: activeThread?.modelSelection, + projectModelSelection: activeProject?.defaultModelSelection, + settings, + }); + const selectedProviderModels = getProviderModels(providerStatuses, selectedProvider); + const composerProviderState = useMemo( + () => + getComposerProviderState({ + provider: selectedProvider, + model: selectedModel, + models: selectedProviderModels, + prompt, + modelOptions: composerModelOptions, + }), + [composerModelOptions, prompt, selectedModel, selectedProvider, selectedProviderModels], + ); + const selectedPromptEffort = composerProviderState.promptEffort; + const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; + const selectedAcpAgentId = + (composerDraft.modelSelectionByProvider.acp?.provider === "acp" + ? composerDraft.modelSelectionByProvider.acp.agentServerId + : undefined) ?? + (activeThread?.modelSelection.provider === "acp" + ? activeThread.modelSelection.agentServerId + : undefined) ?? + (activeProject?.defaultModelSelection?.provider === "acp" + ? activeProject.defaultModelSelection.agentServerId + : undefined) ?? + acpAgents[0]?.id ?? + null; + const selectedModelSelection = useMemo( + () => + selectedProvider === "acp" + ? { + provider: "acp", + agentServerId: selectedAcpAgentId ?? "default", + model: "default", + } + : { + provider: selectedProvider, + model: selectedModel, + ...(selectedModelOptionsForDispatch + ? { options: selectedModelOptionsForDispatch } + : {}), + }, + [selectedAcpAgentId, selectedModel, selectedModelOptionsForDispatch, selectedProvider], + ); + const selectedModelForPicker = selectedModel; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( @@ -1430,6 +1487,7 @@ export default function ChatView(props: ChatViewProps) { claudeAgent: providerStatuses.find((provider) => provider.provider === "claudeAgent")?.models ?? [], cursor: providerStatuses.find((provider) => provider.provider === "cursor")?.models ?? [], + acp: [], }), [providerStatuses], ); @@ -2062,8 +2120,8 @@ export default function ChatView(props: ChatViewProps) { input.modelSelection !== undefined && (input.modelSelection.model !== serverThread.modelSelection.model || input.modelSelection.provider !== serverThread.modelSelection.provider || - JSON.stringify(input.modelSelection.options ?? null) !== - JSON.stringify(serverThread.modelSelection.options ?? null)) + JSON.stringify(getModelSelectionOptions(input.modelSelection) ?? null) !== + JSON.stringify(getModelSelectionOptions(serverThread.modelSelection) ?? null)) ) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", @@ -2653,14 +2711,70 @@ export default function ChatView(props: ChatViewProps) { titleSeed = "New thread"; } } - const title = truncate(titleSeed); - const threadCreateModelSelection = createModelSelection( - ctxSelectedProvider, - ctxSelectedModel || - activeProject.defaultModelSelection?.model || - DEFAULT_MODEL_BY_PROVIDER.codex, - ctxSelectedModelSelection.options, - ); + const title = truncateTitle(titleSeed); + let threadCreateModelSelection: ModelSelection; + if (selectedProvider === "acp") { + threadCreateModelSelection = { + provider: "acp", + agentServerId: + selectedModelSelection.provider === "acp" + ? selectedModelSelection.agentServerId + : "default", + model: selectedModelSelection.model, + }; + } else { + const selectedOptions = getModelSelectionOptions(selectedModelSelection); + threadCreateModelSelection = { + provider: selectedProvider, + model: + selectedModel || + activeProject.defaultModelSelection?.model || + DEFAULT_MODEL_BY_PROVIDER.codex, + ...(selectedOptions ? { options: selectedOptions } : {}), + } as ModelSelection; + } + + if (isLocalDraftThread) { + await api.orchestration.dispatchCommand({ + type: "thread.create", + commandId: newCommandId(), + threadId: threadIdForSend, + projectId: activeProject.id, + title, + modelSelection: threadCreateModelSelection, + runtimeMode, + interactionMode, + branch: nextThreadBranch, + worktreePath: nextThreadWorktreePath, + createdAt: activeThread.createdAt, + }); + createdServerThreadForLocalDraft = true; + } + + let setupScript: ProjectScript | null = null; + if (baseBranchForWorktree) { + setupScript = setupProjectScript(activeProject.scripts); + } + if (setupScript) { + let shouldRunSetupScript = false; + if (isServerThread) { + shouldRunSetupScript = true; + } else { + if (createdServerThreadForLocalDraft) { + shouldRunSetupScript = true; + } + } + if (shouldRunSetupScript) { + const setupScriptOptions: Parameters[1] = { + worktreePath: nextThreadWorktreePath, + rememberAsLastInvoked: false, + }; + if (nextThreadWorktreePath) { + setupScriptOptions.cwd = nextThreadWorktreePath; + } + await runProjectScript(setupScript, setupScriptOptions); + } + } // Auto-title from first message if (isFirstMessage && isServerThread) { @@ -3224,7 +3338,11 @@ export default function ChatView(props: ChatViewProps) { scheduleComposerFocus(); return; } - const resolvedProvider = resolveSelectableProvider(providerStatuses, provider); + if (!isBuiltInProviderKind(provider)) { + scheduleComposerFocus(); + return; + } + const resolvedProvider = resolveBuiltInSelectableProvider(providerStatuses, provider); const resolvedModel = resolveAppModelSelection( resolvedProvider, settings, @@ -3252,10 +3370,29 @@ export default function ChatView(props: ChatViewProps) { settings, ], ); - const onEnvModeChange = useCallback( - (mode: DraftThreadEnvMode) => { - if (canOverrideServerThreadEnvMode) { - setPendingServerThreadEnvMode(mode); + const onAcpAgentSelect = useCallback( + (agentServerId: string) => { + if (!activeThread) return; + const nextModelSelection: ModelSelection = { + provider: "acp", + agentServerId, + model: "default", + }; + setComposerDraftModelSelection(activeThread.id, nextModelSelection); + setStickyComposerModelSelection(nextModelSelection); + scheduleComposerFocus(); + }, + [ + activeThread, + scheduleComposerFocus, + setComposerDraftModelSelection, + setStickyComposerModelSelection, + ], + ); + const setPromptFromTraits = useCallback( + (nextPrompt: string) => { + const currentPrompt = promptRef.current; + if (nextPrompt === currentPrompt) { scheduleComposerFocus(); return; } @@ -3631,6 +3768,8 @@ export default function ChatView(props: ChatViewProps) { lockedProvider={lockedProvider} providers={providerStatuses} modelOptionsByProvider={modelOptionsByProvider} + acpAgents={acpAgents} + activeAcpAgentId={selectedAcpAgentId} {...(composerProviderState.modelPickerIconClassName ? { activeProviderIconClassName: @@ -3638,6 +3777,7 @@ export default function ChatView(props: ChatViewProps) { } : {})} onProviderModelChange={onProviderModelSelect} + onAcpAgentSelect={onAcpAgentSelect} /> {isComposerFooterCompact ? ( diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 4d5653596bb7..c3418546752b 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -84,6 +84,7 @@ function createBaseServerConfig(): ServerConfig { skills: [], }, ], + acpAgentServers: [], availableEditors: [], observability: { logsDirectoryPath: "/repo/project/.t3/logs", @@ -100,6 +101,7 @@ function createBaseServerConfig(): ServerConfig { codex: { enabled: true, binaryPath: "", homePath: "", customModels: [] }, claudeAgent: { enabled: true, binaryPath: "", customModels: [] }, cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, + acp: { enabled: true, registryUrl: "", agentServers: [] }, }, }, }; diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index a1bdf6c1710e..5c6ecadc2f9a 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -8,6 +8,7 @@ import { render } from "vitest-browser-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { TraitsMenuContent } from "./TraitsPicker"; import { useComposerDraftStore } from "../../composerDraftStore"; +import { getModelSelectionOptions } from "../../modelSelection"; const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); @@ -18,24 +19,19 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str const provider = props?.modelSelection?.provider ?? "claudeAgent"; const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; - useComposerDraftStore.setState({ - draftsByThreadKey: { - [threadKey]: { - prompt: props?.prompt ?? "", - images: [], - nonPersistedImageIds: [], - persistedAttachments: [], - terminalContexts: [], - modelSelectionByProvider: { - [provider]: { - provider, - model, - ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), - }, - }, - activeProvider: provider, - runtimeMode: null, - interactionMode: null, + draftsByThreadId[threadId] = { + prompt: props?.prompt ?? "", + images: [], + nonPersistedImageIds: [], + persistedAttachments: [], + terminalContexts: [], + modelSelectionByProvider: { + [provider]: { + provider, + model, + ...(getModelSelectionOptions(props?.modelSelection) + ? { options: getModelSelectionOptions(props?.modelSelection) } + : {}), }, }, draftThreadsByThreadKey: {}, @@ -44,7 +40,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str const host = document.createElement("div"); document.body.append(host); const onPromptChange = vi.fn(); - const providerOptions = props?.modelSelection?.options; + const providerOptions = getModelSelectionOptions(props?.modelSelection); const models = provider === "claudeAgent" ? [ diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index bc12a06c8d7d..460d667d083f 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -1,9 +1,10 @@ -import { type ProviderKind, type ServerProvider } from "@t3tools/contracts"; +import { type AcpAgentServer, type ProviderKind, type ServerProvider } from "@t3tools/contracts"; import { resolveModelSlugForProvider, resolveSelectableModel } from "@t3tools/shared/model"; import { memo, useState } from "react"; import type { VariantProps } from "class-variance-authority"; import { type ProviderPickerKind, PROVIDER_OPTIONS } from "../../session-logic"; -import { ChevronDownIcon } from "lucide-react"; +import { type BuiltInProviderKind } from "../../modelSelection"; +import { BotIcon, ChevronDownIcon } from "lucide-react"; import { Button, buttonVariants } from "../ui/button"; import { Menu, @@ -23,18 +24,19 @@ import { cn } from "~/lib/utils"; import { getProviderSnapshot } from "../../providerModels"; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { - value: ProviderKind; + value: BuiltInProviderKind; label: string; available: true; } { return option.available; } -const PROVIDER_ICON_BY_PROVIDER: Record = { +const PROVIDER_ICON_BY_PROVIDER: Record = { codex: OpenAI, claudeAgent: ClaudeAI, opencode: OpenCodeIcon, cursor: CursorIcon, + acp: OpenCodeIcon, }; export const AVAILABLE_PROVIDER_OPTIONS = PROVIDER_OPTIONS.filter(isAvailableProviderOption); @@ -45,15 +47,78 @@ function providerIconClassName( provider: ProviderKind | ProviderPickerKind, fallbackClassName: string, ): string { - return provider === "claudeAgent" ? "text-[#d97757]" : fallbackClassName; + if (provider === "claudeAgent") { + return "text-[#d97757]"; + } + if (provider === "acp") { + return "text-foreground/85"; + } + return fallbackClassName; } +const AcpAgentIcon = memo(function AcpAgentIcon(props: { + iconUrl?: string | undefined; + className?: string | undefined; + fallbackClassName?: string | undefined; +}) { + const [failedIconUrl, setFailedIconUrl] = useState(null); + const iconUrl = + props.iconUrl && props.iconUrl !== failedIconUrl + ? props.iconUrl.trim() || undefined + : undefined; + + if (iconUrl) { + return ( + setFailedIconUrl(iconUrl)} + /> + ); + } + + return ( +