authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 21:55:05-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log78d53062d1669c911cd938dd3a32faceafd6a57c
tree22db2df17bce3086ecb4b272d4b334771c3e629b
parent697d71d57d1f237bb104e1a933baac6b234477dd

refactor: move cursor provider surface out of core\n\nCo-authored-by: codex <codex@users.noreply.github.com>


28 files changed, 46 insertions(+), 1010 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+1-1
...@@ -188,7 +188,7 @@ export interface OrchestrationIntegrationHarness {...@@ -188,7 +188,7 @@ export interface OrchestrationIntegrationHarness {
188}188}
189189
190interface MakeOrchestrationIntegrationHarnessOptions {190interface MakeOrchestrationIntegrationHarnessOptions {
191 readonly provider?: "codex" | "cursor";191 readonly provider?: "codex";
192 readonly realCodex?: boolean;192 readonly realCodex?: boolean;
193}193}
194194
apps/server/integration/TestProviderAdapter.integration.ts+5-5
...@@ -35,7 +35,7 @@ export interface TestTurnResponse {...@@ -35,7 +35,7 @@ export interface TestTurnResponse {
35export type FixtureProviderRuntimeEvent = {35export type FixtureProviderRuntimeEvent = {
36 readonly type: string;36 readonly type: string;
37 readonly eventId: EventId;37 readonly eventId: EventId;
38 readonly provider: "codex" | "cursor";38 readonly provider: "codex";
39 readonly createdAt: string;39 readonly createdAt: string;
40 readonly threadId: string;40 readonly threadId: string;
41 readonly turnId?: string | undefined;41 readonly turnId?: string | undefined;
...@@ -178,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record<string, unknown>): ProviderRunti...@@ -178,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record<string, unknown>): ProviderRunti
178178
179export interface TestProviderAdapterHarness {179export interface TestProviderAdapterHarness {
180 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;180 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
181 readonly provider: "codex" | "cursor";181 readonly provider: "codex";
182 readonly queueTurnResponse: (182 readonly queueTurnResponse: (
183 threadId: ThreadId,183 threadId: ThreadId,
184 response: TestTurnResponse,184 response: TestTurnResponse,
...@@ -198,7 +198,7 @@ export interface TestProviderAdapterHarness {...@@ -198,7 +198,7 @@ export interface TestProviderAdapterHarness {
198}198}
199199
200interface MakeTestProviderAdapterHarnessOptions {200interface MakeTestProviderAdapterHarnessOptions {
201 readonly provider?: "codex" | "cursor";201 readonly provider?: "codex";
202}202}
203203
204function nowIso(): string {204function nowIso(): string {
...@@ -206,7 +206,7 @@ function nowIso(): string {...@@ -206,7 +206,7 @@ function nowIso(): string {
206}206}
207207
208function sessionNotFound(208function sessionNotFound(
209 provider: "codex" | "cursor",209 provider: "codex",
210 threadId: ThreadId,210 threadId: ThreadId,
211): ProviderAdapterSessionNotFoundError {211): ProviderAdapterSessionNotFoundError {
212 return new ProviderAdapterSessionNotFoundError({212 return new ProviderAdapterSessionNotFoundError({
...@@ -216,7 +216,7 @@ function sessionNotFound(...@@ -216,7 +216,7 @@ function sessionNotFound(
216}216}
217217
218function missingSessionEffect(218function missingSessionEffect(
219 provider: "codex" | "cursor",219 provider: "codex",
220 threadId: ThreadId,220 threadId: ThreadId,
221): Effect.Effect<never, ProviderAdapterError> {221): Effect.Effect<never, ProviderAdapterError> {
222 return Effect.fail(sessionNotFound(provider, threadId));222 return Effect.fail(sessionNotFound(provider, threadId));
apps/server/integration/orchestrationEngine.integration.test.ts+1-1
...@@ -32,7 +32,7 @@ const PROJECT_ID = asProjectId("project-1");...@@ -32,7 +32,7 @@ const PROJECT_ID = asProjectId("project-1");
32const THREAD_ID = ThreadId.makeUnsafe("thread-1");32const THREAD_ID = ThreadId.makeUnsafe("thread-1");
33const FIXTURE_TURN_ID = "fixture-turn";33const FIXTURE_TURN_ID = "fixture-turn";
34const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");34const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
35type IntegrationProvider = "codex" | "cursor";35type IntegrationProvider = "codex";
3636
37function nowIso() {37function nowIso() {
38 return new Date().toISOString();38 return new Date().toISOString();
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+1-1
...@@ -43,7 +43,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);...@@ -43,7 +43,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
43type LegacyProviderRuntimeEvent = {43type LegacyProviderRuntimeEvent = {
44 readonly type: string;44 readonly type: string;
45 readonly eventId: EventId;45 readonly eventId: EventId;
46 readonly provider: "codex" | "cursor";46 readonly provider: "codex";
47 readonly createdAt: string;47 readonly createdAt: string;
48 readonly threadId: ThreadId;48 readonly threadId: ThreadId;
49 readonly turnId?: string | undefined;49 readonly turnId?: string | undefined;
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+4-147
...@@ -93,8 +93,7 @@ describe("ProviderCommandReactor", () => {...@@ -93,8 +93,7 @@ describe("ProviderCommandReactor", () => {
93 typeof input === "object" &&93 typeof input === "object" &&
94 input !== null &&94 input !== null &&
95 "provider" in input &&95 "provider" in input &&
96 (input.provider === "codex" ||96 input.provider === "codex"
97 input.provider === "cursor")
98 ? input.provider97 ? input.provider
99 : "codex";98 : "codex";
100 const resumeCursor =99 const resumeCursor =
...@@ -186,7 +185,7 @@ describe("ProviderCommandReactor", () => {...@@ -186,7 +185,7 @@ describe("ProviderCommandReactor", () => {
186 listSessions: () => Effect.succeed(runtimeSessions),185 listSessions: () => Effect.succeed(runtimeSessions),
187 getCapabilities: (provider) =>186 getCapabilities: (provider) =>
188 Effect.succeed({187 Effect.succeed({
189 sessionModelSwitch: provider === "cursor" ? "unsupported" : "in-session",188 sessionModelSwitch: provider === "codex" ? "in-session" : "in-session",
190 }),189 }),
191 rollbackConversation: () => unsupported(),190 rollbackConversation: () => unsupported(),
192 stopAll: () => Effect.void,191 stopAll: () => Effect.void,
...@@ -384,43 +383,6 @@ describe("ProviderCommandReactor", () => {...@@ -384,43 +383,6 @@ describe("ProviderCommandReactor", () => {
384 });383 });
385 });384 });
386385
387 it("starts first turn with cursor provider when provider is specified", async () => {
388 const harness = await createHarness();
389 const now = new Date().toISOString();
390
391 await Effect.runPromise(
392 harness.engine.dispatch({
393 type: "thread.turn.start",
394 commandId: CommandId.makeUnsafe("cmd-turn-start-provider-cursor"),
395 threadId: ThreadId.makeUnsafe("thread-1"),
396 message: {
397 messageId: asMessageId("user-message-provider-cursor"),
398 role: "user",
399 text: "hello cursor",
400 attachments: [],
401 },
402 provider: "cursor",
403 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
404 runtimeMode: "approval-required",
405 createdAt: now,
406 }),
407 );
408
409 await waitFor(() => harness.startSession.mock.calls.length === 1);
410 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
411 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
412 provider: "cursor",
413 cwd: "/tmp/provider-project",
414 model: "gpt-5-codex",
415 runtimeMode: "approval-required",
416 });
417
418 const readModel = await Effect.runPromise(harness.engine.getReadModel());
419 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
420 expect(thread?.session?.providerName).toBe("cursor");
421 expect(thread?.session?.threadId).toBe("thread-1");
422 });
423
424 it("reuses the same provider session when runtime mode is unchanged", async () => {386 it("reuses the same provider session when runtime mode is unchanged", async () => {
425 const harness = await createHarness();387 const harness = await createHarness();
426 const now = new Date().toISOString();388 const now = new Date().toISOString();
...@@ -467,111 +429,6 @@ describe("ProviderCommandReactor", () => {...@@ -467,111 +429,6 @@ describe("ProviderCommandReactor", () => {
467 expect(harness.stopSession.mock.calls.length).toBe(0);429 expect(harness.stopSession.mock.calls.length).toBe(0);
468 });430 });
469431
470 it("reuses the same cursor session when requested model is unchanged", async () => {
471 const harness = await createHarness();
472 const now = new Date().toISOString();
473
474 await Effect.runPromise(
475 harness.engine.dispatch({
476 type: "thread.turn.start",
477 commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-1"),
478 threadId: ThreadId.makeUnsafe("thread-1"),
479 message: {
480 messageId: asMessageId("user-message-cursor-model-same-1"),
481 role: "user",
482 text: "first",
483 attachments: [],
484 },
485 provider: "cursor",
486 model: "composer-1.5",
487 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
488 runtimeMode: "approval-required",
489 createdAt: now,
490 }),
491 );
492
493 await waitFor(() => harness.startSession.mock.calls.length === 1);
494 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
495
496 await Effect.runPromise(
497 harness.engine.dispatch({
498 type: "thread.turn.start",
499 commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-same-2"),
500 threadId: ThreadId.makeUnsafe("thread-1"),
501 message: {
502 messageId: asMessageId("user-message-cursor-model-same-2"),
503 role: "user",
504 text: "second",
505 attachments: [],
506 },
507 provider: "cursor",
508 model: "composer-1.5",
509 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
510 runtimeMode: "approval-required",
511 createdAt: now,
512 }),
513 );
514
515 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
516 expect(harness.startSession.mock.calls.length).toBe(1);
517 expect(harness.stopSession.mock.calls.length).toBe(0);
518 });
519
520 it("keeps cursor session/model when model change is unsupported", async () => {
521 const harness = await createHarness();
522 const now = new Date().toISOString();
523
524 await Effect.runPromise(
525 harness.engine.dispatch({
526 type: "thread.turn.start",
527 commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-1"),
528 threadId: ThreadId.makeUnsafe("thread-1"),
529 message: {
530 messageId: asMessageId("user-message-cursor-model-change-1"),
531 role: "user",
532 text: "first",
533 attachments: [],
534 },
535 provider: "cursor",
536 model: "gpt-5.3-codex",
537 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
538 runtimeMode: "approval-required",
539 createdAt: now,
540 }),
541 );
542
543 await waitFor(() => harness.startSession.mock.calls.length === 1);
544 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
545
546 await Effect.runPromise(
547 harness.engine.dispatch({
548 type: "thread.turn.start",
549 commandId: CommandId.makeUnsafe("cmd-turn-start-cursor-model-change-2"),
550 threadId: ThreadId.makeUnsafe("thread-1"),
551 message: {
552 messageId: asMessageId("user-message-cursor-model-change-2"),
553 role: "user",
554 text: "second",
555 attachments: [],
556 },
557 provider: "cursor",
558 model: "composer-1.5",
559 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
560 runtimeMode: "approval-required",
561 createdAt: now,
562 }),
563 );
564
565 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
566
567 expect(harness.stopSession.mock.calls.length).toBe(0);
568 expect(harness.startSession.mock.calls.length).toBe(1);
569 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
570 threadId: ThreadId.makeUnsafe("thread-1"),
571 model: "gpt-5.3-codex",
572 });
573 });
574
575 it("restarts the provider session when runtime mode is updated on the thread", async () => {432 it("restarts the provider session when runtime mode is updated on the thread", async () => {
576 const harness = await createHarness();433 const harness = await createHarness();
577 const now = new Date().toISOString();434 const now = new Date().toISOString();
...@@ -852,7 +709,7 @@ describe("ProviderCommandReactor", () => {...@@ -852,7 +709,7 @@ describe("ProviderCommandReactor", () => {
852 harness.respondToRequest.mockImplementation(() =>709 harness.respondToRequest.mockImplementation(() =>
853 Effect.fail(710 Effect.fail(
854 new ProviderAdapterRequestError({711 new ProviderAdapterRequestError({
855 provider: "cursor",712 provider: "codex",
856 method: "session/request_permission",713 method: "session/request_permission",
857 detail: "Unknown pending permission request: approval-request-1",714 detail: "Unknown pending permission request: approval-request-1",
858 }),715 }),
...@@ -867,7 +724,7 @@ describe("ProviderCommandReactor", () => {...@@ -867,7 +724,7 @@ describe("ProviderCommandReactor", () => {
867 session: {724 session: {
868 threadId: ThreadId.makeUnsafe("thread-1"),725 threadId: ThreadId.makeUnsafe("thread-1"),
869 status: "running",726 status: "running",
870 providerName: "cursor",727 providerName: "codex",
871 runtimeMode: "approval-required",728 runtimeMode: "approval-required",
872 activeTurnId: null,729 activeTurnId: null,
873 lastError: null,730 lastError: null,
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+1-4
...@@ -211,10 +211,7 @@ const make = Effect.gen(function* () {...@@ -211,10 +211,7 @@ const make = Effect.gen(function* () {
211211
212 const desiredRuntimeMode = thread.runtimeMode;212 const desiredRuntimeMode = thread.runtimeMode;
213 const currentProvider: ProviderKind | undefined =213 const currentProvider: ProviderKind | undefined =
214 thread.session?.providerName === "codex" ||214 thread.session?.providerName === "codex" ? thread.session.providerName : undefined;
215 thread.session?.providerName === "cursor"
216 ? thread.session.providerName
217 : undefined;
218 const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;215 const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;
219 const desiredModel = options?.model ?? thread.model;216 const desiredModel = options?.model ?? thread.model;
220 const effectiveCwd = resolveThreadWorkspaceCwd({217 const effectiveCwd = resolveThreadWorkspaceCwd({
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+1-1
...@@ -41,7 +41,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);...@@ -41,7 +41,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
41type LegacyProviderRuntimeEvent = {41type LegacyProviderRuntimeEvent = {
42 readonly type: string;42 readonly type: string;
43 readonly eventId: EventId;43 readonly eventId: EventId;
44 readonly provider: "codex" | "cursor";44 readonly provider: "codex";
45 readonly createdAt: string;45 readonly createdAt: string;
46 readonly threadId: ThreadId;46 readonly threadId: ThreadId;
47 readonly turnId?: string | undefined;47 readonly turnId?: string | undefined;
apps/server/src/provider/Layers/CodexAdapter.test.ts-24
...@@ -157,30 +157,6 @@ const validationLayer = it.layer(...@@ -157,30 +157,6 @@ const validationLayer = it.layer(
157);157);
158158
159validationLayer("CodexAdapterLive validation", (it) => {159validationLayer("CodexAdapterLive validation", (it) => {
160 it.effect("returns validation error for non-codex provider on startSession", () =>
161 Effect.gen(function* () {
162 const adapter = yield* CodexAdapter;
163 const result = yield* adapter
164 .startSession({
165 provider: "cursor",
166 threadId: asThreadId("thread-1"),
167 runtimeMode: "full-access",
168 })
169 .pipe(Effect.result);
170
171 assert.equal(result._tag, "Failure");
172 assert.deepStrictEqual(
173 result.failure,
174 new ProviderAdapterValidationError({
175 provider: "codex",
176 operation: "startSession",
177 issue: "Expected provider 'codex' but received 'cursor'.",
178 }),
179 );
180 assert.equal(validationManager.startSessionImpl.mock.calls.length, 0);
181 }),
182 );
183
184 it.effect("maps codex model options before starting a session", () =>160 it.effect("maps codex model options before starting a session", () =>
185 Effect.gen(function* () {161 Effect.gen(function* () {
186 validationManager.startSessionImpl.mockClear();162 validationManager.startSessionImpl.mockClear();
apps/server/src/provider/Layers/ProviderService.test.ts+1-1
...@@ -51,7 +51,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);...@@ -51,7 +51,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
51type LegacyProviderRuntimeEvent = {51type LegacyProviderRuntimeEvent = {
52 readonly type: string;52 readonly type: string;
53 readonly eventId: EventId;53 readonly eventId: EventId;
54 readonly provider: "codex" | "cursor";54 readonly provider: "codex";
55 readonly createdAt: string;55 readonly createdAt: string;
56 readonly threadId: ThreadId;56 readonly threadId: ThreadId;
57 readonly turnId?: string | undefined;57 readonly turnId?: string | undefined;
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts-22
...@@ -174,26 +174,4 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL...@@ -174,26 +174,4 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
174174
175 fs.rmSync(tempDir, { recursive: true, force: true });175 fs.rmSync(tempDir, { recursive: true, force: true });
176 }));176 }));
177
178 it("accepts cursor provider bindings", () =>
179 Effect.gen(function* () {
180 const directory = yield* ProviderSessionDirectory;
181 const threadId = ThreadId.makeUnsafe("thread-cursor");
182
183 yield* directory.upsert({
184 provider: "cursor",
185 threadId,
186 });
187
188 const provider = yield* directory.getProvider(threadId);
189 assert.equal(provider, "cursor");
190 const resolvedBinding = yield* directory.getBinding(threadId);
191 assertSome(resolvedBinding, {
192 threadId,
193 provider: "cursor",
194 });
195 if (Option.isSome(resolvedBinding)) {
196 assert.equal(resolvedBinding.value.threadId, threadId);
197 }
198 }));
199});177});
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+1-1
...@@ -25,7 +25,7 @@ function decodeProviderKind(...@@ -25,7 +25,7 @@ function decodeProviderKind(
25 providerName: string,25 providerName: string,
26 operation: string,26 operation: string,
27): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {27): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
28 if (providerName === "codex" || providerName === "cursor") {28 if (providerName === "codex") {
29 return Effect.succeed(providerName);29 return Effect.succeed(providerName);
30 }30 }
31 return Effect.fail(31 return Effect.fail(
apps/web/src/appSettings.test.ts-21
...@@ -15,13 +15,6 @@ describe("normalizeCustomModelSlugs", () => {...@@ -15,13 +15,6 @@ describe("normalizeCustomModelSlugs", () => {
15 ]),15 ]),
16 ).toEqual(["custom/internal-model"]);16 ).toEqual(["custom/internal-model"]);
17 });17 });
18
19 it("normalizes provider-specific aliases for cursor", () => {
20 expect(normalizeCustomModelSlugs(["composer"], "cursor")).toEqual([]);
21 expect(normalizeCustomModelSlugs(["cursor/custom-model"], "cursor")).toEqual([
22 "cursor/custom-model",
23 ]);
24 });
25});18});
2619
27describe("getAppModelOptions", () => {20describe("getAppModelOptions", () => {
...@@ -47,14 +40,6 @@ describe("getAppModelOptions", () => {...@@ -47,14 +40,6 @@ describe("getAppModelOptions", () => {
47 isCustom: true,40 isCustom: true,
48 });41 });
49 });42 });
50
51 it("keeps a saved custom provider model available as an exact slug option", () => {
52 const options = getAppModelOptions("cursor", ["cursor/custom-model"], "cursor/custom-model");
53
54 expect(options.some((option) => option.slug === "cursor/custom-model" && option.isCustom)).toBe(
55 true,
56 );
57 });
58});43});
5944
60describe("getSlashModelOptions", () => {45describe("getSlashModelOptions", () => {
...@@ -79,10 +64,4 @@ describe("getSlashModelOptions", () => {...@@ -79,10 +64,4 @@ describe("getSlashModelOptions", () => {
7964
80 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);65 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
81 });66 });
82
83 it("includes provider-specific custom slugs in non-codex model lists", () => {
84 const cursorOptions = getAppModelOptions("cursor", ["cursor/custom-model"]);
85
86 expect(cursorOptions.some((option) => option.slug === "cursor/custom-model")).toBe(true);
87 });
88});67});
apps/web/src/appSettings.ts-5
...@@ -8,7 +8,6 @@ const MAX_CUSTOM_MODEL_COUNT = 32;...@@ -8,7 +8,6 @@ const MAX_CUSTOM_MODEL_COUNT = 32;
8export const MAX_CUSTOM_MODEL_LENGTH = 256;8export const MAX_CUSTOM_MODEL_LENGTH = 256;
9const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {9const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
10 codex: new Set(getModelOptions("codex").map((option) => option.slug)),10 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
11 cursor: new Set(getModelOptions("cursor").map((option) => option.slug)),
12};11};
1312
14const AppSettingsSchema = Schema.Struct({13const AppSettingsSchema = Schema.Struct({
...@@ -25,9 +24,6 @@ const AppSettingsSchema = Schema.Struct({...@@ -25,9 +24,6 @@ const AppSettingsSchema = Schema.Struct({
25 customCodexModels: Schema.Array(Schema.String).pipe(24 customCodexModels: Schema.Array(Schema.String).pipe(
26 Schema.withConstructorDefault(() => Option.some([])),25 Schema.withConstructorDefault(() => Option.some([])),
27 ),26 ),
28 customCursorModels: Schema.Array(Schema.String).pipe(
29 Schema.withConstructorDefault(() => Option.some([])),
30 ),
31});27});
32export type AppSettings = typeof AppSettingsSchema.Type;28export type AppSettings = typeof AppSettingsSchema.Type;
33export interface AppModelOption {29export interface AppModelOption {
...@@ -75,7 +71,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {...@@ -75,7 +71,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
75 return {71 return {
76 ...settings,72 ...settings,
77 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),73 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
78 customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"),
79 };74 };
80}75}
8176
apps/web/src/components/ChatView.tsx+12-287
...@@ -1,12 +1,10 @@...@@ -1,12 +1,10 @@
1import {1import {
2 type ApprovalRequestId,2 type ApprovalRequestId,
3 CURSOR_REASONING_OPTIONS,
4 DEFAULT_MODEL,3 DEFAULT_MODEL,
5 EDITORS,4 EDITORS,
6 type EditorId,5 type EditorId,
7 type KeybindingCommand,6 type KeybindingCommand,
8 type CodexReasoningEffort,7 type CodexReasoningEffort,
9 type CursorReasoningOption,
10 type MessageId,8 type MessageId,
11 type ProjectId,9 type ProjectId,
12 type ProjectEntry,10 type ProjectEntry,
...@@ -27,12 +25,8 @@ import {...@@ -27,12 +25,8 @@ import {
27import {25import {
28 getDefaultModel,26 getDefaultModel,
29 getDefaultReasoningEffort,27 getDefaultReasoningEffort,
30 getCursorModelCapabilities,
31 getCursorModelFamilyOptions,
32 getReasoningEffortOptions,28 getReasoningEffortOptions,
33 normalizeModelSlug,29 normalizeModelSlug,
34 parseCursorModelSelection,
35 resolveCursorModelFromSelection,
36 resolveModelSlugForProvider,30 resolveModelSlugForProvider,
37} from "@t3tools/shared/model";31} from "@t3tools/shared/model";
38import {32import {
...@@ -761,10 +755,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -761,10 +755,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
761 ? (sessionProvider ?? selectedProviderByThreadId ?? null)755 ? (sessionProvider ?? selectedProviderByThreadId ?? null)
762 : null;756 : null;
763 const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex";757 const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex";
764 const cursorModelSelectionLockedReason =
765 hasThreadStarted && selectedProvider === "cursor"
766 ? "Cursor currently does not support changing models after the first message in a thread."
767 : null;
768 const baseThreadModel = resolveModelSlugForProvider(758 const baseThreadModel = resolveModelSlugForProvider(
769 selectedProvider,759 selectedProvider,
770 activeThread?.model ??760 activeThread?.model ??
...@@ -811,40 +801,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -811,40 +801,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
811 };801 };
812 return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined;802 return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined;
813 }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]);803 }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]);
814 const selectedCursorModel = useMemo(804 const selectedModelForPicker = selectedModel;
815 () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null),
816 [selectedModel, selectedProvider],
817 );
818 const selectedCursorModelCapabilities = useMemo(
819 () => (selectedCursorModel ? getCursorModelCapabilities(selectedCursorModel.family) : null),
820 [selectedCursorModel],
821 );
822 const hasSelectedCursorTraits = Boolean(
823 selectedCursorModelCapabilities &&
824 (selectedCursorModelCapabilities.supportsReasoning ||
825 selectedCursorModelCapabilities.supportsFast ||
826 selectedCursorModelCapabilities.supportsThinking),
827 );
828 const selectedModelForPicker =
829 selectedProvider === "cursor" && selectedCursorModel
830 ? selectedCursorModel.family
831 : selectedModel;
832 const modelOptionsByProvider = useMemo(805 const modelOptionsByProvider = useMemo(
833 () => getCustomModelOptionsByProvider(settings),806 () => getCustomModelOptionsByProvider(settings),
834 [settings],807 [settings],
835 );808 );
836 const selectedModelForPickerWithCustomFallback = useMemo(() => {809 const selectedModelForPickerWithCustomFallback = useMemo(() => {
837 if (selectedProvider !== "cursor") {810 const currentOptions = modelOptionsByProvider[selectedProvider];
838 const currentOptions = modelOptionsByProvider[selectedProvider];
839 return currentOptions.some((option) => option.slug === selectedModelForPicker)
840 ? selectedModelForPicker
841 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
842 }
843
844 const currentOptions = modelOptionsByProvider.cursor;
845 return currentOptions.some((option) => option.slug === selectedModelForPicker)811 return currentOptions.some((option) => option.slug === selectedModelForPicker)
846 ? selectedModelForPicker812 ? selectedModelForPicker
847 : selectedModelForPicker;813 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
848 }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);814 }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);
849 const searchableModelOptions = useMemo(815 const searchableModelOptions = useMemo(
850 () =>816 () =>
...@@ -3009,73 +2975,23 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3009,73 +2975,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
3009 const onProviderModelSelect = useCallback(2975 const onProviderModelSelect = useCallback(
3010 (provider: ProviderKind, model: ModelSlug) => {2976 (provider: ProviderKind, model: ModelSlug) => {
3011 if (!activeThread) return;2977 if (!activeThread) return;
3012 if (cursorModelSelectionLockedReason !== null && provider === "cursor") {
3013 scheduleComposerFocus();
3014 return;
3015 }
3016 if (lockedProvider !== null && provider !== lockedProvider) {2978 if (lockedProvider !== null && provider !== lockedProvider) {
3017 scheduleComposerFocus();2979 scheduleComposerFocus();
3018 return;2980 return;
3019 }2981 }
3020 const resolvedModel =2982 const resolvedModel = resolveModelSlugForProvider(provider, model);
3021 provider === "cursor"
3022 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))
3023 : resolveModelSlugForProvider(provider, model);
3024 setComposerDraftProvider(activeThread.id, provider);2983 setComposerDraftProvider(activeThread.id, provider);
3025 setComposerDraftModel(activeThread.id, resolvedModel);2984 setComposerDraftModel(activeThread.id, resolvedModel);
3026 scheduleComposerFocus();2985 scheduleComposerFocus();
3027 },2986 },
3028 [2987 [
3029 activeThread,2988 activeThread,
3030 cursorModelSelectionLockedReason,
3031 lockedProvider,2989 lockedProvider,
3032 scheduleComposerFocus,2990 scheduleComposerFocus,
3033 setComposerDraftModel,2991 setComposerDraftModel,
3034 setComposerDraftProvider,2992 setComposerDraftProvider,
3035 ],2993 ],
3036 );2994 );
3037 const onCursorReasoningSelect = useCallback(
3038 (reasoning: CursorReasoningOption) => {
3039 if (selectedProvider !== "cursor") return;
3040 const cursorSelection = parseCursorModelSelection(selectedModel);
3041 const nextModel = resolveCursorModelFromSelection({
3042 family: cursorSelection.family,
3043 reasoning,
3044 fast: cursorSelection.fast,
3045 thinking: cursorSelection.thinking,
3046 });
3047 onProviderModelSelect("cursor", nextModel);
3048 },
3049 [onProviderModelSelect, selectedModel, selectedProvider],
3050 );
3051 const onCursorFastModeChange = useCallback(
3052 (enabled: boolean) => {
3053 if (selectedProvider !== "cursor") return;
3054 const cursorSelection = parseCursorModelSelection(selectedModel);
3055 const nextModel = resolveCursorModelFromSelection({
3056 family: cursorSelection.family,
3057 reasoning: cursorSelection.reasoning,
3058 fast: enabled,
3059 thinking: cursorSelection.thinking,
3060 });
3061 onProviderModelSelect("cursor", nextModel);
3062 },
3063 [onProviderModelSelect, selectedModel, selectedProvider],
3064 );
3065 const onCursorThinkingModeChange = useCallback(
3066 (enabled: boolean) => {
3067 if (selectedProvider !== "cursor") return;
3068 const cursorSelection = parseCursorModelSelection(selectedModel);
3069 const nextModel = resolveCursorModelFromSelection({
3070 family: cursorSelection.family,
3071 reasoning: cursorSelection.reasoning,
3072 fast: cursorSelection.fast,
3073 thinking: enabled,
3074 });
3075 onProviderModelSelect("cursor", nextModel);
3076 },
3077 [onProviderModelSelect, selectedModel, selectedProvider],
3078 );
3079 const onEffortSelect = useCallback(2995 const onEffortSelect = useCallback(
3080 (effort: CodexReasoningEffort) => {2996 (effort: CodexReasoningEffort) => {
3081 setComposerDraftEffort(threadId, effort);2997 setComposerDraftEffort(threadId, effort);
...@@ -3594,82 +3510,15 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3594,82 +3510,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
3594 <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3">3510 <div className="flex flex-wrap items-center justify-between gap-2 px-2.5 pb-2.5 sm:flex-nowrap sm:gap-0 sm:px-3 sm:pb-3">
3595 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible">3511 <div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden sm:min-w-max sm:overflow-visible">
3596 {/* Provider/model picker */}3512 {/* Provider/model picker */}
3597 {cursorModelSelectionLockedReason ? (3513 <ProviderModelPicker
3598 <Tooltip>3514 provider={selectedProvider}
3599 <TooltipTrigger3515 model={selectedModelForPickerWithCustomFallback}
3600 render={3516 lockedProvider={lockedProvider}
3601 <span className="inline-flex">3517 modelOptionsByProvider={modelOptionsByProvider}
3602 <ProviderModelPicker3518 onProviderModelChange={onProviderModelSelect}
3603 provider={selectedProvider}3519 />
3604 model={selectedModelForPickerWithCustomFallback}
3605 lockedProvider={lockedProvider}
3606 modelOptionsByProvider={modelOptionsByProvider}
3607 disabled
3608 onProviderModelChange={onProviderModelSelect}
3609 />
3610 </span>
3611 }
3612 />
3613 <TooltipPopup side="top" className="max-w-72 whitespace-normal leading-tight">
3614 {cursorModelSelectionLockedReason}
3615 </TooltipPopup>
3616 </Tooltip>
3617 ) : (
3618 <ProviderModelPicker
3619 provider={selectedProvider}
3620 model={selectedModelForPickerWithCustomFallback}
3621 lockedProvider={lockedProvider}
3622 modelOptionsByProvider={modelOptionsByProvider}
3623 onProviderModelChange={onProviderModelSelect}
3624 />
3625 )}
3626
3627 {selectedProvider === "cursor" ? (
3628 <>
3629 {hasSelectedCursorTraits && (
3630 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3631 )}
36323520
3633 {selectedCursorModel &&3521 {selectedProvider === "codex" && selectedEffort != null ? (
3634 selectedCursorModelCapabilities &&
3635 hasSelectedCursorTraits && (
3636 <>
3637 {cursorModelSelectionLockedReason ? (
3638 <Tooltip>
3639 <TooltipTrigger
3640 render={
3641 <span className="inline-flex">
3642 <CursorTraitsPicker
3643 selection={selectedCursorModel}
3644 capabilities={selectedCursorModelCapabilities}
3645 disabled
3646 onReasoningChange={onCursorReasoningSelect}
3647 onFastModeChange={onCursorFastModeChange}
3648 onThinkingModeChange={onCursorThinkingModeChange}
3649 />
3650 </span>
3651 }
3652 />
3653 <TooltipPopup
3654 side="top"
3655 className="max-w-72 whitespace-normal leading-tight"
3656 >
3657 {cursorModelSelectionLockedReason}
3658 </TooltipPopup>
3659 </Tooltip>
3660 ) : (
3661 <CursorTraitsPicker
3662 selection={selectedCursorModel}
3663 capabilities={selectedCursorModelCapabilities}
3664 onReasoningChange={onCursorReasoningSelect}
3665 onFastModeChange={onCursorFastModeChange}
3666 onThinkingModeChange={onCursorThinkingModeChange}
3667 />
3668 )}
3669 </>
3670 )}
3671 </>
3672 ) : selectedProvider === "codex" && selectedEffort != null ? (
3673 <>3522 <>
3674 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />3523 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3675 <CodexTraitsPicker3524 <CodexTraitsPicker
...@@ -5297,18 +5146,9 @@ const COMING_SOON_PROVIDER_OPTIONS = [...@@ -5297,18 +5146,9 @@ const COMING_SOON_PROVIDER_OPTIONS = [
52975146
5298function getCustomModelOptionsByProvider(settings: {5147function getCustomModelOptionsByProvider(settings: {
5299 customCodexModels: readonly string[];5148 customCodexModels: readonly string[];
5300 customCursorModels: readonly string[];
5301}): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {5149}): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
5302 const cursorFamilyOptions = getCursorModelFamilyOptions();
5303 return {5150 return {
5304 codex: getAppModelOptions("codex", settings.customCodexModels),5151 codex: getAppModelOptions("codex", settings.customCodexModels),
5305 cursor: [
5306 ...cursorFamilyOptions,
5307 ...getAppModelOptions("cursor", settings.customCursorModels).filter(
5308 (option) =>
5309 option.isCustom && !cursorFamilyOptions.some((family) => family.slug === option.slug),
5310 ),
5311 ],
5312 };5152 };
5313}5153}
53145154
...@@ -5348,10 +5188,6 @@ function resolveModelForProviderPicker(...@@ -5348,10 +5188,6 @@ function resolveModelForProviderPicker(
5348 return resolved.slug;5188 return resolved.slug;
5349 }5189 }
53505190
5351 if (provider === "cursor") {
5352 return parseCursorModelSelection(normalized).family;
5353 }
5354
5355 return null;5191 return null;
5356}5192}
53575193
...@@ -5562,117 +5398,6 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {...@@ -5562,117 +5398,6 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
5562 );5398 );
5563});5399});
55645400
5565const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
5566 selection: ReturnType<typeof parseCursorModelSelection>;
5567 capabilities: ReturnType<typeof getCursorModelCapabilities>;
5568 disabled?: boolean;
5569 onReasoningChange: (reasoning: CursorReasoningOption) => void;
5570 onFastModeChange: (enabled: boolean) => void;
5571 onThinkingModeChange: (enabled: boolean) => void;
5572}) {
5573 const [isMenuOpen, setIsMenuOpen] = useState(false);
5574 const reasoningLabelByOption: Record<CursorReasoningOption, string> = {
5575 low: "Low",
5576 normal: "Normal",
5577 high: "High",
5578 xhigh: "Extra High",
5579 };
5580 const traitSummary = [
5581 ...(props.capabilities.supportsReasoning
5582 ? [reasoningLabelByOption[props.selection.reasoning]]
5583 : []),
5584 ...(props.capabilities.supportsFast && props.selection.fast ? ["Fast"] : []),
5585 ...(props.capabilities.supportsThinking && props.selection.thinking ? ["Thinking"] : []),
5586 ];
5587 const triggerLabel = traitSummary.length > 0 ? traitSummary.join(" · ") : "Traits";
5588
5589 return (
5590 <Menu
5591 open={isMenuOpen}
5592 onOpenChange={(open) => {
5593 if (props.disabled) {
5594 setIsMenuOpen(false);
5595 return;
5596 }
5597 setIsMenuOpen(open);
5598 }}
5599 >
5600 <MenuTrigger
5601 render={
5602 <Button
5603 size="sm"
5604 variant="ghost"
5605 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
5606 disabled={props.disabled}
5607 />
5608 }
5609 >
5610 <span>{triggerLabel}</span>
5611 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
5612 </MenuTrigger>
5613 <MenuPopup align="start">
5614 {props.capabilities.supportsReasoning && (
5615 <MenuGroup>
5616 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div>
5617 <MenuRadioGroup
5618 value={props.selection.reasoning}
5619 onValueChange={(value) => {
5620 if (props.disabled) return;
5621 if (!value) return;
5622 const nextReasoning = CURSOR_REASONING_OPTIONS.find((option) => option === value);
5623 if (!nextReasoning) return;
5624 props.onReasoningChange(nextReasoning);
5625 }}
5626 >
5627 {CURSOR_REASONING_OPTIONS.map((reasoning) => (
5628 <MenuRadioItem key={reasoning} value={reasoning}>
5629 {reasoning}
5630 {reasoning === "normal" ? " (default)" : ""}
5631 </MenuRadioItem>
5632 ))}
5633 </MenuRadioGroup>
5634 </MenuGroup>
5635 )}
5636 {props.capabilities.supportsReasoning &&
5637 (props.capabilities.supportsFast || props.capabilities.supportsThinking) && (
5638 <MenuDivider />
5639 )}
5640 {props.capabilities.supportsFast && (
5641 <MenuGroup>
5642 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
5643 <MenuRadioGroup
5644 value={props.selection.fast ? "on" : "off"}
5645 onValueChange={(value) => {
5646 if (props.disabled) return;
5647 props.onFastModeChange(value === "on");
5648 }}
5649 >
5650 <MenuRadioItem value="off">off</MenuRadioItem>
5651 <MenuRadioItem value="on">on</MenuRadioItem>
5652 </MenuRadioGroup>
5653 </MenuGroup>
5654 )}
5655 {props.capabilities.supportsFast && props.capabilities.supportsThinking && <MenuDivider />}
5656 {props.capabilities.supportsThinking && (
5657 <MenuGroup>
5658 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>
5659 <MenuRadioGroup
5660 value={props.selection.thinking ? "on" : "off"}
5661 onValueChange={(value) => {
5662 if (props.disabled) return;
5663 props.onThinkingModeChange(value === "on");
5664 }}
5665 >
5666 <MenuRadioItem value="off">off</MenuRadioItem>
5667 <MenuRadioItem value="on">on</MenuRadioItem>
5668 </MenuRadioGroup>
5669 </MenuGroup>
5670 )}
5671 </MenuPopup>
5672 </Menu>
5673 );
5674});
5675
5676const OpenInPicker = memo(function OpenInPicker({5401const OpenInPicker = memo(function OpenInPicker({
5677 keybindings,5402 keybindings,
5678 availableEditors,5403 availableEditors,
apps/web/src/composerDraftStore.test.ts+3-3
...@@ -394,15 +394,15 @@ describe("composerDraftStore setProvider", () => {...@@ -394,15 +394,15 @@ describe("composerDraftStore setProvider", () => {
394 it("persists provider-only selection even when prompt/model are empty", () => {394 it("persists provider-only selection even when prompt/model are empty", () => {
395 const store = useComposerDraftStore.getState();395 const store = useComposerDraftStore.getState();
396396
397 store.setProvider(threadId, "cursor");397 store.setProvider(threadId, "codex");
398398
399 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("cursor");399 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("codex");
400 });400 });
401401
402 it("removes empty provider-only draft when provider is reset", () => {402 it("removes empty provider-only draft when provider is reset", () => {
403 const store = useComposerDraftStore.getState();403 const store = useComposerDraftStore.getState();
404404
405 store.setProvider(threadId, "cursor");405 store.setProvider(threadId, "codex");
406 store.setProvider(threadId, null);406 store.setProvider(threadId, null);
407407
408 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();408 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
apps/web/src/composerDraftStore.ts+1-1
...@@ -208,7 +208,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {...@@ -208,7 +208,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
208}208}
209209
210function normalizeProviderKind(value: unknown): ProviderKind | null {210function normalizeProviderKind(value: unknown): ProviderKind | null {
211 return value === "codex" || value === "cursor" ? value : null;211 return value === "codex" ? value : null;
212}212}
213213
214function revokeObjectPreviewUrl(previewUrl: string): void {214function revokeObjectPreviewUrl(previewUrl: string): void {
apps/web/src/routes/_chat.settings.tsx-7
...@@ -54,8 +54,6 @@ function getCustomModelsForProvider(...@@ -54,8 +54,6 @@ function getCustomModelsForProvider(
54 provider: ProviderKind,54 provider: ProviderKind,
55) {55) {
56 switch (provider) {56 switch (provider) {
57 case "cursor":
58 return settings.customCursorModels;
59 case "codex":57 case "codex":
60 default:58 default:
61 return settings.customCodexModels;59 return settings.customCodexModels;
...@@ -67,8 +65,6 @@ function getDefaultCustomModelsForProvider(...@@ -67,8 +65,6 @@ function getDefaultCustomModelsForProvider(
67 provider: ProviderKind,65 provider: ProviderKind,
68) {66) {
69 switch (provider) {67 switch (provider) {
70 case "cursor":
71 return defaults.customCursorModels;
72 case "codex":68 case "codex":
73 default:69 default:
74 return defaults.customCodexModels;70 return defaults.customCodexModels;
...@@ -77,8 +73,6 @@ function getDefaultCustomModelsForProvider(...@@ -77,8 +73,6 @@ function getDefaultCustomModelsForProvider(
7773
78function patchCustomModels(provider: ProviderKind, models: string[]) {74function patchCustomModels(provider: ProviderKind, models: string[]) {
79 switch (provider) {75 switch (provider) {
80 case "cursor":
81 return { customCursorModels: models };
82 case "codex":76 case "codex":
83 default:77 default:
84 return { customCodexModels: models };78 return { customCodexModels: models };
...@@ -95,7 +89,6 @@ function SettingsRouteView() {...@@ -95,7 +89,6 @@ function SettingsRouteView() {
95 Record<ProviderKind, string>89 Record<ProviderKind, string>
96 >({90 >({
97 codex: "",91 codex: "",
98 cursor: "",
99 });92 });
100 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<93 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
101 Partial<Record<ProviderKind, string | null>>94 Partial<Record<ProviderKind, string | null>>
apps/web/src/session-logic.ts+1-1
...@@ -10,7 +10,7 @@ import {...@@ -10,7 +10,7 @@ import {
1010
11import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types";11import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types";
1212
13export type ProviderPickerKind = ProviderKind | "claudeCode";13export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor";
1414
15export const PROVIDER_OPTIONS: Array<{15export const PROVIDER_OPTIONS: Array<{
16 value: ProviderPickerKind;16 value: ProviderPickerKind;
apps/web/src/store.test.ts-23
...@@ -141,27 +141,4 @@ describe("store read model sync", () => {...@@ -141,27 +141,4 @@ describe("store read model sync", () => {
141141
142 expect(next.threads[0]?.model).toBe("gpt-5.3-codex");142 expect(next.threads[0]?.model).toBe("gpt-5.3-codex");
143 });143 });
144
145 it("resolves cursor aliases when session provider is cursor", () => {
146 const initialState = makeState(makeThread());
147 const readModel = makeReadModel(
148 makeReadModelThread({
149 model: "composer",
150 session: {
151 threadId: ThreadId.makeUnsafe("thread-1"),
152 status: "ready",
153 providerName: "cursor",
154 runtimeMode: "approval-required",
155 activeTurnId: null,
156 lastError: null,
157 updatedAt: "2026-02-27T00:00:00.000Z",
158 },
159 }),
160 );
161
162 const next = syncServerReadModel(initialState, readModel);
163
164 expect(next.threads[0]?.model).toBe("composer-1.5");
165 expect(next.threads[0]?.session?.provider).toBe("cursor");
166 });
167});144});
apps/web/src/store.ts+2-20
...@@ -145,43 +145,25 @@ function toLegacySessionStatus(...@@ -145,43 +145,25 @@ function toLegacySessionStatus(
145}145}
146146
147function toLegacyProvider(providerName: string | null): ProviderKind {147function toLegacyProvider(providerName: string | null): ProviderKind {
148 if (providerName === "codex" || providerName === "cursor") {148 if (providerName === "codex") {
149 return providerName;149 return providerName;
150 }150 }
151 return "codex";151 return "codex";
152}152}
153153
154const CODEX_MODEL_SLUGS = new Set<string>(getModelOptions("codex").map((option) => option.slug));154const CODEX_MODEL_SLUGS = new Set<string>(getModelOptions("codex").map((option) => option.slug));
155const CURSOR_MODEL_SLUGS = new Set<string>(getModelOptions("cursor").map((option) => option.slug));
156const CURSOR_DISTINCT_MODEL_SLUGS = new Set(
157 [...CURSOR_MODEL_SLUGS].filter((slug) => !CODEX_MODEL_SLUGS.has(slug)),
158);
159155
160function inferProviderForThreadModel(input: {156function inferProviderForThreadModel(input: {
161 readonly model: string;157 readonly model: string;
162 readonly sessionProviderName: string | null;158 readonly sessionProviderName: string | null;
163}): ProviderKind {159}): ProviderKind {
164 if (160 if (input.sessionProviderName === "codex") {
165 input.sessionProviderName === "codex" ||
166 input.sessionProviderName === "cursor"
167 ) {
168 return input.sessionProviderName;161 return input.sessionProviderName;
169 }162 }
170 const normalizedCursor = normalizeModelSlug(input.model, "cursor");
171 if (normalizedCursor && CURSOR_DISTINCT_MODEL_SLUGS.has(normalizedCursor)) {
172 return "cursor";
173 }
174 const normalizedCodex = normalizeModelSlug(input.model, "codex");163 const normalizedCodex = normalizeModelSlug(input.model, "codex");
175 if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) {164 if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) {
176 return "codex";165 return "codex";
177 }166 }
178 if (
179 input.model.trim().startsWith("composer-") ||
180 input.model.trim().startsWith("gemini-") ||
181 input.model.trim().endsWith("-thinking")
182 ) {
183 return "cursor";
184 }
185 return "codex";167 return "codex";
186}168}
187169
packages/contracts/src/model.ts+1-70
...@@ -1,8 +1,5 @@...@@ -1,8 +1,5 @@
1import { Schema } from "effect";1import { Schema } from "effect";
22
3export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const;
4export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number];
5
6export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;3export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
7export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number];4export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number];
85
...@@ -12,16 +9,8 @@ export const CodexModelOptions = Schema.Struct({...@@ -12,16 +9,8 @@ export const CodexModelOptions = Schema.Struct({
12});9});
13export type CodexModelOptions = typeof CodexModelOptions.Type;10export type CodexModelOptions = typeof CodexModelOptions.Type;
1411
15export const CursorModelOptions = Schema.Struct({
16 reasoning: Schema.optional(Schema.Literals(CURSOR_REASONING_OPTIONS)),
17 fastMode: Schema.optional(Schema.Boolean),
18 thinking: Schema.optional(Schema.Boolean),
19});
20export type CursorModelOptions = typeof CursorModelOptions.Type;
21
22export const ProviderModelOptions = Schema.Struct({12export const ProviderModelOptions = Schema.Struct({
23 codex: Schema.optional(CodexModelOptions),13 codex: Schema.optional(CodexModelOptions),
24 cursor: Schema.optional(CursorModelOptions),
25});14});
26export type ProviderModelOptions = typeof ProviderModelOptions.Type;15export type ProviderModelOptions = typeof ProviderModelOptions.Type;
2716
...@@ -30,25 +19,6 @@ type ModelOption = {...@@ -30,25 +19,6 @@ type ModelOption = {
30 readonly name: string;19 readonly name: string;
31};20};
3221
33type CursorModelFamilyOption = {
34 readonly slug: string;
35 readonly name: string;
36};
37
38export const CURSOR_MODEL_FAMILY_OPTIONS = [
39 { slug: "auto", name: "Auto" },
40 { slug: "composer-1.5", name: "Composer 1.5" },
41 { slug: "composer-1", name: "Composer 1" },
42 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
43 { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" },
44 { slug: "opus-4.6", name: "Claude 4.6 Opus" },
45 { slug: "opus-4.5", name: "Claude 4.5 Opus" },
46 { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" },
47 { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
48] as const satisfies readonly CursorModelFamilyOption[];
49
50export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["slug"];
51
52export const MODEL_OPTIONS_BY_PROVIDER = {22export const MODEL_OPTIONS_BY_PROVIDER = {
53 codex: [23 codex: [
54 { slug: "gpt-5.4", name: "GPT-5.4" },24 { slug: "gpt-5.4", name: "GPT-5.4" },
...@@ -57,37 +27,14 @@ export const MODEL_OPTIONS_BY_PROVIDER = {...@@ -57,37 +27,14 @@ export const MODEL_OPTIONS_BY_PROVIDER = {
57 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },27 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
58 { slug: "gpt-5.2", name: "GPT-5.2" },28 { slug: "gpt-5.2", name: "GPT-5.2" },
59 ],29 ],
60 cursor: [30} as const satisfies Record<"codex", readonly ModelOption[]>;
61 { slug: "auto", name: "Auto" },
62 { slug: "composer-1.5", name: "Composer 1.5" },
63 { slug: "composer-1", name: "Composer 1" },
64 { slug: "gpt-5.3-codex-low", name: "GPT-5.3 Codex Low" },
65 { slug: "gpt-5.3-codex-low-fast", name: "GPT-5.3 Codex Low Fast" },
66 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
67 { slug: "gpt-5.3-codex-fast", name: "GPT-5.3 Codex Fast" },
68 { slug: "gpt-5.3-codex-high", name: "GPT-5.3 Codex High" },
69 { slug: "gpt-5.3-codex-high-fast", name: "GPT-5.3 Codex High Fast" },
70 { slug: "gpt-5.3-codex-xhigh", name: "GPT-5.3 Codex Extra High" },
71 { slug: "gpt-5.3-codex-xhigh-fast", name: "GPT-5.3 Codex Extra High Fast" },
72 { slug: "gpt-5.3-codex-spark-preview", name: "GPT-5.3 Codex Spark" },
73 { slug: "opus-4.6", name: "Claude 4.6 Opus" },
74 { slug: "opus-4.6-thinking", name: "Claude 4.6 Opus (Thinking)" },
75 { slug: "opus-4.5", name: "Claude 4.5 Opus" },
76 { slug: "opus-4.5-thinking", name: "Claude 4.5 Opus (Thinking)" },
77 { slug: "sonnet-4.6", name: "Claude 4.6 Sonnet" },
78 { slug: "sonnet-4.6-thinking", name: "Claude 4.6 Sonnet (Thinking)" },
79 { slug: "gemini-3.1-pro", name: "Gemini 3.1 Pro" },
80 ],
81} as const satisfies Record<"codex" | "cursor", readonly ModelOption[]>;
8231
83type BuiltInModelSlug =32type BuiltInModelSlug =
84 (typeof MODEL_OPTIONS_BY_PROVIDER)[keyof typeof MODEL_OPTIONS_BY_PROVIDER][number]["slug"];33 (typeof MODEL_OPTIONS_BY_PROVIDER)[keyof typeof MODEL_OPTIONS_BY_PROVIDER][number]["slug"];
85export type ModelSlug = BuiltInModelSlug | (string & {});34export type ModelSlug = BuiltInModelSlug | (string & {});
86export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"];
8735
88export const DEFAULT_MODEL_BY_PROVIDER = {36export const DEFAULT_MODEL_BY_PROVIDER = {
89 codex: "gpt-5.3-codex",37 codex: "gpt-5.3-codex",
90 cursor: "opus-4.6-thinking",
91} as const satisfies Record<keyof typeof MODEL_OPTIONS_BY_PROVIDER, ModelSlug>;38} as const satisfies Record<keyof typeof MODEL_OPTIONS_BY_PROVIDER, ModelSlug>;
9239
93// Backward compatibility for existing Codex-only call sites.40// Backward compatibility for existing Codex-only call sites.
...@@ -102,25 +49,10 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = {...@@ -102,25 +49,10 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = {
102 "5.3-spark": "gpt-5.3-codex-spark",49 "5.3-spark": "gpt-5.3-codex-spark",
103 "gpt-5.3-spark": "gpt-5.3-codex-spark",50 "gpt-5.3-spark": "gpt-5.3-codex-spark",
104 },51 },
105 cursor: {
106 composer: "composer-1.5",
107 "composer-1.5": "composer-1.5",
108 "composer-1": "composer-1",
109 "gpt-5.3-codex": "gpt-5.3-codex",
110 "gpt-5.3-codex-spark": "gpt-5.3-codex-spark-preview",
111 "gemini-3.1-pro": "gemini-3.1-pro",
112 "claude-4.6-sonnet-thinking": "sonnet-4.6-thinking",
113 "claude-4.6-opus-thinking": "opus-4.6-thinking",
114 "claude-4.5-opus-thinking": "opus-4.5-thinking",
115 "sonnet-4.6-thinking": "sonnet-4.6-thinking",
116 "opus-4.6-thinking": "opus-4.6-thinking",
117 "opus-4.5-thinking": "opus-4.5-thinking",
118 },
119} as const satisfies Record<keyof typeof MODEL_OPTIONS_BY_PROVIDER, Record<string, ModelSlug>>;52} as const satisfies Record<keyof typeof MODEL_OPTIONS_BY_PROVIDER, Record<string, ModelSlug>>;
12053
121export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {54export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {
122 codex: CODEX_REASONING_EFFORT_OPTIONS,55 codex: CODEX_REASONING_EFFORT_OPTIONS,
123 cursor: [],
124} as const satisfies Record<56} as const satisfies Record<
125 keyof typeof MODEL_OPTIONS_BY_PROVIDER,57 keyof typeof MODEL_OPTIONS_BY_PROVIDER,
126 readonly CodexReasoningEffort[]58 readonly CodexReasoningEffort[]
...@@ -128,5 +60,4 @@ export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {...@@ -128,5 +60,4 @@ export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {
12860
129export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = {61export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = {
130 codex: "high",62 codex: "high",
131 cursor: null,
132} as const satisfies Record<keyof typeof MODEL_OPTIONS_BY_PROVIDER, CodexReasoningEffort | null>;63} as const satisfies Record<keyof typeof MODEL_OPTIONS_BY_PROVIDER, CodexReasoningEffort | null>;
packages/contracts/src/orchestration.test.ts+2-22
...@@ -128,11 +128,11 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (...@@ -128,11 +128,11 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
128 text: "hello",128 text: "hello",
129 attachments: [],129 attachments: [],
130 },130 },
131 provider: "cursor",131 provider: "codex",
132 runtimeMode: "full-access",132 runtimeMode: "full-access",
133 createdAt: "2026-01-01T00:00:00.000Z",133 createdAt: "2026-01-01T00:00:00.000Z",
134 });134 });
135 assert.strictEqual(parsed.provider, "cursor");135 assert.strictEqual(parsed.provider, "codex");
136 assert.strictEqual(parsed.runtimeMode, "full-access");136 assert.strictEqual(parsed.runtimeMode, "full-access");
137 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);137 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
138 }),138 }),
...@@ -166,26 +166,6 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>...@@ -166,26 +166,6 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>
166 }),166 }),
167);167);
168168
169it.effect("accepts cursor provider in thread.turn.start", () =>
170 Effect.gen(function* () {
171 const parsed = yield* decodeThreadTurnStartCommand({
172 type: "thread.turn.start",
173 commandId: "cmd-turn-cursor",
174 threadId: "thread-1",
175 message: {
176 messageId: "msg-3",
177 role: "user",
178 text: "hello",
179 attachments: [],
180 },
181 provider: "cursor",
182 createdAt: "2026-01-01T00:00:00.000Z",
183 });
184 assert.strictEqual(parsed.provider, "cursor");
185 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
186 }),
187);
188
189it.effect(169it.effect(
190 "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode",170 "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode",
191 () =>171 () =>
packages/contracts/src/orchestration.ts+1-1
...@@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = {...@@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = {
27 domainEvent: "orchestration.domainEvent",27 domainEvent: "orchestration.domainEvent",
28} as const;28} as const;
2929
30export const ProviderKind = Schema.Literals(["codex", "cursor"]);30export const ProviderKind = Schema.Literal("codex");
31export type ProviderKind = typeof ProviderKind.Type;31export type ProviderKind = typeof ProviderKind.Type;
32export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";32export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
33export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);33export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
packages/contracts/src/provider.test.ts-25
...@@ -42,31 +42,6 @@ describe("ProviderSessionStartInput", () => {...@@ -42,31 +42,6 @@ describe("ProviderSessionStartInput", () => {
42 }),42 }),
43 ).toThrow();43 ).toThrow();
44 });44 });
45
46 it("accepts cursor provider payloads", () => {
47 const parsed = decodeProviderSessionStartInput({
48 threadId: "thread-1",
49 provider: "cursor",
50 cwd: "/tmp/workspace",
51 model: "composer-1.5",
52 modelOptions: {
53 cursor: {
54 thinking: true,
55 },
56 },
57 providerOptions: {
58 cursor: {
59 binaryPath: "/usr/local/bin/agent",
60 },
61 },
62 runtimeMode: "approval-required",
63 });
64 expect(parsed.provider).toBe("cursor");
65 expect(parsed.model).toBe("composer-1.5");
66 expect(parsed.modelOptions?.cursor?.thinking).toBe(true);
67 expect(parsed.providerOptions?.cursor?.binaryPath).toBe("/usr/local/bin/agent");
68 expect(parsed.runtimeMode).toBe("approval-required");
69 });
70});45});
7146
72describe("ProviderSendTurnInput", () => {47describe("ProviderSendTurnInput", () => {
packages/contracts/src/provider.ts-5
...@@ -50,13 +50,8 @@ const CodexProviderStartOptions = Schema.Struct({...@@ -50,13 +50,8 @@ const CodexProviderStartOptions = Schema.Struct({
50 homePath: Schema.optional(TrimmedNonEmptyStringSchema),50 homePath: Schema.optional(TrimmedNonEmptyStringSchema),
51});51});
5252
53const CursorProviderStartOptions = Schema.Struct({
54 binaryPath: Schema.optional(TrimmedNonEmptyStringSchema),
55});
56
57const ProviderStartOptions = Schema.Struct({53const ProviderStartOptions = Schema.Struct({
58 codex: Schema.optional(CodexProviderStartOptions),54 codex: Schema.optional(CodexProviderStartOptions),
59 cursor: Schema.optional(CursorProviderStartOptions),
60});55});
6156
62export const ProviderSessionStartInput = Schema.Struct({57export const ProviderSessionStartInput = Schema.Struct({
packages/contracts/src/providerRuntime.ts-3
...@@ -20,9 +20,6 @@ const RuntimeEventRawSource = Schema.Literals([...@@ -20,9 +20,6 @@ const RuntimeEventRawSource = Schema.Literals([
20 "codex.app-server.request",20 "codex.app-server.request",
21 "codex.eventmsg",21 "codex.eventmsg",
22 "codex.sdk.thread-event",22 "codex.sdk.thread-event",
23 "cursor.acp.notification",
24 "cursor.acp.request",
25 "cursor.acp.response",
26]);23]);
27export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;24export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;
2825
packages/shared/src/model.test.ts+1-89
...@@ -1,24 +1,13 @@...@@ -1,24 +1,13 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import {2import { DEFAULT_MODEL, MODEL_OPTIONS } from "@t3tools/contracts";
3 CURSOR_MODEL_FAMILY_OPTIONS,
4 CURSOR_REASONING_OPTIONS,
5 DEFAULT_MODEL,
6 DEFAULT_MODEL_BY_PROVIDER,
7 MODEL_OPTIONS,
8 MODEL_OPTIONS_BY_PROVIDER,
9} from "../../contracts/src";
103
11import {4import {
12 getDefaultModel,5 getDefaultModel,
13 getDefaultReasoningEffort,6 getDefaultReasoningEffort,
14 getCursorModelFamilyOptions,
15 getModelOptions,7 getModelOptions,
16 getReasoningEffortOptions,8 getReasoningEffortOptions,
17 normalizeModelSlug,9 normalizeModelSlug,
18 parseCursorModelSelection,
19 resolveCursorModelFromSelection,
20 resolveModelSlug,10 resolveModelSlug,
21 resolveModelSlugForProvider,
22} from "./model";11} from "./model";
2312
24describe("normalizeModelSlug", () => {13describe("normalizeModelSlug", () => {
...@@ -38,17 +27,6 @@ describe("normalizeModelSlug", () => {...@@ -38,17 +27,6 @@ describe("normalizeModelSlug", () => {
38 expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2");27 expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2");
39 expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");28 expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
40 });29 });
41
42 it("uses provider-specific aliases", () => {
43 expect(normalizeModelSlug("composer", "cursor")).toBe("composer-1.5");
44 expect(normalizeModelSlug("gpt-5.3-codex-spark", "cursor")).toBe(
45 "gpt-5.3-codex-spark-preview",
46 );
47 expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1");
48 expect(normalizeModelSlug("claude-4.6-sonnet-thinking", "cursor")).toBe(
49 "sonnet-4.6-thinking",
50 );
51 });
52});30});
5331
54describe("resolveModelSlug", () => {32describe("resolveModelSlug", () => {
...@@ -67,70 +45,9 @@ describe("resolveModelSlug", () => {...@@ -67,70 +45,9 @@ describe("resolveModelSlug", () => {
67 expect(resolveModelSlug(model.slug)).toBe(model.slug);45 expect(resolveModelSlug(model.slug)).toBe(model.slug);
68 }46 }
69 });47 });
70
71 it("supports provider-aware resolution", () => {
72 expect(resolveModelSlugForProvider("cursor", undefined)).toBe(
73 DEFAULT_MODEL_BY_PROVIDER.cursor,
74 );
75 expect(resolveModelSlugForProvider("cursor", "composer")).toBe("composer-1.5");
76 expect(resolveModelSlugForProvider("cursor", "gpt-5.3-codex-high-fast")).toBe(
77 "gpt-5.3-codex-high-fast",
78 );
79 expect(resolveModelSlugForProvider("cursor", "claude-sonnet-4-6")).toBe(
80 DEFAULT_MODEL_BY_PROVIDER.cursor,
81 );
82 });
83
84 it("keeps codex defaults for backward compatibility", () => {48 it("keeps codex defaults for backward compatibility", () => {
85 expect(getDefaultModel()).toBe(DEFAULT_MODEL);49 expect(getDefaultModel()).toBe(DEFAULT_MODEL);
86 expect(getModelOptions()).toEqual(MODEL_OPTIONS);50 expect(getModelOptions()).toEqual(MODEL_OPTIONS);
87 expect(getModelOptions("cursor")).toEqual(MODEL_OPTIONS_BY_PROVIDER.cursor);
88 expect(getCursorModelFamilyOptions()).toEqual(CURSOR_MODEL_FAMILY_OPTIONS);
89 });
90});
91
92describe("cursor model selection", () => {
93 it("includes the expected cursor reasoning levels and families", () => {
94 expect(CURSOR_REASONING_OPTIONS).toEqual(["low", "normal", "high", "xhigh"]);
95 expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("gpt-5.3-codex");
96 expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("opus-4.6");
97 });
98
99 it("parses codex reasoning and fast mode variants", () => {
100 expect(parseCursorModelSelection("gpt-5.3-codex-high-fast")).toEqual({
101 family: "gpt-5.3-codex",
102 reasoning: "high",
103 fast: true,
104 thinking: false,
105 });
106 expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual(
107 parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor),
108 );
109 });
110
111 it("parses and resolves thinking variants", () => {
112 expect(parseCursorModelSelection("sonnet-4.6-thinking")).toEqual({
113 family: "sonnet-4.6",
114 reasoning: "normal",
115 fast: false,
116 thinking: true,
117 });
118 expect(
119 resolveCursorModelFromSelection({
120 family: "sonnet-4.6",
121 thinking: true,
122 }),
123 ).toBe("sonnet-4.6-thinking");
124 });
125
126 it("resolves codex family selections into concrete model ids", () => {
127 expect(
128 resolveCursorModelFromSelection({
129 family: "gpt-5.3-codex",
130 reasoning: "xhigh",
131 fast: true,
132 }),
133 ).toBe("gpt-5.3-codex-xhigh-fast");
134 });51 });
135});52});
13653
...@@ -138,15 +55,10 @@ describe("getReasoningEffortOptions", () => {...@@ -138,15 +55,10 @@ describe("getReasoningEffortOptions", () => {
138 it("returns codex reasoning options for codex", () => {55 it("returns codex reasoning options for codex", () => {
139 expect(getReasoningEffortOptions("codex")).toEqual(["xhigh", "high", "medium", "low"]);56 expect(getReasoningEffortOptions("codex")).toEqual(["xhigh", "high", "medium", "low"]);
140 });57 });
141
142 it("returns no reasoning options for cursor", () => {
143 expect(getReasoningEffortOptions("cursor")).toEqual([]);
144 });
145});58});
14659
147describe("getDefaultReasoningEffort", () => {60describe("getDefaultReasoningEffort", () => {
148 it("returns provider-scoped defaults", () => {61 it("returns provider-scoped defaults", () => {
149 expect(getDefaultReasoningEffort("codex")).toBe("high");62 expect(getDefaultReasoningEffort("codex")).toBe("high");
150 expect(getDefaultReasoningEffort("cursor")).toBeNull();
151 });63 });
152});64});
packages/shared/src/model.ts+6-219
...@@ -1,233 +1,26 @@...@@ -1,233 +1,26 @@
1import {1import {
2 CODEX_REASONING_EFFORT_OPTIONS,2 CODEX_REASONING_EFFORT_OPTIONS,
3 CURSOR_MODEL_FAMILY_OPTIONS,
4 CURSOR_REASONING_OPTIONS,
5 DEFAULT_MODEL,3 DEFAULT_MODEL,
6 DEFAULT_MODEL_BY_PROVIDER,4 DEFAULT_MODEL_BY_PROVIDER,
7 MODEL_OPTIONS_BY_PROVIDER,5 MODEL_OPTIONS_BY_PROVIDER,
8 MODEL_SLUG_ALIASES_BY_PROVIDER,6 MODEL_SLUG_ALIASES_BY_PROVIDER,
9 type CodexReasoningEffort,7 type CodexReasoningEffort,
10 type CursorModelFamily,
11 type CursorModelSlug,
12 type CursorReasoningOption,
13 type ModelSlug,8 type ModelSlug,
14 type ProviderKind,9 type ProviderKind,
15} from "../../contracts/src";10} from "@t3tools/contracts";
1611
17type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER;12type CatalogProvider = keyof typeof MODEL_OPTIONS_BY_PROVIDER;
1813
19type CursorModelCapability = {
20 readonly supportsReasoning: boolean;
21 readonly supportsFast: boolean;
22 readonly supportsThinking: boolean;
23 readonly defaultReasoning: CursorReasoningOption;
24 readonly defaultThinking: boolean;
25};
26
27const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCapability> = {
28 auto: {
29 supportsReasoning: false,
30 supportsFast: false,
31 supportsThinking: false,
32 defaultReasoning: "normal",
33 defaultThinking: false,
34 },
35 "composer-1.5": {
36 supportsReasoning: false,
37 supportsFast: false,
38 supportsThinking: false,
39 defaultReasoning: "normal",
40 defaultThinking: false,
41 },
42 "composer-1": {
43 supportsReasoning: false,
44 supportsFast: false,
45 supportsThinking: false,
46 defaultReasoning: "normal",
47 defaultThinking: false,
48 },
49 "gpt-5.3-codex": {
50 supportsReasoning: true,
51 supportsFast: true,
52 supportsThinking: false,
53 defaultReasoning: "normal",
54 defaultThinking: false,
55 },
56 "gpt-5.3-codex-spark-preview": {
57 supportsReasoning: false,
58 supportsFast: false,
59 supportsThinking: false,
60 defaultReasoning: "normal",
61 defaultThinking: false,
62 },
63 "opus-4.6": {
64 supportsReasoning: false,
65 supportsFast: false,
66 supportsThinking: true,
67 defaultReasoning: "normal",
68 defaultThinking: true,
69 },
70 "opus-4.5": {
71 supportsReasoning: false,
72 supportsFast: false,
73 supportsThinking: true,
74 defaultReasoning: "normal",
75 defaultThinking: true,
76 },
77 "sonnet-4.6": {
78 supportsReasoning: false,
79 supportsFast: false,
80 supportsThinking: true,
81 defaultReasoning: "normal",
82 defaultThinking: true,
83 },
84 "gemini-3.1-pro": {
85 supportsReasoning: false,
86 supportsFast: false,
87 supportsThinking: false,
88 defaultReasoning: "normal",
89 defaultThinking: false,
90 },
91};
92
93function hasModelCatalog(provider: ProviderKind): provider is CatalogProvider {
94 return Object.hasOwn(MODEL_OPTIONS_BY_PROVIDER, provider);
95}
96
97const MODEL_SLUG_SET_BY_PROVIDER: Record<CatalogProvider, ReadonlySet<ModelSlug>> = {14const MODEL_SLUG_SET_BY_PROVIDER: Record<CatalogProvider, ReadonlySet<ModelSlug>> = {
98 codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)),15 codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)),
99 cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)),
100};16};
10117
102const CURSOR_MODEL_FAMILY_SET = new Set<CursorModelFamily>(
103 CURSOR_MODEL_FAMILY_OPTIONS.map((option) => option.slug),
104);
105
106export interface CursorModelSelection {
107 readonly family: CursorModelFamily;
108 readonly reasoning: CursorReasoningOption;
109 readonly fast: boolean;
110 readonly thinking: boolean;
111}
112
113export function getModelOptions(provider: ProviderKind = "codex") {18export function getModelOptions(provider: ProviderKind = "codex") {
114 return hasModelCatalog(provider) ? MODEL_OPTIONS_BY_PROVIDER[provider] : [];19 return MODEL_OPTIONS_BY_PROVIDER[provider];
115}
116
117export function getCursorModelFamilyOptions() {
118 return CURSOR_MODEL_FAMILY_OPTIONS;
119}
120
121export function getCursorModelCapabilities(family: CursorModelFamily) {
122 return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family];
123}
124
125function fallbackCursorModelFamily(): CursorModelFamily {
126 const fallback = parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor);
127 return fallback.family;
128}
129
130function resolveCursorModelFamily(model: string | null | undefined): CursorModelFamily {
131 const normalized = normalizeModelSlug(model, "cursor");
132 if (!normalized) {
133 return fallbackCursorModelFamily();
134 }
135
136 if (
137 normalized === "gpt-5.3-codex" ||
138 normalized === "gpt-5.3-codex-fast" ||
139 normalized === "gpt-5.3-codex-low" ||
140 normalized === "gpt-5.3-codex-low-fast" ||
141 normalized === "gpt-5.3-codex-high" ||
142 normalized === "gpt-5.3-codex-high-fast" ||
143 normalized === "gpt-5.3-codex-xhigh" ||
144 normalized === "gpt-5.3-codex-xhigh-fast"
145 ) {
146 return "gpt-5.3-codex";
147 }
148
149 if (normalized === "sonnet-4.6-thinking") {
150 return "sonnet-4.6";
151 }
152 if (normalized === "opus-4.6-thinking") {
153 return "opus-4.6";
154 }
155 if (normalized === "opus-4.5-thinking") {
156 return "opus-4.5";
157 }
158
159 return CURSOR_MODEL_FAMILY_SET.has(normalized as CursorModelFamily)
160 ? (normalized as CursorModelFamily)
161 : fallbackCursorModelFamily();
162}
163
164function resolveCursorReasoning(model: CursorModelSlug): CursorReasoningOption {
165 if (model.includes("-xhigh")) return "xhigh";
166 if (model.includes("-high")) return "high";
167 if (model.includes("-low")) return "low";
168 return "normal";
169}
170
171export function parseCursorModelSelection(model: string | null | undefined): CursorModelSelection {
172 const family = resolveCursorModelFamily(model);
173 const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family];
174 const normalized = resolveModelSlugForProvider("cursor", model) as CursorModelSlug;
175
176 if (capability.supportsReasoning) {
177 return {
178 family,
179 reasoning: resolveCursorReasoning(normalized),
180 fast: normalized.endsWith("-fast"),
181 thinking: false,
182 };
183 }
184
185 if (capability.supportsThinking) {
186 return {
187 family,
188 reasoning: capability.defaultReasoning,
189 fast: false,
190 thinking: normalized.endsWith("-thinking"),
191 };
192 }
193
194 return {
195 family,
196 reasoning: capability.defaultReasoning,
197 fast: false,
198 thinking: capability.defaultThinking,
199 };
200}
201
202export function resolveCursorModelFromSelection(input: {
203 readonly family: CursorModelFamily;
204 readonly reasoning?: CursorReasoningOption | null;
205 readonly fast?: boolean;
206 readonly thinking?: boolean;
207}): CursorModelSlug {
208 const family = resolveCursorModelFamily(input.family);
209 const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family];
210
211 if (capability.supportsReasoning) {
212 const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal")
213 ? (input.reasoning ?? "normal")
214 : capability.defaultReasoning;
215 const reasoningSuffix = reasoning === "normal" ? "" : `-${reasoning}`;
216 const fastSuffix = input.fast ? "-fast" : "";
217 const candidate = `${family}${reasoningSuffix}${fastSuffix}`;
218 return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug;
219 }
220
221 if (capability.supportsThinking) {
222 const candidate = input.thinking ? `${family}-thinking` : family;
223 return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug;
224 }
225
226 return resolveModelSlugForProvider("cursor", family) as CursorModelSlug;
227}20}
22821
229export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {22export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
230 return hasModelCatalog(provider) ? DEFAULT_MODEL_BY_PROVIDER[provider] : DEFAULT_MODEL;23 return DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL;
231}24}
23225
233export function normalizeModelSlug(26export function normalizeModelSlug(
...@@ -243,10 +36,6 @@ export function normalizeModelSlug(...@@ -243,10 +36,6 @@ export function normalizeModelSlug(
243 return null;36 return null;
244 }37 }
24538
246 if (!hasModelCatalog(provider)) {
247 return trimmed as ModelSlug;
248 }
249
250 const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record<string, ModelSlug>;39 const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record<string, ModelSlug>;
251 return aliases[trimmed] ?? (trimmed as ModelSlug);40 return aliases[trimmed] ?? (trimmed as ModelSlug);
252}41}
...@@ -260,11 +49,9 @@ export function resolveModelSlug(...@@ -260,11 +49,9 @@ export function resolveModelSlug(
260 return getDefaultModel(provider);49 return getDefaultModel(provider);
261 }50 }
26251
263 if (!hasModelCatalog(provider)) {52 return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized)
264 return getDefaultModel(provider);53 ? normalized
265 }54 : getDefaultModel(provider);
266
267 return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized) ? normalized : getDefaultModel(provider);
268}55}
26956
270export function resolveModelSlugForProvider(57export function resolveModelSlugForProvider(