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 {
188188}
189189
190190interface MakeOrchestrationIntegrationHarnessOptions {
191 readonly provider?: "codex" | "cursor";
191 readonly provider?: "codex";
192192 readonly realCodex?: boolean;
193193}
194194
apps/server/integration/TestProviderAdapter.integration.ts+5-5
......@@ -35,7 +35,7 @@ export interface TestTurnResponse {
3535export type FixtureProviderRuntimeEvent = {
3636 readonly type: string;
3737 readonly eventId: EventId;
38 readonly provider: "codex" | "cursor";
38 readonly provider: "codex";
3939 readonly createdAt: string;
4040 readonly threadId: string;
4141 readonly turnId?: string | undefined;
......@@ -178,7 +178,7 @@ function normalizeFixtureEvent(rawEvent: Record<string, unknown>): ProviderRunti
178178
179179export interface TestProviderAdapterHarness {
180180 readonly adapter: ProviderAdapterShape<ProviderAdapterError>;
181 readonly provider: "codex" | "cursor";
181 readonly provider: "codex";
182182 readonly queueTurnResponse: (
183183 threadId: ThreadId,
184184 response: TestTurnResponse,
......@@ -198,7 +198,7 @@ export interface TestProviderAdapterHarness {
198198}
199199
200200interface MakeTestProviderAdapterHarnessOptions {
201 readonly provider?: "codex" | "cursor";
201 readonly provider?: "codex";
202202}
203203
204204function nowIso(): string {
......@@ -206,7 +206,7 @@ function nowIso(): string {
206206}
207207
208208function sessionNotFound(
209 provider: "codex" | "cursor",
209 provider: "codex",
210210 threadId: ThreadId,
211211): ProviderAdapterSessionNotFoundError {
212212 return new ProviderAdapterSessionNotFoundError({
......@@ -216,7 +216,7 @@ function sessionNotFound(
216216}
217217
218218function missingSessionEffect(
219 provider: "codex" | "cursor",
219 provider: "codex",
220220 threadId: ThreadId,
221221): Effect.Effect<never, ProviderAdapterError> {
222222 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");
3232const THREAD_ID = ThreadId.makeUnsafe("thread-1");
3333const FIXTURE_TURN_ID = "fixture-turn";
3434const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
35type IntegrationProvider = "codex" | "cursor";
35type IntegrationProvider = "codex";
3636
3737function nowIso() {
3838 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);
4343type LegacyProviderRuntimeEvent = {
4444 readonly type: string;
4545 readonly eventId: EventId;
46 readonly provider: "codex" | "cursor";
46 readonly provider: "codex";
4747 readonly createdAt: string;
4848 readonly threadId: ThreadId;
4949 readonly turnId?: string | undefined;
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+4-147
......@@ -93,8 +93,7 @@ describe("ProviderCommandReactor", () => {
9393 typeof input === "object" &&
9494 input !== null &&
9595 "provider" in input &&
96 (input.provider === "codex" ||
97 input.provider === "cursor")
96 input.provider === "codex"
9897 ? input.provider
9998 : "codex";
10099 const resumeCursor =
......@@ -186,7 +185,7 @@ describe("ProviderCommandReactor", () => {
186185 listSessions: () => Effect.succeed(runtimeSessions),
187186 getCapabilities: (provider) =>
188187 Effect.succeed({
189 sessionModelSwitch: provider === "cursor" ? "unsupported" : "in-session",
188 sessionModelSwitch: provider === "codex" ? "in-session" : "in-session",
190189 }),
191190 rollbackConversation: () => unsupported(),
192191 stopAll: () => Effect.void,
......@@ -384,43 +383,6 @@ describe("ProviderCommandReactor", () => {
384383 });
385384 });
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
424386 it("reuses the same provider session when runtime mode is unchanged", async () => {
425387 const harness = await createHarness();
426388 const now = new Date().toISOString();
......@@ -467,111 +429,6 @@ describe("ProviderCommandReactor", () => {
467429 expect(harness.stopSession.mock.calls.length).toBe(0);
468430 });
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
575432 it("restarts the provider session when runtime mode is updated on the thread", async () => {
576433 const harness = await createHarness();
577434 const now = new Date().toISOString();
......@@ -852,7 +709,7 @@ describe("ProviderCommandReactor", () => {
852709 harness.respondToRequest.mockImplementation(() =>
853710 Effect.fail(
854711 new ProviderAdapterRequestError({
855 provider: "cursor",
712 provider: "codex",
856713 method: "session/request_permission",
857714 detail: "Unknown pending permission request: approval-request-1",
858715 }),
......@@ -867,7 +724,7 @@ describe("ProviderCommandReactor", () => {
867724 session: {
868725 threadId: ThreadId.makeUnsafe("thread-1"),
869726 status: "running",
870 providerName: "cursor",
727 providerName: "codex",
871728 runtimeMode: "approval-required",
872729 activeTurnId: null,
873730 lastError: null,
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+1-4
......@@ -211,10 +211,7 @@ const make = Effect.gen(function* () {
211211
212212 const desiredRuntimeMode = thread.runtimeMode;
213213 const currentProvider: ProviderKind | undefined =
214 thread.session?.providerName === "codex" ||
215 thread.session?.providerName === "cursor"
216 ? thread.session.providerName
217 : undefined;
214 thread.session?.providerName === "codex" ? thread.session.providerName : undefined;
218215 const preferredProvider: ProviderKind | undefined = options?.provider ?? currentProvider;
219216 const desiredModel = options?.model ?? thread.model;
220217 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);
4141type LegacyProviderRuntimeEvent = {
4242 readonly type: string;
4343 readonly eventId: EventId;
44 readonly provider: "codex" | "cursor";
44 readonly provider: "codex";
4545 readonly createdAt: string;
4646 readonly threadId: ThreadId;
4747 readonly turnId?: string | undefined;
apps/server/src/provider/Layers/CodexAdapter.test.ts-24
......@@ -157,30 +157,6 @@ const validationLayer = it.layer(
157157);
158158
159159validationLayer("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
184160 it.effect("maps codex model options before starting a session", () =>
185161 Effect.gen(function* () {
186162 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);
5151type LegacyProviderRuntimeEvent = {
5252 readonly type: string;
5353 readonly eventId: EventId;
54 readonly provider: "codex" | "cursor";
54 readonly provider: "codex";
5555 readonly createdAt: string;
5656 readonly threadId: ThreadId;
5757 readonly turnId?: string | undefined;
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts-22
......@@ -174,26 +174,4 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL
174174
175175 fs.rmSync(tempDir, { recursive: true, force: true });
176176 }));
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 }));
199177});
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+1-1
......@@ -25,7 +25,7 @@ function decodeProviderKind(
2525 providerName: string,
2626 operation: string,
2727): Effect.Effect<ProviderKind, ProviderSessionDirectoryPersistenceError> {
28 if (providerName === "codex" || providerName === "cursor") {
28 if (providerName === "codex") {
2929 return Effect.succeed(providerName);
3030 }
3131 return Effect.fail(
apps/web/src/appSettings.test.ts-21
......@@ -15,13 +15,6 @@ describe("normalizeCustomModelSlugs", () => {
1515 ]),
1616 ).toEqual(["custom/internal-model"]);
1717 });
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 });
2518});
2619
2720describe("getAppModelOptions", () => {
......@@ -47,14 +40,6 @@ describe("getAppModelOptions", () => {
4740 isCustom: true,
4841 });
4942 });
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 });
5843});
5944
6045describe("getSlashModelOptions", () => {
......@@ -79,10 +64,4 @@ describe("getSlashModelOptions", () => {
7964
8065 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
8166 });
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 });
8867});
apps/web/src/appSettings.ts-5
......@@ -8,7 +8,6 @@ const MAX_CUSTOM_MODEL_COUNT = 32;
88export const MAX_CUSTOM_MODEL_LENGTH = 256;
99const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record<ProviderKind, ReadonlySet<string>> = {
1010 codex: new Set(getModelOptions("codex").map((option) => option.slug)),
11 cursor: new Set(getModelOptions("cursor").map((option) => option.slug)),
1211};
1312
1413const AppSettingsSchema = Schema.Struct({
......@@ -25,9 +24,6 @@ const AppSettingsSchema = Schema.Struct({
2524 customCodexModels: Schema.Array(Schema.String).pipe(
2625 Schema.withConstructorDefault(() => Option.some([])),
2726 ),
28 customCursorModels: Schema.Array(Schema.String).pipe(
29 Schema.withConstructorDefault(() => Option.some([])),
30 ),
3127});
3228export type AppSettings = typeof AppSettingsSchema.Type;
3329export interface AppModelOption {
......@@ -75,7 +71,6 @@ function normalizeAppSettings(settings: AppSettings): AppSettings {
7571 return {
7672 ...settings,
7773 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
78 customCursorModels: normalizeCustomModelSlugs(settings.customCursorModels, "cursor"),
7974 };
8075}
8176
apps/web/src/components/ChatView.tsx+12-287
......@@ -1,12 +1,10 @@
11import {
22 type ApprovalRequestId,
3 CURSOR_REASONING_OPTIONS,
43 DEFAULT_MODEL,
54 EDITORS,
65 type EditorId,
76 type KeybindingCommand,
87 type CodexReasoningEffort,
9 type CursorReasoningOption,
108 type MessageId,
119 type ProjectId,
1210 type ProjectEntry,
......@@ -27,12 +25,8 @@ import {
2725import {
2826 getDefaultModel,
2927 getDefaultReasoningEffort,
30 getCursorModelCapabilities,
31 getCursorModelFamilyOptions,
3228 getReasoningEffortOptions,
3329 normalizeModelSlug,
34 parseCursorModelSelection,
35 resolveCursorModelFromSelection,
3630 resolveModelSlugForProvider,
3731} from "@t3tools/shared/model";
3832import {
......@@ -761,10 +755,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
761755 ? (sessionProvider ?? selectedProviderByThreadId ?? null)
762756 : null;
763757 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;
768758 const baseThreadModel = resolveModelSlugForProvider(
769759 selectedProvider,
770760 activeThread?.model ??
......@@ -811,40 +801,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
811801 };
812802 return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined;
813803 }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]);
814 const selectedCursorModel = useMemo(
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;
804 const selectedModelForPicker = selectedModel;
832805 const modelOptionsByProvider = useMemo(
833806 () => getCustomModelOptionsByProvider(settings),
834807 [settings],
835808 );
836809 const selectedModelForPickerWithCustomFallback = useMemo(() => {
837 if (selectedProvider !== "cursor") {
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;
810 const currentOptions = modelOptionsByProvider[selectedProvider];
845811 return currentOptions.some((option) => option.slug === selectedModelForPicker)
846812 ? selectedModelForPicker
847 : selectedModelForPicker;
813 : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker);
848814 }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]);
849815 const searchableModelOptions = useMemo(
850816 () =>
......@@ -3009,73 +2975,23 @@ export default function ChatView({ threadId }: ChatViewProps) {
30092975 const onProviderModelSelect = useCallback(
30102976 (provider: ProviderKind, model: ModelSlug) => {
30112977 if (!activeThread) return;
3012 if (cursorModelSelectionLockedReason !== null && provider === "cursor") {
3013 scheduleComposerFocus();
3014 return;
3015 }
30162978 if (lockedProvider !== null && provider !== lockedProvider) {
30172979 scheduleComposerFocus();
30182980 return;
30192981 }
3020 const resolvedModel =
3021 provider === "cursor"
3022 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))
3023 : resolveModelSlugForProvider(provider, model);
2982 const resolvedModel = resolveModelSlugForProvider(provider, model);
30242983 setComposerDraftProvider(activeThread.id, provider);
30252984 setComposerDraftModel(activeThread.id, resolvedModel);
30262985 scheduleComposerFocus();
30272986 },
30282987 [
30292988 activeThread,
3030 cursorModelSelectionLockedReason,
30312989 lockedProvider,
30322990 scheduleComposerFocus,
30332991 setComposerDraftModel,
30342992 setComposerDraftProvider,
30352993 ],
30362994 );
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 );
30792995 const onEffortSelect = useCallback(
30802996 (effort: CodexReasoningEffort) => {
30812997 setComposerDraftEffort(threadId, effort);
......@@ -3594,82 +3510,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
35943510 <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">
35953511 <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">
35963512 {/* Provider/model picker */}
3597 {cursorModelSelectionLockedReason ? (
3598 <Tooltip>
3599 <TooltipTrigger
3600 render={
3601 <span className="inline-flex">
3602 <ProviderModelPicker
3603 provider={selectedProvider}
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 )}
3513 <ProviderModelPicker
3514 provider={selectedProvider}
3515 model={selectedModelForPickerWithCustomFallback}
3516 lockedProvider={lockedProvider}
3517 modelOptionsByProvider={modelOptionsByProvider}
3518 onProviderModelChange={onProviderModelSelect}
3519 />
36323520
3633 {selectedCursorModel &&
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 ? (
3521 {selectedProvider === "codex" && selectedEffort != null ? (
36733522 <>
36743523 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
36753524 <CodexTraitsPicker
......@@ -5297,18 +5146,9 @@ const COMING_SOON_PROVIDER_OPTIONS = [
52975146
52985147function getCustomModelOptionsByProvider(settings: {
52995148 customCodexModels: readonly string[];
5300 customCursorModels: readonly string[];
53015149}): Record<ProviderKind, ReadonlyArray<{ slug: string; name: string }>> {
5302 const cursorFamilyOptions = getCursorModelFamilyOptions();
53035150 return {
53045151 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 ],
53125152 };
53135153}
53145154
......@@ -5348,10 +5188,6 @@ function resolveModelForProviderPicker(
53485188 return resolved.slug;
53495189 }
53505190
5351 if (provider === "cursor") {
5352 return parseCursorModelSelection(normalized).family;
5353 }
5354
53555191 return null;
53565192}
53575193
......@@ -5562,117 +5398,6 @@ const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
55625398 );
55635399});
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
56765401const OpenInPicker = memo(function OpenInPicker({
56775402 keybindings,
56785403 availableEditors,
apps/web/src/composerDraftStore.test.ts+3-3
......@@ -394,15 +394,15 @@ describe("composerDraftStore setProvider", () => {
394394 it("persists provider-only selection even when prompt/model are empty", () => {
395395 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");
400400 });
401401
402402 it("removes empty provider-only draft when provider is reset", () => {
403403 const store = useComposerDraftStore.getState();
404404
405 store.setProvider(threadId, "cursor");
405 store.setProvider(threadId, "codex");
406406 store.setProvider(threadId, null);
407407
408408 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
apps/web/src/composerDraftStore.ts+1-1
......@@ -208,7 +208,7 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
208208}
209209
210210function normalizeProviderKind(value: unknown): ProviderKind | null {
211 return value === "codex" || value === "cursor" ? value : null;
211 return value === "codex" ? value : null;
212212}
213213
214214function revokeObjectPreviewUrl(previewUrl: string): void {
apps/web/src/routes/_chat.settings.tsx-7
......@@ -54,8 +54,6 @@ function getCustomModelsForProvider(
5454 provider: ProviderKind,
5555) {
5656 switch (provider) {
57 case "cursor":
58 return settings.customCursorModels;
5957 case "codex":
6058 default:
6159 return settings.customCodexModels;
......@@ -67,8 +65,6 @@ function getDefaultCustomModelsForProvider(
6765 provider: ProviderKind,
6866) {
6967 switch (provider) {
70 case "cursor":
71 return defaults.customCursorModels;
7268 case "codex":
7369 default:
7470 return defaults.customCodexModels;
......@@ -77,8 +73,6 @@ function getDefaultCustomModelsForProvider(
7773
7874function patchCustomModels(provider: ProviderKind, models: string[]) {
7975 switch (provider) {
80 case "cursor":
81 return { customCursorModels: models };
8276 case "codex":
8377 default:
8478 return { customCodexModels: models };
......@@ -95,7 +89,6 @@ function SettingsRouteView() {
9589 Record<ProviderKind, string>
9690 >({
9791 codex: "",
98 cursor: "",
9992 });
10093 const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState<
10194 Partial<Record<ProviderKind, string | null>>
apps/web/src/session-logic.ts+1-1
......@@ -10,7 +10,7 @@ import {
1010
1111import type { ChatMessage, ProposedPlan, SessionPhase, ThreadSession, TurnDiffSummary } from "./types";
1212
13export type ProviderPickerKind = ProviderKind | "claudeCode";
13export type ProviderPickerKind = ProviderKind | "claudeCode" | "cursor";
1414
1515export const PROVIDER_OPTIONS: Array<{
1616 value: ProviderPickerKind;
apps/web/src/store.test.ts-23
......@@ -141,27 +141,4 @@ describe("store read model sync", () => {
141141
142142 expect(next.threads[0]?.model).toBe("gpt-5.3-codex");
143143 });
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 });
167144});
apps/web/src/store.ts+2-20
......@@ -145,43 +145,25 @@ function toLegacySessionStatus(
145145}
146146
147147function toLegacyProvider(providerName: string | null): ProviderKind {
148 if (providerName === "codex" || providerName === "cursor") {
148 if (providerName === "codex") {
149149 return providerName;
150150 }
151151 return "codex";
152152}
153153
154154const 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
160156function inferProviderForThreadModel(input: {
161157 readonly model: string;
162158 readonly sessionProviderName: string | null;
163159}): ProviderKind {
164 if (
165 input.sessionProviderName === "codex" ||
166 input.sessionProviderName === "cursor"
167 ) {
160 if (input.sessionProviderName === "codex") {
168161 return input.sessionProviderName;
169162 }
170 const normalizedCursor = normalizeModelSlug(input.model, "cursor");
171 if (normalizedCursor && CURSOR_DISTINCT_MODEL_SLUGS.has(normalizedCursor)) {
172 return "cursor";
173 }
174163 const normalizedCodex = normalizeModelSlug(input.model, "codex");
175164 if (normalizedCodex && CODEX_MODEL_SLUGS.has(normalizedCodex)) {
176165 return "codex";
177166 }
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 }
185167 return "codex";
186168}
187169
packages/contracts/src/model.ts+1-70
......@@ -1,8 +1,5 @@
11import { Schema } from "effect";
22
3export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const;
4export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number];
5
63export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
74export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number];
85
......@@ -12,16 +9,8 @@ export const CodexModelOptions = Schema.Struct({
129});
1310export 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
2212export const ProviderModelOptions = Schema.Struct({
2313 codex: Schema.optional(CodexModelOptions),
24 cursor: Schema.optional(CursorModelOptions),
2514});
2615export type ProviderModelOptions = typeof ProviderModelOptions.Type;
2716
......@@ -30,25 +19,6 @@ type ModelOption = {
3019 readonly name: string;
3120};
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
5222export const MODEL_OPTIONS_BY_PROVIDER = {
5323 codex: [
5424 { slug: "gpt-5.4", name: "GPT-5.4" },
......@@ -57,37 +27,14 @@ export const MODEL_OPTIONS_BY_PROVIDER = {
5727 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
5828 { slug: "gpt-5.2", name: "GPT-5.2" },
5929 ],
60 cursor: [
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[]>;
30} as const satisfies Record<"codex", readonly ModelOption[]>;
8231
8332type BuiltInModelSlug =
8433 (typeof MODEL_OPTIONS_BY_PROVIDER)[keyof typeof MODEL_OPTIONS_BY_PROVIDER][number]["slug"];
8534export type ModelSlug = BuiltInModelSlug | (string & {});
86export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"];
8735
8836export const DEFAULT_MODEL_BY_PROVIDER = {
8937 codex: "gpt-5.3-codex",
90 cursor: "opus-4.6-thinking",
9138} as const satisfies Record<keyof typeof MODEL_OPTIONS_BY_PROVIDER, ModelSlug>;
9239
9340// Backward compatibility for existing Codex-only call sites.
......@@ -102,25 +49,10 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER = {
10249 "5.3-spark": "gpt-5.3-codex-spark",
10350 "gpt-5.3-spark": "gpt-5.3-codex-spark",
10451 },
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 },
11952} as const satisfies Record<keyof typeof MODEL_OPTIONS_BY_PROVIDER, Record<string, ModelSlug>>;
12053
12154export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {
12255 codex: CODEX_REASONING_EFFORT_OPTIONS,
123 cursor: [],
12456} as const satisfies Record<
12557 keyof typeof MODEL_OPTIONS_BY_PROVIDER,
12658 readonly CodexReasoningEffort[]
......@@ -128,5 +60,4 @@ export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {
12860
12961export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = {
13062 codex: "high",
131 cursor: null,
13263} 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", (
128128 text: "hello",
129129 attachments: [],
130130 },
131 provider: "cursor",
131 provider: "codex",
132132 runtimeMode: "full-access",
133133 createdAt: "2026-01-01T00:00:00.000Z",
134134 });
135 assert.strictEqual(parsed.provider, "cursor");
135 assert.strictEqual(parsed.provider, "codex");
136136 assert.strictEqual(parsed.runtimeMode, "full-access");
137137 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
138138 }),
......@@ -166,26 +166,6 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>
166166 }),
167167);
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
189169it.effect(
190170 "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode",
191171 () =>
packages/contracts/src/orchestration.ts+1-1
......@@ -27,7 +27,7 @@ export const ORCHESTRATION_WS_CHANNELS = {
2727 domainEvent: "orchestration.domainEvent",
2828} as const;
2929
30export const ProviderKind = Schema.Literals(["codex", "cursor"]);
30export const ProviderKind = Schema.Literal("codex");
3131export type ProviderKind = typeof ProviderKind.Type;
3232export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
3333export const RuntimeMode = Schema.Literals(["approval-required", "full-access"]);
packages/contracts/src/provider.test.ts-25
......@@ -42,31 +42,6 @@ describe("ProviderSessionStartInput", () => {
4242 }),
4343 ).toThrow();
4444 });
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 });
7045});
7146
7247describe("ProviderSendTurnInput", () => {
packages/contracts/src/provider.ts-5
......@@ -50,13 +50,8 @@ const CodexProviderStartOptions = Schema.Struct({
5050 homePath: Schema.optional(TrimmedNonEmptyStringSchema),
5151});
5252
53const CursorProviderStartOptions = Schema.Struct({
54 binaryPath: Schema.optional(TrimmedNonEmptyStringSchema),
55});
56
5753const ProviderStartOptions = Schema.Struct({
5854 codex: Schema.optional(CodexProviderStartOptions),
59 cursor: Schema.optional(CursorProviderStartOptions),
6055});
6156
6257export const ProviderSessionStartInput = Schema.Struct({
packages/contracts/src/providerRuntime.ts-3
......@@ -20,9 +20,6 @@ const RuntimeEventRawSource = Schema.Literals([
2020 "codex.app-server.request",
2121 "codex.eventmsg",
2222 "codex.sdk.thread-event",
23 "cursor.acp.notification",
24 "cursor.acp.request",
25 "cursor.acp.response",
2623]);
2724export type RuntimeEventRawSource = typeof RuntimeEventRawSource.Type;
2825
packages/shared/src/model.test.ts+1-89
......@@ -1,24 +1,13 @@
11import { describe, expect, it } from "vitest";
2import {
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";
2import { DEFAULT_MODEL, MODEL_OPTIONS } from "@t3tools/contracts";
103
114import {
125 getDefaultModel,
136 getDefaultReasoningEffort,
14 getCursorModelFamilyOptions,
157 getModelOptions,
168 getReasoningEffortOptions,
179 normalizeModelSlug,
18 parseCursorModelSelection,
19 resolveCursorModelFromSelection,
2010 resolveModelSlug,
21 resolveModelSlugForProvider,
2211} from "./model";
2312
2413describe("normalizeModelSlug", () => {
......@@ -38,17 +27,6 @@ describe("normalizeModelSlug", () => {
3827 expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2");
3928 expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
4029 });
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 });
5230});
5331
5432describe("resolveModelSlug", () => {
......@@ -67,70 +45,9 @@ describe("resolveModelSlug", () => {
6745 expect(resolveModelSlug(model.slug)).toBe(model.slug);
6846 }
6947 });
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
8448 it("keeps codex defaults for backward compatibility", () => {
8549 expect(getDefaultModel()).toBe(DEFAULT_MODEL);
8650 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");
13451 });
13552});
13653
......@@ -138,15 +55,10 @@ describe("getReasoningEffortOptions", () => {
13855 it("returns codex reasoning options for codex", () => {
13956 expect(getReasoningEffortOptions("codex")).toEqual(["xhigh", "high", "medium", "low"]);
14057 });
141
142 it("returns no reasoning options for cursor", () => {
143 expect(getReasoningEffortOptions("cursor")).toEqual([]);
144 });
14558});
14659
14760describe("getDefaultReasoningEffort", () => {
14861 it("returns provider-scoped defaults", () => {
14962 expect(getDefaultReasoningEffort("codex")).toBe("high");
150 expect(getDefaultReasoningEffort("cursor")).toBeNull();
15163 });
15264});
packages/shared/src/model.ts+6-219
......@@ -1,233 +1,26 @@
11import {
22 CODEX_REASONING_EFFORT_OPTIONS,
3 CURSOR_MODEL_FAMILY_OPTIONS,
4 CURSOR_REASONING_OPTIONS,
53 DEFAULT_MODEL,
64 DEFAULT_MODEL_BY_PROVIDER,
75 MODEL_OPTIONS_BY_PROVIDER,
86 MODEL_SLUG_ALIASES_BY_PROVIDER,
97 type CodexReasoningEffort,
10 type CursorModelFamily,
11 type CursorModelSlug,
12 type CursorReasoningOption,
138 type ModelSlug,
149 type ProviderKind,
15} from "../../contracts/src";
10} from "@t3tools/contracts";
1611
1712type 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
9714const MODEL_SLUG_SET_BY_PROVIDER: Record<CatalogProvider, ReadonlySet<ModelSlug>> = {
9815 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)),
10016};
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
11318export function getModelOptions(provider: ProviderKind = "codex") {
114 return hasModelCatalog(provider) ? 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;
19 return MODEL_OPTIONS_BY_PROVIDER[provider];
22720}
22821
22922export 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;
23124}
23225
23326export function normalizeModelSlug(
......@@ -243,10 +36,6 @@ export function normalizeModelSlug(
24336 return null;
24437 }
24538
246 if (!hasModelCatalog(provider)) {
247 return trimmed as ModelSlug;
248 }
249
25039 const aliases = MODEL_SLUG_ALIASES_BY_PROVIDER[provider] as Record<string, ModelSlug>;
25140 return aliases[trimmed] ?? (trimmed as ModelSlug);
25241}
......@@ -260,11 +49,9 @@ export function resolveModelSlug(
26049 return getDefaultModel(provider);
26150 }
26251
263 if (!hasModelCatalog(provider)) {
264 return getDefaultModel(provider);
265 }
266
267 return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized) ? normalized : getDefaultModel(provider);
52 return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized)
53 ? normalized
54 : getDefaultModel(provider);
26855}
26956
27057export function resolveModelSlugForProvider(