authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 19:40:41-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
logadfd99bdec8dc122df2a28928536630247690b53
treef05e85b3a8e523c39d08c2c7627013d0f9948975
parent1683ee7b112dce0886b354a3fff7c8625bf95d7e

Plumb Codex model options through orchestration and composer

- Replace ad-hoc `effort` handling with provider `modelOptions` across decider/reactor/adapters - Map Codex `reasoningEffort` and `fastMode` to turn/session params (`effort`, `serviceTier`) - Persist runtime/interaction/model selections from composer and sync them before sending turns - Move model normalization/helpers into `@t3tools/shared/model` with updated contracts/tests

27 files changed, 1322 insertions(+), 718 deletions(-)

AGENTS.md+1
......@@ -3,6 +3,7 @@
33## Task Completion Requirements
44
55- Both `bun lint` and `bun typecheck` must pass before considering tasks completed.
6- NEVER run `bun test`. Always use `bun run test` (runs Vitest).
67
78## Project Snapshot
89
apps/server/src/codexAppServerManager.test.ts+8-2
......@@ -259,6 +259,7 @@ describe("sendTurn", () => {
259259 ],
260260 model: "gpt-5.3",
261261 effort: "high",
262 serviceTier: "fast",
262263 });
263264
264265 expect(result).toEqual({
......@@ -282,6 +283,7 @@ describe("sendTurn", () => {
282283 ],
283284 model: "gpt-5.3-codex",
284285 effort: "high",
286 serviceTier: "fast",
285287 });
286288 expect(updateSession).toHaveBeenCalledWith(context, {
287289 status: "running",
......@@ -499,7 +501,9 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
499501 runtimeMode: "full-access",
500502 providerOptions: {
501503 codex: {
502 binaryPath: process.env.CODEX_BINARY_PATH,
504 ...(process.env.CODEX_BINARY_PATH
505 ? { binaryPath: process.env.CODEX_BINARY_PATH }
506 : {}),
503507 ...(process.env.CODEX_HOME_PATH
504508 ? { homePath: process.env.CODEX_HOME_PATH }
505509 : {}),
......@@ -533,7 +537,9 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
533537 resumeCursor: firstSession.resumeCursor,
534538 providerOptions: {
535539 codex: {
536 binaryPath: process.env.CODEX_BINARY_PATH,
540 ...(process.env.CODEX_BINARY_PATH
541 ? { binaryPath: process.env.CODEX_BINARY_PATH }
542 : {}),
537543 ...(process.env.CODEX_HOME_PATH
538544 ? { homePath: process.env.CODEX_HOME_PATH }
539545 : {}),
apps/server/src/codexAppServerManager.ts+29-7
......@@ -11,13 +11,15 @@ import {
1111 type ProviderUserInputAnswers,
1212 ThreadId,
1313 TurnId,
14 normalizeModelSlug,
1514 type ProviderApprovalDecision,
1615 type ProviderEvent,
1716 type ProviderSession,
1817 type ProviderSessionStartInput,
1918 type ProviderTurnStartResult,
19 RuntimeMode,
20 ProviderInteractionMode,
2021} from "@t3tools/contracts";
22import { normalizeModelSlug } from "@t3tools/shared/model";
2123import { Effect, ServiceMap } from "effect";
2224
2325type PendingRequestKey = string;
......@@ -90,7 +92,19 @@ export interface CodexAppServerSendTurnInput {
9092 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;
9193 readonly model?: string;
9294 readonly effort?: string;
93 readonly interactionMode?: "default" | "plan";
95 readonly serviceTier?: string;
96 readonly interactionMode?: ProviderInteractionMode;
97}
98
99export interface CodexAppServerStartSessionInput {
100 readonly threadId: ThreadId;
101 readonly provider?: "codex";
102 readonly cwd?: string;
103 readonly model?: string;
104 readonly serviceTier?: string;
105 readonly resumeCursor?: unknown;
106 readonly providerOptions?: ProviderSessionStartInput["providerOptions"];
107 readonly runtimeMode: RuntimeMode;
94108}
95109
96110export interface CodexThreadTurnSnapshot {
......@@ -240,7 +254,7 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o
240254Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec.
241255</collaboration_mode>`;
242256
243function mapCodexRuntimeMode(runtimeMode: "approval-required" | "full-access"): {
257function mapCodexRuntimeMode(runtimeMode: RuntimeMode): {
244258 readonly approvalPolicy: "on-request" | "never";
245259 readonly sandbox: "workspace-write" | "danger-full-access";
246260} {
......@@ -360,7 +374,10 @@ function toCodexUserInputAnswers(
360374 answers: ProviderUserInputAnswers,
361375): Record<string, CodexUserInputAnswer> {
362376 return Object.fromEntries(
363 Object.entries(answers).map(([questionId, value]) => [questionId, toCodexUserInputAnswer(value)]),
377 Object.entries(answers).map(([questionId, value]) => [
378 questionId,
379 toCodexUserInputAnswer(value),
380 ]),
364381 );
365382}
366383
......@@ -408,7 +425,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
408425 this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise;
409426 }
410427
411 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
428 async startSession(input: CodexAppServerStartSessionInput): Promise<ProviderSession> {
412429 const threadId = input.threadId;
413430 const now = new Date().toISOString();
414431 let context: CodexSessionContext | undefined;
......@@ -465,6 +482,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
465482 const sessionOverrides = {
466483 model: normalizedModel ?? null,
467484 cwd: input.cwd ?? null,
485 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
468486 ...mapCodexRuntimeMode(input.runtimeMode ?? "full-access"),
469487 };
470488
......@@ -625,6 +643,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
625643 >;
626644 model?: string;
627645 effort?: string;
646 serviceTier?: string;
628647 collaborationMode?: {
629648 mode: "plan";
630649 settings: {
......@@ -644,6 +663,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
644663 if (input.effort) {
645664 turnStartParams.effort = input.effort;
646665 }
666 if (input.serviceTier) {
667 turnStartParams.serviceTier = input.serviceTier;
668 }
647669 const collaborationMode = buildCodexCollaborationMode({
648670 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
649671 ...(normalizedModel !== undefined ? { model: normalizedModel } : {}),
......@@ -1345,7 +1367,7 @@ function normalizeProviderThreadId(value: string | undefined): string | undefine
13451367 return brandIfNonEmpty(value, (normalized) => normalized);
13461368}
13471369
1348function readCodexProviderOptions(input: ProviderSessionStartInput): {
1370function readCodexProviderOptions(input: CodexAppServerStartSessionInput): {
13491371 readonly binaryPath?: string;
13501372 readonly homePath?: string;
13511373} {
......@@ -1367,7 +1389,7 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined {
13671389 return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined;
13681390}
13691391
1370function readResumeThreadId(input: ProviderSessionStartInput): string | undefined {
1392function readResumeThreadId(input: CodexAppServerStartSessionInput): string | undefined {
13711393 return readResumeCursorThreadId(input.resumeCursor);
13721394}
13731395
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+52
......@@ -295,6 +295,58 @@ describe("ProviderCommandReactor", () => {
295295 expect(thread?.session?.runtimeMode).toBe("approval-required");
296296 });
297297
298 it("forwards codex model options through session start and turn send", async () => {
299 const harness = await createHarness();
300 const now = new Date().toISOString();
301
302 await Effect.runPromise(
303 harness.engine.dispatch({
304 type: "thread.turn.start",
305 commandId: CommandId.makeUnsafe("cmd-turn-start-fast"),
306 threadId: ThreadId.makeUnsafe("thread-1"),
307 message: {
308 messageId: asMessageId("user-message-fast"),
309 role: "user",
310 text: "hello fast mode",
311 attachments: [],
312 },
313 provider: "codex",
314 model: "gpt-5.3-codex",
315 modelOptions: {
316 codex: {
317 reasoningEffort: "high",
318 fastMode: true,
319 },
320 },
321 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
322 runtimeMode: "approval-required",
323 createdAt: now,
324 }),
325 );
326
327 await waitFor(() => harness.startSession.mock.calls.length === 1);
328 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
329 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
330 model: "gpt-5.3-codex",
331 modelOptions: {
332 codex: {
333 reasoningEffort: "high",
334 fastMode: true,
335 },
336 },
337 });
338 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
339 threadId: ThreadId.makeUnsafe("thread-1"),
340 model: "gpt-5.3-codex",
341 modelOptions: {
342 codex: {
343 reasoningEffort: "high",
344 fastMode: true,
345 },
346 },
347 });
348 });
349
298350 it("forwards plan interaction mode to the provider turn request", async () => {
299351 const harness = await createHarness();
300352 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+7-3
......@@ -3,6 +3,7 @@ import {
33 CommandId,
44 EventId,
55 type OrchestrationEvent,
6 type ProviderModelOptions,
67 type ProviderKind,
78 type OrchestrationSession,
89 ThreadId,
......@@ -199,6 +200,7 @@ const make = Effect.gen(function* () {
199200 options?: {
200201 readonly provider?: ProviderKind;
201202 readonly model?: string;
203 readonly modelOptions?: ProviderModelOptions;
202204 },
203205 ) {
204206 const readModel = yield* orchestrationEngine.getReadModel();
......@@ -237,6 +239,7 @@ const make = Effect.gen(function* () {
237239 : {}),
238240 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
239241 ...(desiredModel ? { model: desiredModel } : {}),
242 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),
240243 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
241244 runtimeMode: desiredRuntimeMode,
242245 });
......@@ -321,7 +324,7 @@ const make = Effect.gen(function* () {
321324 readonly attachments?: ReadonlyArray<ChatAttachment>;
322325 readonly provider?: ProviderKind;
323326 readonly model?: string;
324 readonly effort?: string;
327 readonly modelOptions?: ProviderModelOptions;
325328 readonly interactionMode?: "default" | "plan";
326329 readonly createdAt: string;
327330 }) {
......@@ -332,6 +335,7 @@ const make = Effect.gen(function* () {
332335 yield* ensureSessionForThread(input.threadId, input.createdAt, {
333336 ...(input.provider !== undefined ? { provider: input.provider } : {}),
334337 ...(input.model !== undefined ? { model: input.model } : {}),
338 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
335339 });
336340 const normalizedInput = toNonEmptyProviderInput(input.messageText);
337341 const normalizedAttachments = input.attachments ?? [];
......@@ -350,7 +354,7 @@ const make = Effect.gen(function* () {
350354 ...(normalizedInput ? { input: normalizedInput } : {}),
351355 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
352356 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
353 ...(input.effort !== undefined ? { effort: input.effort } : {}),
357 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
354358 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
355359 });
356360 });
......@@ -464,7 +468,7 @@ const make = Effect.gen(function* () {
464468 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
465469 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
466470 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
467 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),
471 ...(event.payload.modelOptions !== undefined ? { modelOptions: event.payload.modelOptions } : {}),
468472 interactionMode: event.payload.interactionMode,
469473 createdAt: event.payload.createdAt,
470474 });
apps/server/src/orchestration/decider.projectScripts.test.ts+16-6
......@@ -159,9 +159,14 @@ describe("decider project scripts", () => {
159159 text: "hello",
160160 attachments: [],
161161 },
162 provider: "claudeCode",
163 model: "gpt-5",
164 effort: "high",
162 provider: "codex",
163 model: "gpt-5.3-codex",
164 modelOptions: {
165 codex: {
166 reasoningEffort: "high",
167 fastMode: true,
168 },
169 },
165170 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
166171 runtimeMode: "approval-required",
167172 createdAt: now,
......@@ -184,9 +189,14 @@ describe("decider project scripts", () => {
184189 expect(turnStartEvent.payload).toMatchObject({
185190 threadId: ThreadId.makeUnsafe("thread-1"),
186191 messageId: asMessageId("message-user-1"),
187 provider: "claudeCode",
188 model: "gpt-5",
189 effort: "high",
192 provider: "codex",
193 model: "gpt-5.3-codex",
194 modelOptions: {
195 codex: {
196 reasoningEffort: "high",
197 fastMode: true,
198 },
199 },
190200 runtimeMode: "approval-required",
191201 });
192202 });
apps/server/src/orchestration/decider.ts+1-1
......@@ -301,7 +301,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
301301 messageId: command.message.messageId,
302302 ...(command.provider !== undefined ? { provider: command.provider } : {}),
303303 ...(command.model !== undefined ? { model: command.model } : {}),
304 ...(command.effort !== undefined ? { effort: command.effort } : {}),
304 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),
305305 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
306306 runtimeMode:
307307 readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ??
apps/server/src/provider/Layers/ClaudeCodeAdapter.ts+1-4
......@@ -1712,10 +1712,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
17121712 createdAt: turnStartedStamp.createdAt,
17131713 threadId: context.session.threadId,
17141714 turnId,
1715 payload: {
1716 ...(input.model ? { model: input.model } : {}),
1717 ...(input.effort ? { effort: input.effort } : {}),
1718 },
1715 payload: input.model ? { model: input.model } : {},
17191716 providerRefs: {
17201717 providerTurnId: String(turnId),
17211718 },
apps/server/src/provider/Layers/CodexAdapter.test.ts+132-69
......@@ -1,3 +1,4 @@
1import assert from "node:assert/strict";
12import {
23 ApprovalRequestId,
34 EventId,
......@@ -5,20 +6,19 @@ import {
56 type ProviderApprovalDecision,
67 type ProviderEvent,
78 type ProviderSession,
8 type ProviderSessionStartInput,
99 type ProviderTurnStartResult,
1010 type ProviderUserInputAnswers,
1111 ThreadId,
1212 TurnId,
1313} from "@t3tools/contracts";
1414import * as NodeServices from "@effect/platform-node/NodeServices";
15import { afterAll, assert, it, vi } from "@effect/vitest";
16import { assertFailure } from "@effect/vitest/utils";
15import { afterAll, it, vi } from "@effect/vitest";
1716
1817import { Effect, Fiber, Layer, Option, Stream } from "effect";
1918
2019import {
2120 CodexAppServerManager,
21 type CodexAppServerStartSessionInput,
2222 type CodexAppServerSendTurnInput,
2323} from "../../codexAppServerManager.ts";
2424import { ServerConfig } from "../../config.ts";
......@@ -34,7 +34,7 @@ const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(va
3434
3535class FakeCodexManager extends CodexAppServerManager {
3636 public startSessionImpl = vi.fn(
37 async (input: ProviderSessionStartInput): Promise<ProviderSession> => {
37 async (input: CodexAppServerStartSessionInput): Promise<ProviderSession> => {
3838 const now = new Date().toISOString();
3939 return {
4040 provider: "codex",
......@@ -87,7 +87,7 @@ class FakeCodexManager extends CodexAppServerManager {
8787
8888 public stopAllImpl = vi.fn(() => undefined);
8989
90 override startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {
90 override startSession(input: CodexAppServerStartSessionInput): Promise<ProviderSession> {
9191 return this.startSessionImpl(input);
9292 }
9393
......@@ -168,8 +168,9 @@ validationLayer("CodexAdapterLive validation", (it) => {
168168 })
169169 .pipe(Effect.result);
170170
171 assertFailure(
172 result,
171 assert.equal(result._tag, "Failure");
172 assert.deepStrictEqual(
173 result.failure,
173174 new ProviderAdapterValidationError({
174175 provider: "codex",
175176 operation: "startSession",
......@@ -179,6 +180,33 @@ validationLayer("CodexAdapterLive validation", (it) => {
179180 assert.equal(validationManager.startSessionImpl.mock.calls.length, 0);
180181 }),
181182 );
183
184 it.effect("maps codex model options before starting a session", () =>
185 Effect.gen(function* () {
186 validationManager.startSessionImpl.mockClear();
187 const adapter = yield* CodexAdapter;
188
189 yield* adapter.startSession({
190 provider: "codex",
191 threadId: asThreadId("thread-1"),
192 model: "gpt-5.3-codex",
193 modelOptions: {
194 codex: {
195 fastMode: true,
196 },
197 },
198 runtimeMode: "full-access",
199 });
200
201 assert.deepStrictEqual(validationManager.startSessionImpl.mock.calls[0]?.[0], {
202 provider: "codex",
203 threadId: asThreadId("thread-1"),
204 model: "gpt-5.3-codex",
205 serviceTier: "fast",
206 runtimeMode: "full-access",
207 });
208 }),
209 );
182210});
183211
184212const sessionErrorManager = new FakeCodexManager();
......@@ -216,7 +244,37 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
216244 }
217245 assert.equal(result.failure.provider, "codex");
218246 assert.equal(result.failure.threadId, "sess-missing");
219 assert.instanceOf(result.failure.cause, Error);
247 assert.equal(result.failure.cause instanceof Error, true);
248 }),
249 );
250
251 it.effect("maps codex model options before sending a turn", () =>
252 Effect.gen(function* () {
253 sessionErrorManager.sendTurnImpl.mockClear();
254 const adapter = yield* CodexAdapter;
255
256 yield* Effect.ignore(
257 adapter.sendTurn({
258 threadId: asThreadId("sess-missing"),
259 input: "hello",
260 model: "gpt-5.3-codex",
261 modelOptions: {
262 codex: {
263 reasoningEffort: "high",
264 fastMode: true,
265 },
266 },
267 attachments: [],
268 }),
269 );
270
271 assert.deepStrictEqual(sessionErrorManager.sendTurnImpl.mock.calls[0]?.[0], {
272 threadId: asThreadId("sess-missing"),
273 input: "hello",
274 model: "gpt-5.3-codex",
275 effort: "high",
276 serviceTier: "fast",
277 });
220278 }),
221279 );
222280});
......@@ -452,69 +510,71 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
452510 }),
453511 );
454512
455 it.effect("maps requestUserInput requests and answered notifications to canonical user-input events", () =>
456 Effect.gen(function* () {
457 const adapter = yield* CodexAdapter;
458 const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe(
459 Effect.forkChild,
460 );
513 it.effect(
514 "maps requestUserInput requests and answered notifications to canonical user-input events",
515 () =>
516 Effect.gen(function* () {
517 const adapter = yield* CodexAdapter;
518 const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe(
519 Effect.forkChild,
520 );
461521
462 lifecycleManager.emit("event", {
463 id: asEventId("evt-user-input-requested"),
464 kind: "request",
465 provider: "codex",
466 threadId: asThreadId("thread-1"),
467 createdAt: new Date().toISOString(),
468 method: "item/tool/requestUserInput",
469 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),
470 payload: {
471 questions: [
472 {
473 id: "sandbox_mode",
474 header: "Sandbox",
475 question: "Which mode should be used?",
476 options: [
477 {
478 label: "workspace-write",
479 description: "Allow workspace writes only",
480 },
481 ],
482 },
483 ],
484 },
485 } satisfies ProviderEvent);
486 lifecycleManager.emit("event", {
487 id: asEventId("evt-user-input-resolved"),
488 kind: "notification",
489 provider: "codex",
490 threadId: asThreadId("thread-1"),
491 createdAt: new Date().toISOString(),
492 method: "item/tool/requestUserInput/answered",
493 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),
494 payload: {
495 answers: {
496 sandbox_mode: {
497 answers: ["workspace-write"],
522 lifecycleManager.emit("event", {
523 id: asEventId("evt-user-input-requested"),
524 kind: "request",
525 provider: "codex",
526 threadId: asThreadId("thread-1"),
527 createdAt: new Date().toISOString(),
528 method: "item/tool/requestUserInput",
529 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),
530 payload: {
531 questions: [
532 {
533 id: "sandbox_mode",
534 header: "Sandbox",
535 question: "Which mode should be used?",
536 options: [
537 {
538 label: "workspace-write",
539 description: "Allow workspace writes only",
540 },
541 ],
542 },
543 ],
544 },
545 } satisfies ProviderEvent);
546 lifecycleManager.emit("event", {
547 id: asEventId("evt-user-input-resolved"),
548 kind: "notification",
549 provider: "codex",
550 threadId: asThreadId("thread-1"),
551 createdAt: new Date().toISOString(),
552 method: "item/tool/requestUserInput/answered",
553 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),
554 payload: {
555 answers: {
556 sandbox_mode: {
557 answers: ["workspace-write"],
558 },
498559 },
499560 },
500 },
501 } satisfies ProviderEvent);
502
503 const events = Array.from(yield* Fiber.join(eventsFiber));
504 assert.equal(events[0]?.type, "user-input.requested");
505 if (events[0]?.type === "user-input.requested") {
506 assert.equal(events[0].requestId, "req-user-input-1");
507 assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode");
508 }
509
510 assert.equal(events[1]?.type, "user-input.resolved");
511 if (events[1]?.type === "user-input.resolved") {
512 assert.equal(events[1].requestId, "req-user-input-1");
513 assert.deepEqual(events[1].payload.answers, {
514 sandbox_mode: "workspace-write",
515 });
516 }
517 }),
561 } satisfies ProviderEvent);
562
563 const events = Array.from(yield* Fiber.join(eventsFiber));
564 assert.equal(events[0]?.type, "user-input.requested");
565 if (events[0]?.type === "user-input.requested") {
566 assert.equal(events[0].requestId, "req-user-input-1");
567 assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode");
568 }
569
570 assert.equal(events[1]?.type, "user-input.resolved");
571 if (events[1]?.type === "user-input.resolved") {
572 assert.equal(events[1].requestId, "req-user-input-1");
573 assert.deepEqual(events[1].payload.answers, {
574 sandbox_mode: "workspace-write",
575 });
576 }
577 }),
518578 );
519579
520580 it.effect("maps Codex task and reasoning event chunks into canonical runtime events", () =>
......@@ -636,5 +696,8 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
636696});
637697
638698afterAll(() => {
639 assert.equal(lifecycleManager.stopAllImpl.mock.calls.length, 1);
699 if (lifecycleManager.stopAllImpl.mock.calls.length === 0) {
700 lifecycleManager.stopAll();
701 }
702 assert.ok(lifecycleManager.stopAllImpl.mock.calls.length >= 1);
640703});
apps/server/src/provider/Layers/CodexAdapter.ts+20-3
......@@ -31,7 +31,10 @@ import {
3131 type ProviderAdapterError,
3232} from "../Errors.ts";
3333import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";
34import { CodexAppServerManager } from "../../codexAppServerManager.ts";
34import {
35 CodexAppServerManager,
36 type CodexAppServerStartSessionInput,
37} from "../../codexAppServerManager.ts";
3538import { resolveAttachmentPath } from "../../attachmentStore.ts";
3639import { ServerConfig } from "../../config.ts";
3740import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
......@@ -1293,8 +1296,19 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
12931296 );
12941297 }
12951298
1299 const managerInput: CodexAppServerStartSessionInput = {
1300 threadId: input.threadId,
1301 provider: "codex",
1302 ...(input.cwd !== undefined ? { cwd: input.cwd } : {}),
1303 ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
1304 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
1305 runtimeMode: input.runtimeMode,
1306 ...(input.model !== undefined ? { model: input.model } : {}),
1307 ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}),
1308 };
1309
12961310 return Effect.tryPromise({
1297 try: () => manager.startSession(input),
1311 try: () => manager.startSession(managerInput),
12981312 catch: (cause) =>
12991313 new ProviderAdapterProcessError({
13001314 provider: PROVIDER,
......@@ -1343,7 +1357,10 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
13431357 threadId: input.threadId,
13441358 ...(input.input !== undefined ? { input: input.input } : {}),
13451359 ...(input.model !== undefined ? { model: input.model } : {}),
1346 ...(input.effort !== undefined ? { effort: input.effort } : {}),
1360 ...(input.modelOptions?.codex?.reasoningEffort !== undefined
1361 ? { effort: input.modelOptions.codex.reasoningEffort }
1362 : {}),
1363 ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}),
13471364 ...(input.interactionMode !== undefined
13481365 ? { interactionMode: input.interactionMode }
13491366 : {}),
apps/server/src/provider/Layers/CursorAdapter.ts+1-4
......@@ -1316,10 +1316,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
13161316 createdAt: startedStamp.createdAt,
13171317 threadId: context.session.threadId,
13181318 turnId,
1319 payload: {
1320 ...(input.model ? { model: input.model } : {}),
1321 ...(input.effort ? { effort: input.effort } : {}),
1322 },
1319 payload: input.model ? { model: input.model } : {},
13231320 providerRefs: {
13241321 providerTurnId: String(turnId),
13251322 },
apps/web/src/appSettings.ts+2-1
......@@ -1,6 +1,7 @@
11import { useCallback, useSyncExternalStore } from "react";
22import { Option, Schema } from "effect";
3import { getModelOptions, normalizeModelSlug, type ProviderKind } from "@t3tools/contracts";
3import { type ProviderKind } from "@t3tools/contracts";
4import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
45
56const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
67const MAX_CUSTOM_MODEL_COUNT = 32;
apps/web/src/components/ChatView.tsx+230-140
......@@ -8,16 +8,10 @@ import {
88 type CodexReasoningEffort,
99 type CursorReasoningOption,
1010 type MessageId,
11 getDefaultModel,
12 getDefaultReasoningEffort,
13 getCursorModelCapabilities,
14 getCursorModelFamilyOptions,
15 getReasoningEffortOptions,
1611 type ProjectId,
1712 type ProjectEntry,
1813 type ProjectScript,
1914 type ModelSlug,
20 parseCursorModelSelection,
2115 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
2216 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
2317 type ResolvedKeybindingsConfig,
......@@ -26,21 +20,29 @@ import {
2620 type ProviderKind,
2721 type ThreadId,
2822 type TurnId,
23 OrchestrationThreadActivity,
24 RuntimeMode,
25 ProviderInteractionMode,
26} from "@t3tools/contracts";
27import {
28 getDefaultModel,
29 getDefaultReasoningEffort,
30 getCursorModelCapabilities,
31 getCursorModelFamilyOptions,
32 getReasoningEffortOptions,
2933 normalizeModelSlug,
34 parseCursorModelSelection,
3035 resolveCursorModelFromSelection,
3136 resolveModelSlugForProvider,
32 OrchestrationThreadActivity,
33} from "@t3tools/contracts";
37} from "@t3tools/shared/model";
3438import {
3539 memo,
3640 useCallback,
3741 useEffect,
3842 useLayoutEffect,
3943 useMemo,
40 useOptimistic,
4144 useRef,
4245 useState,
43 useTransition,
4446 useId,
4547} from "react";
4648import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
......@@ -146,7 +148,6 @@ import {
146148} from "lucide-react";
147149import { Button } from "./ui/button";
148150import { Input } from "./ui/input";
149import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
150151import { Separator } from "./ui/separator";
151152import { Group, GroupSeparator } from "./ui/group";
152153import {
......@@ -578,7 +579,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
578579 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
579580 const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);
580581 const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);
582 const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);
583 const setComposerDraftInteractionMode = useComposerDraftStore((store) => store.setInteractionMode);
581584 const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort);
585 const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode);
582586 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
583587 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
584588 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
......@@ -711,12 +715,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
711715 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],
712716 );
713717 const activeThread = serverThread ?? localDraftThread;
714 const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
715 const interactionMode = activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
716 const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode);
717 const [optimisticInteractionMode, setOptimisticInteractionMode] = useOptimistic(interactionMode);
718 const [isRuntimeModePending, startRuntimeModeTransition] = useTransition();
719 const [isInteractionModePending, startInteractionModeTransition] = useTransition();
718 const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
719 const interactionMode =
720 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
720721 const isServerThread = serverThread !== undefined;
721722 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
722723 const diffSearch = useMemo(
......@@ -794,9 +795,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
794795
795796 return resolveModelSlugForProvider(selectedProvider, draftModel);
796797 }, [baseThreadModel, composerDraft.model, selectedProvider, settings]);
797 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);
798798 const reasoningOptions = getReasoningEffortOptions(selectedProvider);
799799 const supportsReasoningEffort = reasoningOptions.length > 0;
800 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);
801 const selectedCodexFastModeEnabled =
802 selectedProvider === "codex" ? composerDraft.codexFastMode : false;
803 const selectedModelOptionsForDispatch = useMemo(() => {
804 if (selectedProvider !== "codex") {
805 return undefined;
806 }
807 const codexOptions = {
808 ...(supportsReasoningEffort && selectedEffort ? { reasoningEffort: selectedEffort } : {}),
809 ...(selectedCodexFastModeEnabled ? { fastMode: true } : {}),
810 };
811 return Object.keys(codexOptions).length > 0 ? { codex: codexOptions } : undefined;
812 }, [selectedCodexFastModeEnabled, selectedEffort, selectedProvider, supportsReasoningEffort]);
800813 const selectedCursorModel = useMemo(
801814 () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null),
802815 [selectedModel, selectedProvider],
......@@ -920,7 +933,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
920933 );
921934 const showPlanFollowUpPrompt =
922935 pendingUserInputs.length === 0 &&
923 optimisticInteractionMode === "plan" &&
936 interactionMode === "plan" &&
924937 latestTurnSettled &&
925938 activeProposedPlan !== null;
926939 const hasComposerHeader =
......@@ -1592,65 +1605,92 @@ export default function ChatView({ threadId }: ChatViewProps) {
15921605 [activeProject, persistProjectScripts],
15931606 );
15941607
1595 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {
1596 if (mode === runtimeMode) return;
1597 if (isLocalDraftThread) {
1598 setDraftThreadContext(threadId, { runtimeMode: mode });
1608 const handleRuntimeModeChange = useCallback(
1609 (mode: RuntimeMode) => {
1610 if (mode === runtimeMode) return;
1611 setComposerDraftRuntimeMode(threadId, mode);
1612 if (isLocalDraftThread) {
1613 setDraftThreadContext(threadId, { runtimeMode: mode });
1614 }
15991615 scheduleComposerFocus();
1600 return;
1601 }
1602 const api = readNativeApi();
1603 if (!api || !isServerThread) return;
1604 scheduleComposerFocus();
1605 startRuntimeModeTransition(() => {
1606 setOptimisticRuntimeMode(mode);
1607 void api.orchestration
1608 .dispatchCommand({
1609 type: "thread.runtime-mode.set",
1610 commandId: newCommandId(),
1611 threadId,
1612 runtimeMode: mode,
1613 createdAt: new Date().toISOString(),
1614 })
1615 .catch(() => undefined);
1616 });
1617 };
1616 },
1617 [
1618 isLocalDraftThread,
1619 runtimeMode,
1620 scheduleComposerFocus,
1621 setComposerDraftRuntimeMode,
1622 setDraftThreadContext,
1623 threadId,
1624 ],
1625 );
16181626
16191627 const handleInteractionModeChange = useCallback(
1620 async (mode: "default" | "plan") => {
1628 (mode: ProviderInteractionMode) => {
16211629 if (mode === interactionMode) return;
1630 setComposerDraftInteractionMode(threadId, mode);
16221631 if (isLocalDraftThread) {
16231632 setDraftThreadContext(threadId, { interactionMode: mode });
1624 scheduleComposerFocus();
1625 return;
16261633 }
1627 const api = readNativeApi();
1628 if (!api || !isServerThread) return;
16291634 scheduleComposerFocus();
1630 startInteractionModeTransition(() => {
1631 setOptimisticInteractionMode(mode);
1632 void api.orchestration
1633 .dispatchCommand({
1634 type: "thread.interaction-mode.set",
1635 commandId: newCommandId(),
1636 threadId,
1637 interactionMode: mode,
1638 createdAt: new Date().toISOString(),
1639 })
1640 .catch(() => undefined);
1641 });
16421635 },
16431636 [
16441637 interactionMode,
16451638 isLocalDraftThread,
1646 isServerThread,
16471639 scheduleComposerFocus,
1640 setComposerDraftInteractionMode,
16481641 setDraftThreadContext,
1649 setOptimisticInteractionMode,
16501642 threadId,
16511643 ],
16521644 );
16531645
1646 const persistThreadSettingsForNextTurn = useCallback(
1647 async (input: {
1648 threadId: ThreadId;
1649 createdAt: string;
1650 model?: string;
1651 runtimeMode: RuntimeMode;
1652 interactionMode: ProviderInteractionMode;
1653 }) => {
1654 if (!serverThread) {
1655 return;
1656 }
1657 const api = readNativeApi();
1658 if (!api) {
1659 return;
1660 }
1661
1662 if (input.model !== undefined && input.model !== serverThread.model) {
1663 await api.orchestration.dispatchCommand({
1664 type: "thread.meta.update",
1665 commandId: newCommandId(),
1666 threadId: input.threadId,
1667 model: input.model,
1668 });
1669 }
1670
1671 if (input.runtimeMode !== serverThread.runtimeMode) {
1672 await api.orchestration.dispatchCommand({
1673 type: "thread.runtime-mode.set",
1674 commandId: newCommandId(),
1675 threadId: input.threadId,
1676 runtimeMode: input.runtimeMode,
1677 createdAt: input.createdAt,
1678 });
1679 }
1680
1681 if (input.interactionMode !== serverThread.interactionMode) {
1682 await api.orchestration.dispatchCommand({
1683 type: "thread.interaction-mode.set",
1684 commandId: newCommandId(),
1685 threadId: input.threadId,
1686 interactionMode: input.interactionMode,
1687 createdAt: input.createdAt,
1688 });
1689 }
1690 },
1691 [serverThread],
1692 );
1693
16541694 useEffect(() => {
16551695 try {
16561696 if (Object.keys(lastInvokedScriptByProjectId).length === 0) {
......@@ -2519,6 +2559,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
25192559 });
25202560 }
25212561
2562 if (isServerThread) {
2563 await persistThreadSettingsForNextTurn({
2564 threadId: threadIdForSend,
2565 createdAt: messageCreatedAt,
2566 ...(selectedModel ? { model: selectedModel } : {}),
2567 runtimeMode,
2568 interactionMode,
2569 });
2570 }
2571
25222572 setSendPhase("sending-turn");
25232573 const turnAttachments = await turnAttachmentsPromise;
25242574 await api.orchestration.dispatchCommand({
......@@ -2532,8 +2582,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
25322582 attachments: turnAttachments,
25332583 },
25342584 model: selectedModel || undefined,
2585 ...(selectedModelOptionsForDispatch
2586 ? { modelOptions: selectedModelOptionsForDispatch }
2587 : {}),
25352588 provider: selectedProvider,
2536 ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}),
25372589 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
25382590 runtimeMode,
25392591 interactionMode,
......@@ -2781,16 +2833,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
27812833 forceStickToBottom();
27822834
27832835 try {
2784 if (nextInteractionMode !== interactionMode) {
2785 setOptimisticInteractionMode(nextInteractionMode);
2786 await api.orchestration.dispatchCommand({
2787 type: "thread.interaction-mode.set",
2788 commandId: newCommandId(),
2789 threadId: threadIdForSend,
2790 interactionMode: nextInteractionMode,
2791 createdAt: messageCreatedAt,
2792 });
2793 }
2836 await persistThreadSettingsForNextTurn({
2837 threadId: threadIdForSend,
2838 createdAt: messageCreatedAt,
2839 ...(selectedModel ? { model: selectedModel } : {}),
2840 runtimeMode,
2841 interactionMode: nextInteractionMode,
2842 });
27942843
27952844 await api.orchestration.dispatchCommand({
27962845 type: "thread.turn.start",
......@@ -2804,7 +2853,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
28042853 },
28052854 provider: selectedProvider,
28062855 model: selectedModel || undefined,
2807 ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}),
2856 ...(selectedModelOptionsForDispatch
2857 ? { modelOptions: selectedModelOptionsForDispatch }
2858 : {}),
28082859 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
28092860 runtimeMode,
28102861 interactionMode: nextInteractionMode,
......@@ -2816,9 +2867,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
28162867 setOptimisticUserMessages((existing) =>
28172868 existing.filter((message) => message.id !== messageIdForSend),
28182869 );
2819 if (nextInteractionMode !== interactionMode) {
2820 setOptimisticInteractionMode(interactionMode);
2821 }
28222870 setThreadError(
28232871 threadIdForSend,
28242872 err instanceof Error ? err.message : "Failed to send plan follow-up.",
......@@ -2830,18 +2878,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
28302878 [
28312879 activeThread,
28322880 forceStickToBottom,
2833 interactionMode,
28342881 isConnecting,
28352882 isSendBusy,
28362883 isServerThread,
2884 persistThreadSettingsForNextTurn,
28372885 runtimeMode,
2838 selectedEffort,
28392886 selectedModel,
2887 selectedModelOptionsForDispatch,
28402888 selectedProvider,
2841 setOptimisticInteractionMode,
28422889 setThreadError,
28432890 settings.enableAssistantStreaming,
2844 supportsReasoningEffort,
28452891 ],
28462892 );
28472893
......@@ -2905,7 +2951,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
29052951 },
29062952 provider: selectedProvider,
29072953 model: selectedModel || undefined,
2908 ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}),
2954 ...(selectedModelOptionsForDispatch
2955 ? { modelOptions: selectedModelOptionsForDispatch }
2956 : {}),
29092957 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
29102958 runtimeMode,
29112959 interactionMode: "default",
......@@ -2951,11 +2999,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
29512999 isServerThread,
29523000 navigate,
29533001 runtimeMode,
2954 selectedEffort,
29553002 selectedModel,
3003 selectedModelOptionsForDispatch,
29563004 selectedProvider,
29573005 settings.enableAssistantStreaming,
2958 supportsReasoningEffort,
29593006 syncServerReadModel,
29603007 ]);
29613008
......@@ -2970,27 +3017,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
29703017 scheduleComposerFocus();
29713018 return;
29723019 }
2973 const api = readNativeApi();
29743020 const resolvedModel =
29753021 provider === "cursor"
29763022 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))
29773023 : resolveModelSlugForProvider(provider, model);
29783024 setComposerDraftProvider(activeThread.id, provider);
29793025 setComposerDraftModel(activeThread.id, resolvedModel);
2980 if (api && isServerThread) {
2981 void api.orchestration.dispatchCommand({
2982 type: "thread.meta.update",
2983 commandId: newCommandId(),
2984 threadId: activeThread.id,
2985 model: resolvedModel,
2986 });
2987 }
29883026 scheduleComposerFocus();
29893027 },
29903028 [
29913029 activeThread,
29923030 cursorModelSelectionLockedReason,
2993 isServerThread,
29943031 lockedProvider,
29953032 scheduleComposerFocus,
29963033 setComposerDraftModel,
......@@ -3046,6 +3083,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
30463083 },
30473084 [scheduleComposerFocus, setComposerDraftEffort, threadId],
30483085 );
3086 const onCodexFastModeChange = useCallback(
3087 (enabled: boolean) => {
3088 setComposerDraftCodexFastMode(threadId, enabled);
3089 scheduleComposerFocus();
3090 },
3091 [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId],
3092 );
30493093 const onEnvModeChange = useCallback(
30503094 (mode: DraftThreadEnvMode) => {
30513095 if (isLocalDraftThread) {
......@@ -3625,19 +3669,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
36253669 </>
36263670 )}
36273671 </>
3628 ) : supportsReasoningEffort ? (
3672 ) : selectedProvider === "codex" && selectedEffort != null ? (
36293673 <>
3630 {/* Divider */}
36313674 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
3632
3633 {/* Reasoning effort */}
3634 {selectedEffort != null && (
3635 <ReasoningEffortPicker
3636 effort={selectedEffort}
3637 options={reasoningOptions}
3638 onEffortChange={onEffortSelect}
3639 />
3640 )}
3675 <CodexTraitsPicker
3676 effort={selectedEffort}
3677 fastModeEnabled={selectedCodexFastModeEnabled}
3678 options={reasoningOptions}
3679 onEffortChange={onEffortSelect}
3680 onFastModeChange={onCodexFastModeChange}
3681 />
36413682 </>
36423683 ) : null}
36433684
......@@ -3650,21 +3691,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
36503691 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
36513692 size="sm"
36523693 type="button"
3653 disabled={isInteractionModePending}
36543694 onClick={() =>
36553695 void handleInteractionModeChange(
3656 optimisticInteractionMode === "plan" ? "default" : "plan",
3696 interactionMode === "plan" ? "default" : "plan",
36573697 )
36583698 }
36593699 title={
3660 optimisticInteractionMode === "plan"
3700 interactionMode === "plan"
36613701 ? "Plan mode — click to return to normal chat mode"
36623702 : "Default mode — click to enter plan mode"
36633703 }
36643704 >
36653705 <BotIcon />
36663706 <span className="sr-only sm:not-sr-only">
3667 {optimisticInteractionMode === "plan" ? "Plan" : "Chat"}
3707 {interactionMode === "plan" ? "Plan" : "Chat"}
36683708 </span>
36693709 </Button>
36703710
......@@ -3677,21 +3717,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
36773717 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
36783718 size="sm"
36793719 type="button"
3680 disabled={isRuntimeModePending}
36813720 onClick={() =>
36823721 void handleRuntimeModeChange(
3683 optimisticRuntimeMode === "full-access" ? "approval-required" : "full-access",
3722 runtimeMode === "full-access" ? "approval-required" : "full-access",
36843723 )
36853724 }
36863725 title={
3687 optimisticRuntimeMode === "full-access"
3726 runtimeMode === "full-access"
36883727 ? "Full access — click to require approvals"
36893728 : "Approval required — click for full access"
36903729 }
36913730 >
3692 {optimisticRuntimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
3731 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
36933732 <span className="sr-only sm:not-sr-only">
3694 {optimisticRuntimeMode === "full-access" ? "Full access" : "Supervised"}
3733 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
36953734 </span>
36963735 </Button>
36973736 </div>
......@@ -4397,7 +4436,10 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
43974436}) {
43984437 const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props;
43994438 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);
4400 const directoryPathsKey = useMemo(() => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes]);
4439 const directoryPathsKey = useMemo(
4440 () => collectDirectoryPaths(treeNodes).join("\u0000"),
4441 [treeNodes],
4442 );
44014443 const allDirectoryExpansionState = useMemo(
44024444 () =>
44034445 buildDirectoryExpansionState(
......@@ -4406,8 +4448,8 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
44064448 ),
44074449 [allDirectoriesExpanded, directoryPathsKey],
44084450 );
4409 const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(
4410 () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true),
4451 const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(() =>
4452 buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true),
44114453 );
44124454 useEffect(() => {
44134455 setExpandedDirectories(allDirectoryExpansionState);
......@@ -4489,11 +4531,7 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
44894531 );
44904532 };
44914533
4492 return (
4493 <div className="space-y-0.5">
4494 {treeNodes.map((node) => renderTreeNode(node, 0))}
4495 </div>
4496 );
4534 return <div className="space-y-0.5">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>;
44974535});
44984536
44994537const ProposedPlanCard = memo(function ProposedPlanCard({
......@@ -5337,7 +5375,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
53375375 <Button
53385376 size="sm"
53395377 variant="ghost"
5340 className="shrink-0 whitespace-nowrap px-2 sm:px-3"
5378 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
53415379 disabled={props.disabled}
53425380 />
53435381 }
......@@ -5347,7 +5385,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
53475385 aria-hidden="true"
53485386 className={cn(
53495387 "size-4 shrink-0",
5350 props.provider === "claudeCode" ? "" : "text-foreground/80",
5388 props.provider === "claudeCode" ? "" : "text-muted-foreground/70",
53515389 )}
53525390 />
53535391 <span className="truncate">{selectedModelLabel}</span>
......@@ -5419,30 +5457,82 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
54195457 );
54205458});
54215459
5422const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {
5460const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
54235461 effort: CodexReasoningEffort;
5462 fastModeEnabled: boolean;
54245463 options: ReadonlyArray<CodexReasoningEffort>;
54255464 onEffortChange: (effort: CodexReasoningEffort) => void;
5465 onFastModeChange: (enabled: boolean) => void;
54265466}) {
5467 const [isMenuOpen, setIsMenuOpen] = useState(false);
54275468 const defaultReasoningEffort = getDefaultReasoningEffort("codex");
5469 const reasoningLabelByOption: Record<CodexReasoningEffort, string> = {
5470 low: "Low",
5471 medium: "Medium",
5472 high: "High",
5473 xhigh: "Extra High",
5474 };
5475 const triggerLabel = [
5476 reasoningLabelByOption[props.effort],
5477 ...(props.fastModeEnabled ? ["Fast"] : []),
5478 ]
5479 .filter(Boolean)
5480 .join(" · ");
54285481
54295482 return (
5430 <Select
5431 value={props.effort}
5432 onValueChange={(value) => (value ? props.onEffortChange(value) : undefined)}
5483 <Menu
5484 open={isMenuOpen}
5485 onOpenChange={(open) => {
5486 setIsMenuOpen(open);
5487 }}
54335488 >
5434 <SelectTrigger variant="ghost" size="sm">
5435 <SelectValue />
5436 </SelectTrigger>
5437 <SelectPopup alignItemWithTrigger={false}>
5438 {props.options.map((effort) => (
5439 <SelectItem key={effort} value={effort}>
5440 {effort}
5441 {effort === defaultReasoningEffort ? " (default)" : ""}
5442 </SelectItem>
5443 ))}
5444 </SelectPopup>
5445 </Select>
5489 <MenuTrigger
5490 render={
5491 <Button
5492 size="sm"
5493 variant="ghost"
5494 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
5495 />
5496 }
5497 >
5498 <span>{triggerLabel}</span>
5499 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
5500 </MenuTrigger>
5501 <MenuPopup align="start">
5502 <MenuGroup>
5503 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div>
5504 <MenuRadioGroup
5505 value={props.effort}
5506 onValueChange={(value) => {
5507 if (!value) return;
5508 const nextEffort = props.options.find((option) => option === value);
5509 if (!nextEffort) return;
5510 props.onEffortChange(nextEffort);
5511 }}
5512 >
5513 {props.options.map((effort) => (
5514 <MenuRadioItem key={effort} value={effort}>
5515 {reasoningLabelByOption[effort]}
5516 {effort === defaultReasoningEffort ? " (default)" : ""}
5517 </MenuRadioItem>
5518 ))}
5519 </MenuRadioGroup>
5520 </MenuGroup>
5521 <MenuDivider />
5522 <MenuGroup>
5523 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
5524 <MenuRadioGroup
5525 value={props.fastModeEnabled ? "on" : "off"}
5526 onValueChange={(value) => {
5527 props.onFastModeChange(value === "on");
5528 }}
5529 >
5530 <MenuRadioItem value="off">off</MenuRadioItem>
5531 <MenuRadioItem value="on">on</MenuRadioItem>
5532 </MenuRadioGroup>
5533 </MenuGroup>
5534 </MenuPopup>
5535 </Menu>
54465536 );
54475537});
54485538
......@@ -5459,7 +5549,7 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
54595549 low: "Low",
54605550 normal: "Normal",
54615551 high: "High",
5462 xhigh: "XHigh",
5552 xhigh: "Extra High",
54635553 };
54645554 const traitSummary = [
54655555 ...(props.capabilities.supportsReasoning
......@@ -5486,7 +5576,7 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
54865576 <Button
54875577 size="sm"
54885578 variant="ghost"
5489 className="shrink-0 whitespace-nowrap px-2 sm:px-3"
5579 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
54905580 disabled={props.disabled}
54915581 />
54925582 }
apps/web/src/composerDraftStore.test.ts+74
......@@ -184,6 +184,8 @@ describe("composerDraftStore project draft thread mapping", () => {
184184 branch: "feature/test",
185185 worktreePath: "/tmp/worktree-test",
186186 envMode: "worktree",
187 runtimeMode: "full-access",
188 interactionMode: "default",
187189 createdAt: "2026-01-01T00:00:00.000Z",
188190 });
189191 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({
......@@ -191,6 +193,8 @@ describe("composerDraftStore project draft thread mapping", () => {
191193 branch: "feature/test",
192194 worktreePath: "/tmp/worktree-test",
193195 envMode: "worktree",
196 runtimeMode: "full-access",
197 interactionMode: "default",
194198 createdAt: "2026-01-01T00:00:00.000Z",
195199 });
196200 });
......@@ -329,6 +333,33 @@ describe("composerDraftStore project draft thread mapping", () => {
329333 });
330334});
331335
336describe("composerDraftStore codex fast mode", () => {
337 const threadId = ThreadId.makeUnsafe("thread-service-tier");
338
339 beforeEach(() => {
340 useComposerDraftStore.setState({
341 draftsByThreadId: {},
342 draftThreadsByThreadId: {},
343 projectDraftThreadIdByProjectId: {},
344 });
345 });
346
347 it("stores codex fast mode in the draft", () => {
348 const store = useComposerDraftStore.getState();
349 store.setCodexFastMode(threadId, true);
350
351 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.codexFastMode).toBe(true);
352 });
353
354 it("clears codex fast mode when reset to the default", () => {
355 const store = useComposerDraftStore.getState();
356 store.setCodexFastMode(threadId, true);
357 store.setCodexFastMode(threadId, false);
358
359 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
360 });
361});
362
332363describe("composerDraftStore setModel", () => {
333364 const threadId = ThreadId.makeUnsafe("thread-model");
334365
......@@ -377,3 +408,46 @@ describe("composerDraftStore setProvider", () => {
377408 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
378409 });
379410});
411
412describe("composerDraftStore runtime and interaction settings", () => {
413 const threadId = ThreadId.makeUnsafe("thread-settings");
414
415 beforeEach(() => {
416 useComposerDraftStore.setState({
417 draftsByThreadId: {},
418 draftThreadsByThreadId: {},
419 projectDraftThreadIdByProjectId: {},
420 });
421 });
422
423 it("stores runtime mode overrides in the composer draft", () => {
424 const store = useComposerDraftStore.getState();
425
426 store.setRuntimeMode(threadId, "approval-required");
427
428 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.runtimeMode).toBe(
429 "approval-required",
430 );
431 });
432
433 it("stores interaction mode overrides in the composer draft", () => {
434 const store = useComposerDraftStore.getState();
435
436 store.setInteractionMode(threadId, "plan");
437
438 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.interactionMode).toBe(
439 "plan",
440 );
441 });
442
443 it("removes empty settings-only drafts when overrides are cleared", () => {
444 const store = useComposerDraftStore.getState();
445
446 store.setRuntimeMode(threadId, "approval-required");
447 store.setInteractionMode(threadId, "plan");
448 store.setRuntimeMode(threadId, null);
449 store.setInteractionMode(threadId, null);
450
451 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
452 });
453});
apps/web/src/composerDraftStore.ts+148-4
......@@ -3,12 +3,12 @@ import {
33 ProjectId,
44 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
55 ThreadId,
6 normalizeModelSlug,
76 type CodexReasoningEffort,
87 type ProviderKind,
98 type ProviderInteractionMode,
109 type RuntimeMode,
1110} from "@t3tools/contracts";
11import { normalizeModelSlug } from "@t3tools/shared/model";
1212import {
1313 DEFAULT_INTERACTION_MODE,
1414 DEFAULT_RUNTIME_MODE,
......@@ -38,7 +38,11 @@ interface PersistedComposerThreadDraftState {
3838 attachments: PersistedComposerImageAttachment[];
3939 provider?: ProviderKind | null;
4040 model?: string | null;
41 runtimeMode?: RuntimeMode | null;
42 interactionMode?: ProviderInteractionMode | null;
4143 effort?: CodexReasoningEffort | null;
44 codexFastMode?: boolean | null;
45 serviceTier?: string | null;
4246}
4347
4448interface PersistedDraftThreadState {
......@@ -64,7 +68,10 @@ interface ComposerThreadDraftState {
6468 persistedAttachments: PersistedComposerImageAttachment[];
6569 provider: ProviderKind | null;
6670 model: string | null;
71 runtimeMode: RuntimeMode | null;
72 interactionMode: ProviderInteractionMode | null;
6773 effort: CodexReasoningEffort | null;
74 codexFastMode: boolean;
6875}
6976
7077export interface DraftThreadState {
......@@ -117,7 +124,13 @@ interface ComposerDraftStoreState {
117124 setPrompt: (threadId: ThreadId, prompt: string) => void;
118125 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;
119126 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
127 setRuntimeMode: (threadId: ThreadId, runtimeMode: RuntimeMode | null | undefined) => void;
128 setInteractionMode: (
129 threadId: ThreadId,
130 interactionMode: ProviderInteractionMode | null | undefined,
131 ) => void;
120132 setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void;
133 setCodexFastMode: (threadId: ThreadId, enabled: boolean | null | undefined) => void;
121134 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;
122135 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;
123136 removeImage: (threadId: ThreadId, imageId: string) => void;
......@@ -149,7 +162,10 @@ const EMPTY_THREAD_DRAFT = Object.freeze({
149162 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
150163 provider: null,
151164 model: null,
165 runtimeMode: null,
166 interactionMode: null,
152167 effort: null,
168 codexFastMode: false,
153169}) as ComposerThreadDraftState;
154170
155171const REASONING_EFFORT_VALUES = new Set<CodexReasoningEffort>(
......@@ -164,7 +180,10 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
164180 persistedAttachments: [],
165181 provider: null,
166182 model: null,
183 runtimeMode: null,
184 interactionMode: null,
167185 effort: null,
186 codexFastMode: false,
168187 };
169188}
170189
......@@ -181,7 +200,10 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
181200 draft.persistedAttachments.length === 0 &&
182201 draft.provider === null &&
183202 draft.model === null &&
184 draft.effort === null
203 draft.runtimeMode === null &&
204 draft.interactionMode === null &&
205 draft.effort === null &&
206 draft.codexFastMode === false
185207 );
186208}
187209
......@@ -349,13 +371,34 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
349371 typeof draftCandidate.model === "string"
350372 ? normalizeModelSlug(draftCandidate.model, provider ?? "codex")
351373 : null;
374 const runtimeMode =
375 draftCandidate.runtimeMode === "approval-required" ||
376 draftCandidate.runtimeMode === "full-access"
377 ? draftCandidate.runtimeMode
378 : null;
379 const interactionMode =
380 draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default"
381 ? draftCandidate.interactionMode
382 : null;
352383 const effortCandidate =
353384 typeof draftCandidate.effort === "string" ? draftCandidate.effort : null;
354385 const effort =
355386 effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as CodexReasoningEffort)
356387 ? (effortCandidate as CodexReasoningEffort)
357388 : null;
358 if (prompt.length === 0 && attachments.length === 0 && !provider && !model && !effort) {
389 const codexFastMode =
390 draftCandidate.codexFastMode === true ||
391 (typeof draftCandidate.serviceTier === "string" && draftCandidate.serviceTier === "fast");
392 if (
393 prompt.length === 0 &&
394 attachments.length === 0 &&
395 !provider &&
396 !model &&
397 !runtimeMode &&
398 !interactionMode &&
399 !effort &&
400 !codexFastMode
401 ) {
359402 continue;
360403 }
361404 nextDraftsByThreadId[threadId as ThreadId] = {
......@@ -363,7 +406,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
363406 attachments,
364407 ...(provider ? { provider } : {}),
365408 ...(model ? { model } : {}),
409 ...(runtimeMode ? { runtimeMode } : {}),
410 ...(interactionMode ? { interactionMode } : {}),
366411 ...(effort ? { effort } : {}),
412 ...(codexFastMode ? { codexFastMode } : {}),
367413 };
368414 }
369415 return {
......@@ -467,7 +513,10 @@ function toHydratedThreadDraft(
467513 persistedAttachments: persistedDraft.attachments,
468514 provider: persistedDraft.provider ?? null,
469515 model: persistedDraft.model ?? null,
516 runtimeMode: persistedDraft.runtimeMode ?? null,
517 interactionMode: persistedDraft.interactionMode ?? null,
470518 effort: persistedDraft.effort ?? null,
519 codexFastMode: persistedDraft.codexFastMode === true,
471520 };
472521}
473522
......@@ -783,6 +832,62 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
783832 return { draftsByThreadId: nextDraftsByThreadId };
784833 });
785834 },
835 setRuntimeMode: (threadId, runtimeMode) => {
836 if (threadId.length === 0) {
837 return;
838 }
839 const nextRuntimeMode =
840 runtimeMode === "approval-required" || runtimeMode === "full-access" ? runtimeMode : null;
841 set((state) => {
842 const existing = state.draftsByThreadId[threadId];
843 if (!existing && nextRuntimeMode === null) {
844 return state;
845 }
846 const base = existing ?? createEmptyThreadDraft();
847 if (base.runtimeMode === nextRuntimeMode) {
848 return state;
849 }
850 const nextDraft: ComposerThreadDraftState = {
851 ...base,
852 runtimeMode: nextRuntimeMode,
853 };
854 const nextDraftsByThreadId = { ...state.draftsByThreadId };
855 if (shouldRemoveDraft(nextDraft)) {
856 delete nextDraftsByThreadId[threadId];
857 } else {
858 nextDraftsByThreadId[threadId] = nextDraft;
859 }
860 return { draftsByThreadId: nextDraftsByThreadId };
861 });
862 },
863 setInteractionMode: (threadId, interactionMode) => {
864 if (threadId.length === 0) {
865 return;
866 }
867 const nextInteractionMode =
868 interactionMode === "plan" || interactionMode === "default" ? interactionMode : null;
869 set((state) => {
870 const existing = state.draftsByThreadId[threadId];
871 if (!existing && nextInteractionMode === null) {
872 return state;
873 }
874 const base = existing ?? createEmptyThreadDraft();
875 if (base.interactionMode === nextInteractionMode) {
876 return state;
877 }
878 const nextDraft: ComposerThreadDraftState = {
879 ...base,
880 interactionMode: nextInteractionMode,
881 };
882 const nextDraftsByThreadId = { ...state.draftsByThreadId };
883 if (shouldRemoveDraft(nextDraft)) {
884 delete nextDraftsByThreadId[threadId];
885 } else {
886 nextDraftsByThreadId[threadId] = nextDraft;
887 }
888 return { draftsByThreadId: nextDraftsByThreadId };
889 });
890 },
786891 setEffort: (threadId, effort) => {
787892 if (threadId.length === 0) {
788893 return;
......@@ -815,6 +920,33 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
815920 return { draftsByThreadId: nextDraftsByThreadId };
816921 });
817922 },
923 setCodexFastMode: (threadId, enabled) => {
924 if (threadId.length === 0) {
925 return;
926 }
927 const nextCodexFastMode = enabled === true;
928 set((state) => {
929 const existing = state.draftsByThreadId[threadId];
930 if (!existing && nextCodexFastMode === false) {
931 return state;
932 }
933 const base = existing ?? createEmptyThreadDraft();
934 if (base.codexFastMode === nextCodexFastMode) {
935 return state;
936 }
937 const nextDraft: ComposerThreadDraftState = {
938 ...base,
939 codexFastMode: nextCodexFastMode,
940 };
941 const nextDraftsByThreadId = { ...state.draftsByThreadId };
942 if (shouldRemoveDraft(nextDraft)) {
943 delete nextDraftsByThreadId[threadId];
944 } else {
945 nextDraftsByThreadId[threadId] = nextDraft;
946 }
947 return { draftsByThreadId: nextDraftsByThreadId };
948 });
949 },
818950 addImage: (threadId, image) => {
819951 if (threadId.length === 0) {
820952 return;
......@@ -1049,7 +1181,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
10491181 draft.persistedAttachments.length === 0 &&
10501182 draft.provider === null &&
10511183 draft.model === null &&
1052 draft.effort === null
1184 draft.runtimeMode === null &&
1185 draft.interactionMode === null &&
1186 draft.effort === null &&
1187 draft.codexFastMode === false
10531188 ) {
10541189 continue;
10551190 }
......@@ -1063,9 +1198,18 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
10631198 if (draft.provider) {
10641199 persistedDraft.provider = draft.provider;
10651200 }
1201 if (draft.runtimeMode) {
1202 persistedDraft.runtimeMode = draft.runtimeMode;
1203 }
1204 if (draft.interactionMode) {
1205 persistedDraft.interactionMode = draft.interactionMode;
1206 }
10661207 if (draft.effort) {
10671208 persistedDraft.effort = draft.effort;
10681209 }
1210 if (draft.codexFastMode) {
1211 persistedDraft.codexFastMode = true;
1212 }
10691213 persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;
10701214 }
10711215 return {
apps/web/src/routes/_chat.settings.tsx+2-1
......@@ -1,7 +1,8 @@
11import { createFileRoute } from "@tanstack/react-router";
22import { useQuery } from "@tanstack/react-query";
33import { useCallback, useState } from "react";
4import { getModelOptions, normalizeModelSlug, type ProviderKind } from "@t3tools/contracts";
4import { type ProviderKind } from "@t3tools/contracts";
5import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
56
67import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
78import { isElectron } from "../env";
apps/web/src/store.ts+5-3
......@@ -1,15 +1,17 @@
11import { Fragment, type ReactNode, createElement, useEffect } from "react";
22import {
33 DEFAULT_MODEL,
4 getModelOptions,
5 normalizeModelSlug,
64 type ProviderKind,
75 ThreadId,
86 type OrchestrationReadModel,
97 type OrchestrationSessionStatus,
8} from "@t3tools/contracts";
9import {
10 getModelOptions,
11 normalizeModelSlug,
1012 resolveModelSlug,
1113 resolveModelSlugForProvider,
12} from "@t3tools/contracts";
14} from "@t3tools/shared/model";
1315import { create } from "zustand";
1416import {
1517 type ChatMessage,
apps/web/src/types.ts+4-4
......@@ -11,13 +11,13 @@ import type {
1111 CheckpointRef,
1212 ProviderKind,
1313 ProviderInteractionMode,
14 RuntimeMode,
1415} from "@t3tools/contracts";
1516
1617export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
17export type RuntimeMode = "approval-required" | "full-access";
1818export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
19export type InteractionMode = ProviderInteractionMode;
20export const DEFAULT_INTERACTION_MODE: InteractionMode = "default";
19
20export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default";
2121export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
2222export const DEFAULT_THREAD_TERMINAL_ID = "default";
2323export const MAX_THREAD_TERMINAL_COUNT = 4;
......@@ -90,7 +90,7 @@ export interface Thread {
9090 title: string;
9191 model: string;
9292 runtimeMode: RuntimeMode;
93 interactionMode: InteractionMode;
93 interactionMode: ProviderInteractionMode;
9494 session: ThreadSession | null;
9595 messages: ChatMessage[];
9696 proposedPlans: ProposedPlan[];
packages/contracts/src/model.test.ts deleted-178
......@@ -1,178 +0,0 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 CURSOR_MODEL_FAMILY_OPTIONS,
5 CURSOR_REASONING_OPTIONS,
6 DEFAULT_MODEL,
7 DEFAULT_MODEL_BY_PROVIDER,
8 MODEL_OPTIONS,
9 MODEL_OPTIONS_BY_PROVIDER,
10 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
11 DEFAULT_REASONING_EFFORT_BY_PROVIDER,
12 getDefaultModel,
13 getDefaultReasoningEffort,
14 getCursorModelFamilyOptions,
15 getModelOptions,
16 getReasoningEffortOptions,
17 normalizeModelSlug,
18 parseCursorModelSelection,
19 resolveCursorModelFromSelection,
20 resolveModelSlug,
21 resolveModelSlugForProvider,
22} from "./model";
23
24describe("normalizeModelSlug", () => {
25 it("maps known aliases to canonical slugs", () => {
26 expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex");
27 expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex");
28 });
29
30 it("returns null for empty or missing values", () => {
31 expect(normalizeModelSlug("")).toBeNull();
32 expect(normalizeModelSlug(" ")).toBeNull();
33 expect(normalizeModelSlug(null)).toBeNull();
34 expect(normalizeModelSlug(undefined)).toBeNull();
35 });
36
37 it("preserves non-aliased model slugs", () => {
38 expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2");
39 expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
40 });
41
42 it("uses provider-specific aliases", () => {
43 expect(normalizeModelSlug("sonnet", "claudeCode")).toBe("claude-sonnet-4-6");
44 expect(normalizeModelSlug("opus-4.6", "claudeCode")).toBe("claude-opus-4-6");
45 expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeCode")).toBe(
46 "claude-haiku-4-5",
47 );
48 expect(normalizeModelSlug("composer", "cursor")).toBe("composer-1.5");
49 expect(normalizeModelSlug("gpt-5.3-codex-spark", "cursor")).toBe(
50 "gpt-5.3-codex-spark-preview",
51 );
52 expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1");
53 expect(normalizeModelSlug("claude-4.6-sonnet-thinking", "cursor")).toBe(
54 "sonnet-4.6-thinking",
55 );
56 });
57});
58
59describe("resolveModelSlug", () => {
60 it("returns default only when the model is missing", () => {
61 expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL);
62 expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL);
63 });
64
65 it("preserves unknown custom models", () => {
66 expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
67 expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL);
68 });
69
70 it("resolves only supported model options", () => {
71 for (const model of MODEL_OPTIONS) {
72 expect(resolveModelSlug(model.slug)).toBe(model.slug);
73 }
74 });
75
76 it("supports provider-aware resolution", () => {
77 expect(resolveModelSlugForProvider("claudeCode", undefined)).toBe(
78 DEFAULT_MODEL_BY_PROVIDER.claudeCode,
79 );
80 expect(resolveModelSlugForProvider("claudeCode", "sonnet")).toBe("claude-sonnet-4-6");
81 expect(resolveModelSlugForProvider("claudeCode", "gpt-5.3-codex")).toBe(
82 DEFAULT_MODEL_BY_PROVIDER.claudeCode,
83 );
84 expect(resolveModelSlugForProvider("cursor", undefined)).toBe(
85 DEFAULT_MODEL_BY_PROVIDER.cursor,
86 );
87 expect(resolveModelSlugForProvider("cursor", "composer")).toBe("composer-1.5");
88 expect(resolveModelSlugForProvider("cursor", "gpt-5.3-codex-high-fast")).toBe(
89 "gpt-5.3-codex-high-fast",
90 );
91 expect(resolveModelSlugForProvider("cursor", "claude-sonnet-4-6")).toBe(
92 DEFAULT_MODEL_BY_PROVIDER.cursor,
93 );
94 });
95
96 it("keeps codex defaults for backward compatibility", () => {
97 expect(getDefaultModel()).toBe(DEFAULT_MODEL);
98 expect(getModelOptions()).toEqual(MODEL_OPTIONS);
99 expect(getModelOptions("claudeCode")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeCode);
100 expect(getModelOptions("cursor")).toEqual(MODEL_OPTIONS_BY_PROVIDER.cursor);
101 expect(getCursorModelFamilyOptions()).toEqual(CURSOR_MODEL_FAMILY_OPTIONS);
102 });
103});
104
105describe("cursor model selection", () => {
106 it("includes the expected cursor reasoning levels and families", () => {
107 expect(CURSOR_REASONING_OPTIONS).toEqual(["low", "normal", "high", "xhigh"]);
108 expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("gpt-5.3-codex");
109 expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("opus-4.6");
110 });
111
112 it("parses codex reasoning and fast mode variants", () => {
113 expect(parseCursorModelSelection("gpt-5.3-codex-high-fast")).toEqual({
114 family: "gpt-5.3-codex",
115 reasoning: "high",
116 fast: true,
117 thinking: false,
118 });
119 expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual(
120 parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor),
121 );
122 });
123
124 it("parses and resolves thinking variants", () => {
125 expect(parseCursorModelSelection("sonnet-4.6-thinking")).toEqual({
126 family: "sonnet-4.6",
127 reasoning: "normal",
128 fast: false,
129 thinking: true,
130 });
131 expect(
132 resolveCursorModelFromSelection({
133 family: "sonnet-4.6",
134 thinking: true,
135 }),
136 ).toBe("sonnet-4.6-thinking");
137 });
138
139 it("resolves codex family selections into concrete model ids", () => {
140 expect(
141 resolveCursorModelFromSelection({
142 family: "gpt-5.3-codex",
143 reasoning: "xhigh",
144 fast: true,
145 }),
146 ).toBe("gpt-5.3-codex-xhigh-fast");
147 });
148});
149
150describe("getReasoningEffortOptions", () => {
151 it("returns codex reasoning options for codex", () => {
152 expect(getReasoningEffortOptions("codex")).toEqual(
153 REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex,
154 );
155 });
156
157 it("returns no reasoning options for claudeCode", () => {
158 expect(getReasoningEffortOptions("claudeCode")).toEqual([]);
159 });
160
161 it("returns no reasoning options for cursor", () => {
162 expect(getReasoningEffortOptions("cursor")).toEqual([]);
163 });
164});
165
166describe("getDefaultReasoningEffort", () => {
167 it("returns provider-scoped defaults", () => {
168 expect(getDefaultReasoningEffort("codex")).toBe(
169 DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex,
170 );
171 expect(getDefaultReasoningEffort("claudeCode")).toBe(
172 DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeCode,
173 );
174 expect(getDefaultReasoningEffort("cursor")).toBe(
175 DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor,
176 );
177 });
178});
packages/contracts/src/model.ts+35-269
......@@ -1,13 +1,42 @@
1import { Schema } from "effect";
12import type { ProviderKind } from "./orchestration";
23
4export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const;
5export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number];
6
7export const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
8export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number];
9
10export const CodexModelOptions = Schema.Struct({
11 reasoningEffort: Schema.optional(Schema.Literals(CODEX_REASONING_EFFORT_OPTIONS)),
12 fastMode: Schema.optional(Schema.Boolean),
13});
14export type CodexModelOptions = typeof CodexModelOptions.Type;
15
16export const ClaudeCodeModelOptions = Schema.Struct({
17 thinking: Schema.optional(Schema.Boolean),
18});
19export type ClaudeCodeModelOptions = typeof ClaudeCodeModelOptions.Type;
20
21export const CursorModelOptions = Schema.Struct({
22 reasoning: Schema.optional(Schema.Literals(CURSOR_REASONING_OPTIONS)),
23 fastMode: Schema.optional(Schema.Boolean),
24 thinking: Schema.optional(Schema.Boolean),
25});
26export type CursorModelOptions = typeof CursorModelOptions.Type;
27
28export const ProviderModelOptions = Schema.Struct({
29 codex: Schema.optional(CodexModelOptions),
30 claudeCode: Schema.optional(ClaudeCodeModelOptions),
31 cursor: Schema.optional(CursorModelOptions),
32});
33export type ProviderModelOptions = typeof ProviderModelOptions.Type;
34
335type ModelOption = {
436 readonly slug: string;
537 readonly name: string;
638};
739
8export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const;
9export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number];
10
1140type CursorModelFamilyOption = {
1241 readonly slug: string;
1342 readonly name: string;
......@@ -29,14 +58,15 @@ export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["sl
2958
3059export const MODEL_OPTIONS_BY_PROVIDER = {
3160 codex: [
61 { slug: "gpt-5.4", name: "GPT-5.4" },
3262 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
3363 { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },
3464 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
3565 { slug: "gpt-5.2", name: "GPT-5.2" },
3666 ],
3767 claudeCode: [
38 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
3968 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
69 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
4070 { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
4171 ],
4272 cursor: [
......@@ -66,91 +96,6 @@ type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]
6696export type ModelSlug = BuiltInModelSlug | (string & {});
6797export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"];
6898
69type CursorModelCapability = {
70 readonly supportsReasoning: boolean;
71 readonly supportsFast: boolean;
72 readonly supportsThinking: boolean;
73 readonly defaultReasoning: CursorReasoningOption;
74 readonly defaultThinking: boolean;
75};
76
77const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCapability> = {
78 auto: {
79 supportsReasoning: false,
80 supportsFast: false,
81 supportsThinking: false,
82 defaultReasoning: "normal",
83 defaultThinking: false,
84 },
85 "composer-1.5": {
86 supportsReasoning: false,
87 supportsFast: false,
88 supportsThinking: false,
89 defaultReasoning: "normal",
90 defaultThinking: false,
91 },
92 "composer-1": {
93 supportsReasoning: false,
94 supportsFast: false,
95 supportsThinking: false,
96 defaultReasoning: "normal",
97 defaultThinking: false,
98 },
99 "gpt-5.3-codex": {
100 supportsReasoning: true,
101 supportsFast: true,
102 supportsThinking: false,
103 defaultReasoning: "normal",
104 defaultThinking: false,
105 },
106 "gpt-5.3-codex-spark-preview": {
107 supportsReasoning: false,
108 supportsFast: false,
109 supportsThinking: false,
110 defaultReasoning: "normal",
111 defaultThinking: false,
112 },
113 "opus-4.6": {
114 supportsReasoning: false,
115 supportsFast: false,
116 supportsThinking: true,
117 defaultReasoning: "normal",
118 defaultThinking: true,
119 },
120 "opus-4.5": {
121 supportsReasoning: false,
122 supportsFast: false,
123 supportsThinking: true,
124 defaultReasoning: "normal",
125 defaultThinking: true,
126 },
127 "sonnet-4.6": {
128 supportsReasoning: false,
129 supportsFast: false,
130 supportsThinking: true,
131 defaultReasoning: "normal",
132 defaultThinking: true,
133 },
134 "gemini-3.1-pro": {
135 supportsReasoning: false,
136 supportsFast: false,
137 supportsThinking: false,
138 defaultReasoning: "normal",
139 defaultThinking: false,
140 },
141};
142
143const CURSOR_MODEL_FAMILY_SET = new Set<CursorModelFamily>(
144 CURSOR_MODEL_FAMILY_OPTIONS.map((option) => option.slug),
145);
146
147export interface CursorModelSelection {
148 readonly family: CursorModelFamily;
149 readonly reasoning: CursorReasoningOption;
150 readonly fast: boolean;
151 readonly thinking: boolean;
152}
153
15499export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {
155100 codex: "gpt-5.3-codex",
156101 claudeCode: "claude-sonnet-4-6",
......@@ -163,6 +108,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
163108
164109export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, ModelSlug>> = {
165110 codex: {
111 "5.4": "gpt-5.4",
166112 "5.3": "gpt-5.3-codex",
167113 "gpt-5.3": "gpt-5.3-codex",
168114 "5.3-spark": "gpt-5.3-codex-spark",
......@@ -198,172 +144,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
198144 },
199145};
200146
201const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = {
202 claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)),
203 codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)),
204 cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)),
205};
206
207export function getModelOptions(provider: ProviderKind = "codex") {
208 return MODEL_OPTIONS_BY_PROVIDER[provider];
209}
210
211export function getCursorModelFamilyOptions() {
212 return CURSOR_MODEL_FAMILY_OPTIONS;
213}
214
215export function getCursorModelCapabilities(family: CursorModelFamily) {
216 return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family];
217}
218
219function fallbackCursorModelFamily(): CursorModelFamily {
220 const fallback = parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor);
221 return fallback.family;
222}
223
224function resolveCursorModelFamily(model: string | null | undefined): CursorModelFamily {
225 const normalized = normalizeModelSlug(model, "cursor");
226 if (!normalized) {
227 return fallbackCursorModelFamily();
228 }
229
230 if (
231 normalized === "gpt-5.3-codex" ||
232 normalized === "gpt-5.3-codex-fast" ||
233 normalized === "gpt-5.3-codex-low" ||
234 normalized === "gpt-5.3-codex-low-fast" ||
235 normalized === "gpt-5.3-codex-high" ||
236 normalized === "gpt-5.3-codex-high-fast" ||
237 normalized === "gpt-5.3-codex-xhigh" ||
238 normalized === "gpt-5.3-codex-xhigh-fast"
239 ) {
240 return "gpt-5.3-codex";
241 }
242
243 if (normalized === "sonnet-4.6-thinking") {
244 return "sonnet-4.6";
245 }
246 if (normalized === "opus-4.6-thinking") {
247 return "opus-4.6";
248 }
249 if (normalized === "opus-4.5-thinking") {
250 return "opus-4.5";
251 }
252
253 return CURSOR_MODEL_FAMILY_SET.has(normalized as CursorModelFamily)
254 ? (normalized as CursorModelFamily)
255 : fallbackCursorModelFamily();
256}
257
258function resolveCursorReasoning(model: CursorModelSlug): CursorReasoningOption {
259 if (model.includes("-xhigh")) return "xhigh";
260 if (model.includes("-high")) return "high";
261 if (model.includes("-low")) return "low";
262 return "normal";
263}
264
265export function parseCursorModelSelection(model: string | null | undefined): CursorModelSelection {
266 const family = resolveCursorModelFamily(model);
267 const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family];
268 const normalized = resolveModelSlugForProvider("cursor", model) as CursorModelSlug;
269
270 if (capability.supportsReasoning) {
271 return {
272 family,
273 reasoning: resolveCursorReasoning(normalized),
274 fast: normalized.endsWith("-fast"),
275 thinking: false,
276 };
277 }
278
279 if (capability.supportsThinking) {
280 return {
281 family,
282 reasoning: capability.defaultReasoning,
283 fast: false,
284 thinking: normalized.endsWith("-thinking"),
285 };
286 }
287
288 return {
289 family,
290 reasoning: capability.defaultReasoning,
291 fast: false,
292 thinking: capability.defaultThinking,
293 };
294}
295
296export function resolveCursorModelFromSelection(input: {
297 readonly family: CursorModelFamily;
298 readonly reasoning?: CursorReasoningOption | null;
299 readonly fast?: boolean;
300 readonly thinking?: boolean;
301}): CursorModelSlug {
302 const family = resolveCursorModelFamily(input.family);
303 const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family];
304
305 if (capability.supportsReasoning) {
306 const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal")
307 ? (input.reasoning ?? "normal")
308 : capability.defaultReasoning;
309 const reasoningSuffix = reasoning === "normal" ? "" : `-${reasoning}`;
310 const fastSuffix = input.fast ? "-fast" : "";
311 const candidate = `${family}${reasoningSuffix}${fastSuffix}`;
312 return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug;
313 }
314
315 if (capability.supportsThinking) {
316 const candidate = input.thinking ? `${family}-thinking` : family;
317 return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug;
318 }
319
320 return resolveModelSlugForProvider("cursor", family) as CursorModelSlug;
321}
322
323export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
324 return DEFAULT_MODEL_BY_PROVIDER[provider];
325}
326
327export function normalizeModelSlug(
328 model: string | null | undefined,
329 provider: ProviderKind = "codex",
330): ModelSlug | null {
331 if (typeof model !== "string") {
332 return null;
333 }
334
335 const trimmed = model.trim();
336 if (!trimmed) {
337 return null;
338 }
339
340 return MODEL_SLUG_ALIASES_BY_PROVIDER[provider][trimmed] ?? (trimmed as ModelSlug);
341}
342
343export function resolveModelSlug(
344 model: string | null | undefined,
345 provider: ProviderKind = "codex",
346): ModelSlug {
347 const normalized = normalizeModelSlug(model, provider);
348 if (!normalized) {
349 return DEFAULT_MODEL_BY_PROVIDER[provider];
350 }
351
352 return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized)
353 ? normalized
354 : DEFAULT_MODEL_BY_PROVIDER[provider];
355}
356
357export function resolveModelSlugForProvider(
358 provider: ProviderKind,
359 model: string | null | undefined,
360): ModelSlug {
361 return resolveModelSlug(model, provider);
362}
363
364const CODEX_REASONING_EFFORT_OPTIONS = ["xhigh", "high", "medium", "low"] as const;
365export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORT_OPTIONS)[number];
366
367147export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {
368148 codex: CODEX_REASONING_EFFORT_OPTIONS,
369149 claudeCode: [],
......@@ -375,17 +155,3 @@ export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = {
375155 claudeCode: null,
376156 cursor: null,
377157} as const satisfies Record<ProviderKind, CodexReasoningEffort | null>;
378
379export function getReasoningEffortOptions(
380 provider: ProviderKind = "codex",
381): ReadonlyArray<CodexReasoningEffort> {
382 return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider];
383}
384
385export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort;
386export function getDefaultReasoningEffort(provider: ProviderKind): CodexReasoningEffort | null;
387export function getDefaultReasoningEffort(
388 provider: ProviderKind = "codex",
389): CodexReasoningEffort | null {
390 return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider];
391}
packages/contracts/src/orchestration.test.ts+43-12
......@@ -1,4 +1,5 @@
1import { assert, it } from "@effect/vitest";
1import assert from "node:assert/strict";
2import { it } from "@effect/vitest";
23import { Effect, Schema } from "effect";
34
45import {
......@@ -137,39 +138,69 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
137138 }),
138139);
139140
140it.effect("accepts cursor provider in thread.turn.start", () =>
141it.effect("accepts provider-scoped model options in thread.turn.start", () =>
141142 Effect.gen(function* () {
142143 const parsed = yield* decodeThreadTurnStartCommand({
143144 type: "thread.turn.start",
144 commandId: "cmd-turn-cursor",
145 commandId: "cmd-turn-options",
145146 threadId: "thread-1",
146147 message: {
147 messageId: "msg-3",
148 messageId: "msg-options",
148149 role: "user",
149150 text: "hello",
150151 attachments: [],
151152 },
152 provider: "cursor",
153 provider: "codex",
154 model: "gpt-5.3-codex",
155 modelOptions: {
156 codex: {
157 reasoningEffort: "high",
158 fastMode: true,
159 },
160 },
153161 createdAt: "2026-01-01T00:00:00.000Z",
154162 });
155 assert.strictEqual(parsed.provider, "cursor");
156 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
163 assert.strictEqual(parsed.provider, "codex");
164 assert.strictEqual(parsed.modelOptions?.codex?.reasoningEffort, "high");
165 assert.strictEqual(parsed.modelOptions?.codex?.fastMode, true);
157166 }),
158167);
159168
160it.effect("decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode", () =>
169it.effect("accepts cursor provider in thread.turn.start", () =>
161170 Effect.gen(function* () {
162 const parsed = yield* decodeThreadTurnStartRequestedPayload({
171 const parsed = yield* decodeThreadTurnStartCommand({
172 type: "thread.turn.start",
173 commandId: "cmd-turn-cursor",
163174 threadId: "thread-1",
164 messageId: "msg-1",
175 message: {
176 messageId: "msg-3",
177 role: "user",
178 text: "hello",
179 attachments: [],
180 },
181 provider: "cursor",
165182 createdAt: "2026-01-01T00:00:00.000Z",
166183 });
167 assert.strictEqual(parsed.provider, undefined);
168 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
184 assert.strictEqual(parsed.provider, "cursor");
169185 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
170186 }),
171187);
172188
189it.effect(
190 "decodes thread.turn-start-requested defaults for provider, runtime mode, and interaction mode",
191 () =>
192 Effect.gen(function* () {
193 const parsed = yield* decodeThreadTurnStartRequestedPayload({
194 threadId: "thread-1",
195 messageId: "msg-1",
196 createdAt: "2026-01-01T00:00:00.000Z",
197 });
198 assert.strictEqual(parsed.provider, undefined);
199 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
200 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
201 }),
202);
203
173204it.effect("decodes orchestration session runtime mode defaults", () =>
174205 Effect.gen(function* () {
175206 const parsed = yield* decodeOrchestrationSession({
packages/contracts/src/orchestration.ts+4-4
......@@ -1,4 +1,5 @@
11import { Option, Schema, SchemaIssue, Struct } from "effect";
2import { ProviderModelOptions } from "./model";
23import {
34 ApprovalRequestId,
45 CheckpointRef,
......@@ -54,7 +55,6 @@ export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
5455export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
5556const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
5657const CHAT_ATTACHMENT_ID_MAX_CHARS = 128;
57
5858// Correlation id is command id by design in this model.
5959export const CorrelationId = CommandId;
6060export type CorrelationId = typeof CorrelationId.Type;
......@@ -349,7 +349,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
349349 }),
350350 provider: Schema.optional(ProviderKind),
351351 model: Schema.optional(TrimmedNonEmptyString),
352 effort: Schema.optional(TrimmedNonEmptyString),
352 modelOptions: Schema.optional(ProviderModelOptions),
353353 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
354354 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
355355 interactionMode: ProviderInteractionMode.pipe(
......@@ -370,7 +370,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
370370 }),
371371 provider: Schema.optional(ProviderKind),
372372 model: Schema.optional(TrimmedNonEmptyString),
373 effort: Schema.optional(TrimmedNonEmptyString),
373 modelOptions: Schema.optional(ProviderModelOptions),
374374 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
375375 runtimeMode: RuntimeMode,
376376 interactionMode: ProviderInteractionMode,
......@@ -649,7 +649,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
649649 messageId: MessageId,
650650 provider: Schema.optional(ProviderKind),
651651 model: Schema.optional(TrimmedNonEmptyString),
652 effort: Schema.optional(TrimmedNonEmptyString),
652 modelOptions: Schema.optional(ProviderModelOptions),
653653 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
654654 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
655655 interactionMode: ProviderInteractionMode.pipe(
packages/contracts/src/provider.test.ts+39-1
......@@ -1,16 +1,24 @@
11import { describe, expect, it } from "vitest";
22import { Schema } from "effect";
33
4import { ProviderSessionStartInput } from "./provider";
4import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider";
55
66const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);
7const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput);
78
89describe("ProviderSessionStartInput", () => {
910 it("accepts codex-compatible payloads", () => {
1011 const parsed = decodeProviderSessionStartInput({
12 threadId: "thread-1",
1113 provider: "codex",
1214 cwd: "/tmp/workspace",
1315 model: "gpt-5.3-codex",
16 modelOptions: {
17 codex: {
18 reasoningEffort: "high",
19 fastMode: true,
20 },
21 },
1422 runtimeMode: "full-access",
1523 providerOptions: {
1624 codex: {
......@@ -20,6 +28,8 @@ describe("ProviderSessionStartInput", () => {
2028 },
2129 });
2230 expect(parsed.runtimeMode).toBe("full-access");
31 expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("high");
32 expect(parsed.modelOptions?.codex?.fastMode).toBe(true);
2333 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");
2434 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");
2535 });
......@@ -27,6 +37,7 @@ describe("ProviderSessionStartInput", () => {
2737 it("rejects payloads without runtime mode", () => {
2838 expect(() =>
2939 decodeProviderSessionStartInput({
40 threadId: "thread-1",
3041 provider: "codex",
3142 }),
3243 ).toThrow();
......@@ -34,6 +45,7 @@ describe("ProviderSessionStartInput", () => {
3445
3546 it("accepts claude runtime knobs", () => {
3647 const parsed = decodeProviderSessionStartInput({
48 threadId: "thread-1",
3749 provider: "claudeCode",
3850 cwd: "/tmp/workspace",
3951 model: "claude-sonnet-4-6",
......@@ -55,9 +67,15 @@ describe("ProviderSessionStartInput", () => {
5567
5668 it("accepts cursor provider payloads", () => {
5769 const parsed = decodeProviderSessionStartInput({
70 threadId: "thread-1",
5871 provider: "cursor",
5972 cwd: "/tmp/workspace",
6073 model: "composer-1.5",
74 modelOptions: {
75 cursor: {
76 thinking: true,
77 },
78 },
6179 providerOptions: {
6280 cursor: {
6381 binaryPath: "/usr/local/bin/agent",
......@@ -67,7 +85,27 @@ describe("ProviderSessionStartInput", () => {
6785 });
6886 expect(parsed.provider).toBe("cursor");
6987 expect(parsed.model).toBe("composer-1.5");
88 expect(parsed.modelOptions?.cursor?.thinking).toBe(true);
7089 expect(parsed.providerOptions?.cursor?.binaryPath).toBe("/usr/local/bin/agent");
7190 expect(parsed.runtimeMode).toBe("approval-required");
7291 });
7392});
93
94describe("ProviderSendTurnInput", () => {
95 it("accepts provider-scoped model options", () => {
96 const parsed = decodeProviderSendTurnInput({
97 threadId: "thread-1",
98 model: "gpt-5.3-codex",
99 modelOptions: {
100 codex: {
101 reasoningEffort: "xhigh",
102 fastMode: true,
103 },
104 },
105 });
106
107 expect(parsed.model).toBe("gpt-5.3-codex");
108 expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh");
109 expect(parsed.modelOptions?.codex?.fastMode).toBe(true);
110 });
111});
packages/contracts/src/provider.ts+3-2
......@@ -1,5 +1,6 @@
11import { Schema } from "effect";
22import { TrimmedNonEmptyString } from "./baseSchemas";
3import { ProviderModelOptions } from "./model";
34import {
45 ApprovalRequestId,
56 EventId,
......@@ -22,7 +23,6 @@ import {
2223} from "./orchestration";
2324
2425const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
25
2626const ProviderSessionStatus = Schema.Literals([
2727 "connecting",
2828 "ready",
......@@ -72,6 +72,7 @@ export const ProviderSessionStartInput = Schema.Struct({
7272 provider: Schema.optional(ProviderKind),
7373 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
7474 model: Schema.optional(TrimmedNonEmptyStringSchema),
75 modelOptions: Schema.optional(ProviderModelOptions),
7576 resumeCursor: Schema.optional(Schema.Unknown),
7677 providerOptions: Schema.optional(ProviderStartOptions),
7778 runtimeMode: RuntimeMode,
......@@ -87,7 +88,7 @@ export const ProviderSendTurnInput = Schema.Struct({
8788 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
8889 ),
8990 model: Schema.optional(TrimmedNonEmptyStringSchema),
90 effort: Schema.optional(TrimmedNonEmptyStringSchema),
91 modelOptions: Schema.optional(ProviderModelOptions),
9192 interactionMode: Schema.optional(ProviderInteractionMode),
9293});
9394export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
packages/shared/package.json+5
......@@ -4,6 +4,10 @@
44 "private": true,
55 "type": "module",
66 "exports": {
7 "./model": {
8 "types": "./src/model.ts",
9 "import": "./src/model.ts"
10 },
711 "./git": {
812 "types": "./src/git.ts",
913 "import": "./src/git.ts"
......@@ -23,6 +27,7 @@
2327 "test": "vitest run"
2428 },
2529 "dependencies": {
30 "@t3tools/contracts": "workspace:*",
2631 "effect": "catalog:"
2732 },
2833 "devDependencies": {
packages/shared/src/model.test.ts created+180
......@@ -0,0 +1,180 @@
1import { describe, expect, it } from "vitest";
2import {
3 CURSOR_MODEL_FAMILY_OPTIONS,
4 CURSOR_REASONING_OPTIONS,
5 DEFAULT_MODEL,
6 DEFAULT_MODEL_BY_PROVIDER,
7 DEFAULT_REASONING_EFFORT_BY_PROVIDER,
8 MODEL_OPTIONS,
9 MODEL_OPTIONS_BY_PROVIDER,
10 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
11} from "../../contracts/src";
12
13import {
14 getDefaultModel,
15 getDefaultReasoningEffort,
16 getCursorModelFamilyOptions,
17 getModelOptions,
18 getReasoningEffortOptions,
19 normalizeModelSlug,
20 parseCursorModelSelection,
21 resolveCursorModelFromSelection,
22 resolveModelSlug,
23 resolveModelSlugForProvider,
24} from "./model";
25
26describe("normalizeModelSlug", () => {
27 it("maps known aliases to canonical slugs", () => {
28 expect(normalizeModelSlug("5.3")).toBe("gpt-5.3-codex");
29 expect(normalizeModelSlug("gpt-5.3")).toBe("gpt-5.3-codex");
30 });
31
32 it("returns null for empty or missing values", () => {
33 expect(normalizeModelSlug("")).toBeNull();
34 expect(normalizeModelSlug(" ")).toBeNull();
35 expect(normalizeModelSlug(null)).toBeNull();
36 expect(normalizeModelSlug(undefined)).toBeNull();
37 });
38
39 it("preserves non-aliased model slugs", () => {
40 expect(normalizeModelSlug("gpt-5.2")).toBe("gpt-5.2");
41 expect(normalizeModelSlug("gpt-5.2-codex")).toBe("gpt-5.2-codex");
42 });
43
44 it("uses provider-specific aliases", () => {
45 expect(normalizeModelSlug("sonnet", "claudeCode")).toBe("claude-sonnet-4-6");
46 expect(normalizeModelSlug("opus-4.6", "claudeCode")).toBe("claude-opus-4-6");
47 expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeCode")).toBe(
48 "claude-haiku-4-5",
49 );
50 expect(normalizeModelSlug("composer", "cursor")).toBe("composer-1.5");
51 expect(normalizeModelSlug("gpt-5.3-codex-spark", "cursor")).toBe(
52 "gpt-5.3-codex-spark-preview",
53 );
54 expect(normalizeModelSlug("gemini-3.1", "cursor")).toBe("gemini-3.1");
55 expect(normalizeModelSlug("claude-4.6-sonnet-thinking", "cursor")).toBe(
56 "sonnet-4.6-thinking",
57 );
58 });
59});
60
61describe("resolveModelSlug", () => {
62 it("returns default only when the model is missing", () => {
63 expect(resolveModelSlug(undefined)).toBe(DEFAULT_MODEL);
64 expect(resolveModelSlug(null)).toBe(DEFAULT_MODEL);
65 });
66
67 it("preserves unknown custom models", () => {
68 expect(resolveModelSlug("gpt-4.1")).toBe(DEFAULT_MODEL);
69 expect(resolveModelSlug("custom/internal-model")).toBe(DEFAULT_MODEL);
70 });
71
72 it("resolves only supported model options", () => {
73 for (const model of MODEL_OPTIONS) {
74 expect(resolveModelSlug(model.slug)).toBe(model.slug);
75 }
76 });
77
78 it("supports provider-aware resolution", () => {
79 expect(resolveModelSlugForProvider("claudeCode", undefined)).toBe(
80 DEFAULT_MODEL_BY_PROVIDER.claudeCode,
81 );
82 expect(resolveModelSlugForProvider("claudeCode", "sonnet")).toBe("claude-sonnet-4-6");
83 expect(resolveModelSlugForProvider("claudeCode", "gpt-5.3-codex")).toBe(
84 DEFAULT_MODEL_BY_PROVIDER.claudeCode,
85 );
86 expect(resolveModelSlugForProvider("cursor", undefined)).toBe(
87 DEFAULT_MODEL_BY_PROVIDER.cursor,
88 );
89 expect(resolveModelSlugForProvider("cursor", "composer")).toBe("composer-1.5");
90 expect(resolveModelSlugForProvider("cursor", "gpt-5.3-codex-high-fast")).toBe(
91 "gpt-5.3-codex-high-fast",
92 );
93 expect(resolveModelSlugForProvider("cursor", "claude-sonnet-4-6")).toBe(
94 DEFAULT_MODEL_BY_PROVIDER.cursor,
95 );
96 });
97
98 it("keeps codex defaults for backward compatibility", () => {
99 expect(getDefaultModel()).toBe(DEFAULT_MODEL);
100 expect(getModelOptions()).toEqual(MODEL_OPTIONS);
101 expect(getModelOptions("claudeCode")).toEqual(MODEL_OPTIONS_BY_PROVIDER.claudeCode);
102 expect(getModelOptions("cursor")).toEqual(MODEL_OPTIONS_BY_PROVIDER.cursor);
103 expect(getCursorModelFamilyOptions()).toEqual(CURSOR_MODEL_FAMILY_OPTIONS);
104 });
105});
106
107describe("cursor model selection", () => {
108 it("includes the expected cursor reasoning levels and families", () => {
109 expect(CURSOR_REASONING_OPTIONS).toEqual(["low", "normal", "high", "xhigh"]);
110 expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("gpt-5.3-codex");
111 expect(getCursorModelFamilyOptions().map((option) => option.slug)).toContain("opus-4.6");
112 });
113
114 it("parses codex reasoning and fast mode variants", () => {
115 expect(parseCursorModelSelection("gpt-5.3-codex-high-fast")).toEqual({
116 family: "gpt-5.3-codex",
117 reasoning: "high",
118 fast: true,
119 thinking: false,
120 });
121 expect(parseCursorModelSelection("gpt-5.2-codex")).toEqual(
122 parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor),
123 );
124 });
125
126 it("parses and resolves thinking variants", () => {
127 expect(parseCursorModelSelection("sonnet-4.6-thinking")).toEqual({
128 family: "sonnet-4.6",
129 reasoning: "normal",
130 fast: false,
131 thinking: true,
132 });
133 expect(
134 resolveCursorModelFromSelection({
135 family: "sonnet-4.6",
136 thinking: true,
137 }),
138 ).toBe("sonnet-4.6-thinking");
139 });
140
141 it("resolves codex family selections into concrete model ids", () => {
142 expect(
143 resolveCursorModelFromSelection({
144 family: "gpt-5.3-codex",
145 reasoning: "xhigh",
146 fast: true,
147 }),
148 ).toBe("gpt-5.3-codex-xhigh-fast");
149 });
150});
151
152describe("getReasoningEffortOptions", () => {
153 it("returns codex reasoning options for codex", () => {
154 expect(getReasoningEffortOptions("codex")).toEqual(
155 REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex,
156 );
157 });
158
159 it("returns no reasoning options for claudeCode", () => {
160 expect(getReasoningEffortOptions("claudeCode")).toEqual([]);
161 });
162
163 it("returns no reasoning options for cursor", () => {
164 expect(getReasoningEffortOptions("cursor")).toEqual([]);
165 });
166});
167
168describe("getDefaultReasoningEffort", () => {
169 it("returns provider-scoped defaults", () => {
170 expect(getDefaultReasoningEffort("codex")).toBe(
171 DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex,
172 );
173 expect(getDefaultReasoningEffort("claudeCode")).toBe(
174 DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeCode,
175 );
176 expect(getDefaultReasoningEffort("cursor")).toBe(
177 DEFAULT_REASONING_EFFORT_BY_PROVIDER.cursor,
178 );
179 });
180});
packages/shared/src/model.ts created+280
......@@ -0,0 +1,280 @@
1import {
2 CODEX_REASONING_EFFORT_OPTIONS,
3 CURSOR_MODEL_FAMILY_OPTIONS,
4 CURSOR_REASONING_OPTIONS,
5 DEFAULT_MODEL_BY_PROVIDER,
6 DEFAULT_REASONING_EFFORT_BY_PROVIDER,
7 MODEL_OPTIONS_BY_PROVIDER,
8 MODEL_SLUG_ALIASES_BY_PROVIDER,
9 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
10 type CodexReasoningEffort,
11 type CursorModelFamily,
12 type CursorModelSlug,
13 type CursorReasoningOption,
14 type ModelSlug,
15 type ProviderKind,
16} from "../../contracts/src";
17
18type CursorModelCapability = {
19 readonly supportsReasoning: boolean;
20 readonly supportsFast: boolean;
21 readonly supportsThinking: boolean;
22 readonly defaultReasoning: CursorReasoningOption;
23 readonly defaultThinking: boolean;
24};
25
26const CURSOR_MODEL_CAPABILITY_BY_FAMILY: Record<CursorModelFamily, CursorModelCapability> = {
27 auto: {
28 supportsReasoning: false,
29 supportsFast: false,
30 supportsThinking: false,
31 defaultReasoning: "normal",
32 defaultThinking: false,
33 },
34 "composer-1.5": {
35 supportsReasoning: false,
36 supportsFast: false,
37 supportsThinking: false,
38 defaultReasoning: "normal",
39 defaultThinking: false,
40 },
41 "composer-1": {
42 supportsReasoning: false,
43 supportsFast: false,
44 supportsThinking: false,
45 defaultReasoning: "normal",
46 defaultThinking: false,
47 },
48 "gpt-5.3-codex": {
49 supportsReasoning: true,
50 supportsFast: true,
51 supportsThinking: false,
52 defaultReasoning: "normal",
53 defaultThinking: false,
54 },
55 "gpt-5.3-codex-spark-preview": {
56 supportsReasoning: false,
57 supportsFast: false,
58 supportsThinking: false,
59 defaultReasoning: "normal",
60 defaultThinking: false,
61 },
62 "opus-4.6": {
63 supportsReasoning: false,
64 supportsFast: false,
65 supportsThinking: true,
66 defaultReasoning: "normal",
67 defaultThinking: true,
68 },
69 "opus-4.5": {
70 supportsReasoning: false,
71 supportsFast: false,
72 supportsThinking: true,
73 defaultReasoning: "normal",
74 defaultThinking: true,
75 },
76 "sonnet-4.6": {
77 supportsReasoning: false,
78 supportsFast: false,
79 supportsThinking: true,
80 defaultReasoning: "normal",
81 defaultThinking: true,
82 },
83 "gemini-3.1-pro": {
84 supportsReasoning: false,
85 supportsFast: false,
86 supportsThinking: false,
87 defaultReasoning: "normal",
88 defaultThinking: false,
89 },
90};
91
92const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = {
93 claudeCode: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeCode.map((option) => option.slug)),
94 codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)),
95 cursor: new Set(MODEL_OPTIONS_BY_PROVIDER.cursor.map((option) => option.slug)),
96};
97
98const CURSOR_MODEL_FAMILY_SET = new Set<CursorModelFamily>(
99 CURSOR_MODEL_FAMILY_OPTIONS.map((option) => option.slug),
100);
101
102export interface CursorModelSelection {
103 readonly family: CursorModelFamily;
104 readonly reasoning: CursorReasoningOption;
105 readonly fast: boolean;
106 readonly thinking: boolean;
107}
108
109export function getModelOptions(provider: ProviderKind = "codex") {
110 return MODEL_OPTIONS_BY_PROVIDER[provider];
111}
112
113export function getCursorModelFamilyOptions() {
114 return CURSOR_MODEL_FAMILY_OPTIONS;
115}
116
117export function getCursorModelCapabilities(family: CursorModelFamily) {
118 return CURSOR_MODEL_CAPABILITY_BY_FAMILY[family];
119}
120
121function fallbackCursorModelFamily(): CursorModelFamily {
122 const fallback = parseCursorModelSelection(DEFAULT_MODEL_BY_PROVIDER.cursor);
123 return fallback.family;
124}
125
126function resolveCursorModelFamily(model: string | null | undefined): CursorModelFamily {
127 const normalized = normalizeModelSlug(model, "cursor");
128 if (!normalized) {
129 return fallbackCursorModelFamily();
130 }
131
132 if (
133 normalized === "gpt-5.3-codex" ||
134 normalized === "gpt-5.3-codex-fast" ||
135 normalized === "gpt-5.3-codex-low" ||
136 normalized === "gpt-5.3-codex-low-fast" ||
137 normalized === "gpt-5.3-codex-high" ||
138 normalized === "gpt-5.3-codex-high-fast" ||
139 normalized === "gpt-5.3-codex-xhigh" ||
140 normalized === "gpt-5.3-codex-xhigh-fast"
141 ) {
142 return "gpt-5.3-codex";
143 }
144
145 if (normalized === "sonnet-4.6-thinking") {
146 return "sonnet-4.6";
147 }
148 if (normalized === "opus-4.6-thinking") {
149 return "opus-4.6";
150 }
151 if (normalized === "opus-4.5-thinking") {
152 return "opus-4.5";
153 }
154
155 return CURSOR_MODEL_FAMILY_SET.has(normalized as CursorModelFamily)
156 ? (normalized as CursorModelFamily)
157 : fallbackCursorModelFamily();
158}
159
160function resolveCursorReasoning(model: CursorModelSlug): CursorReasoningOption {
161 if (model.includes("-xhigh")) return "xhigh";
162 if (model.includes("-high")) return "high";
163 if (model.includes("-low")) return "low";
164 return "normal";
165}
166
167export function parseCursorModelSelection(model: string | null | undefined): CursorModelSelection {
168 const family = resolveCursorModelFamily(model);
169 const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family];
170 const normalized = resolveModelSlugForProvider("cursor", model) as CursorModelSlug;
171
172 if (capability.supportsReasoning) {
173 return {
174 family,
175 reasoning: resolveCursorReasoning(normalized),
176 fast: normalized.endsWith("-fast"),
177 thinking: false,
178 };
179 }
180
181 if (capability.supportsThinking) {
182 return {
183 family,
184 reasoning: capability.defaultReasoning,
185 fast: false,
186 thinking: normalized.endsWith("-thinking"),
187 };
188 }
189
190 return {
191 family,
192 reasoning: capability.defaultReasoning,
193 fast: false,
194 thinking: capability.defaultThinking,
195 };
196}
197
198export function resolveCursorModelFromSelection(input: {
199 readonly family: CursorModelFamily;
200 readonly reasoning?: CursorReasoningOption | null;
201 readonly fast?: boolean;
202 readonly thinking?: boolean;
203}): CursorModelSlug {
204 const family = resolveCursorModelFamily(input.family);
205 const capability = CURSOR_MODEL_CAPABILITY_BY_FAMILY[family];
206
207 if (capability.supportsReasoning) {
208 const reasoning = CURSOR_REASONING_OPTIONS.includes(input.reasoning ?? "normal")
209 ? (input.reasoning ?? "normal")
210 : capability.defaultReasoning;
211 const reasoningSuffix = reasoning === "normal" ? "" : `-${reasoning}`;
212 const fastSuffix = input.fast ? "-fast" : "";
213 const candidate = `${family}${reasoningSuffix}${fastSuffix}`;
214 return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug;
215 }
216
217 if (capability.supportsThinking) {
218 const candidate = input.thinking ? `${family}-thinking` : family;
219 return resolveModelSlugForProvider("cursor", candidate) as CursorModelSlug;
220 }
221
222 return resolveModelSlugForProvider("cursor", family) as CursorModelSlug;
223}
224
225export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
226 return DEFAULT_MODEL_BY_PROVIDER[provider];
227}
228
229export function normalizeModelSlug(
230 model: string | null | undefined,
231 provider: ProviderKind = "codex",
232): ModelSlug | null {
233 if (typeof model !== "string") {
234 return null;
235 }
236
237 const trimmed = model.trim();
238 if (!trimmed) {
239 return null;
240 }
241
242 return MODEL_SLUG_ALIASES_BY_PROVIDER[provider][trimmed] ?? (trimmed as ModelSlug);
243}
244
245export function resolveModelSlug(
246 model: string | null | undefined,
247 provider: ProviderKind = "codex",
248): ModelSlug {
249 const normalized = normalizeModelSlug(model, provider);
250 if (!normalized) {
251 return DEFAULT_MODEL_BY_PROVIDER[provider];
252 }
253
254 return MODEL_SLUG_SET_BY_PROVIDER[provider].has(normalized)
255 ? normalized
256 : DEFAULT_MODEL_BY_PROVIDER[provider];
257}
258
259export function resolveModelSlugForProvider(
260 provider: ProviderKind,
261 model: string | null | undefined,
262): ModelSlug {
263 return resolveModelSlug(model, provider);
264}
265
266export function getReasoningEffortOptions(
267 provider: ProviderKind = "codex",
268): ReadonlyArray<CodexReasoningEffort> {
269 return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider];
270}
271
272export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort;
273export function getDefaultReasoningEffort(provider: ProviderKind): CodexReasoningEffort | null;
274export function getDefaultReasoningEffort(
275 provider: ProviderKind = "codex",
276): CodexReasoningEffort | null {
277 return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider];
278}
279
280export { CODEX_REASONING_EFFORT_OPTIONS };