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 @@...@@ -3,6 +3,7 @@
3## Task Completion Requirements3## Task Completion Requirements
44
5- Both `bun lint` and `bun typecheck` must pass before considering tasks completed.5- 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
7## Project Snapshot8## Project Snapshot
89
apps/server/src/codexAppServerManager.test.ts+8-2
...@@ -259,6 +259,7 @@ describe("sendTurn", () => {...@@ -259,6 +259,7 @@ describe("sendTurn", () => {
259 ],259 ],
260 model: "gpt-5.3",260 model: "gpt-5.3",
261 effort: "high",261 effort: "high",
262 serviceTier: "fast",
262 });263 });
263264
264 expect(result).toEqual({265 expect(result).toEqual({
...@@ -282,6 +283,7 @@ describe("sendTurn", () => {...@@ -282,6 +283,7 @@ describe("sendTurn", () => {
282 ],283 ],
283 model: "gpt-5.3-codex",284 model: "gpt-5.3-codex",
284 effort: "high",285 effort: "high",
286 serviceTier: "fast",
285 });287 });
286 expect(updateSession).toHaveBeenCalledWith(context, {288 expect(updateSession).toHaveBeenCalledWith(context, {
287 status: "running",289 status: "running",
...@@ -499,7 +501,9 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"...@@ -499,7 +501,9 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
499 runtimeMode: "full-access",501 runtimeMode: "full-access",
500 providerOptions: {502 providerOptions: {
501 codex: {503 codex: {
502 binaryPath: process.env.CODEX_BINARY_PATH,504 ...(process.env.CODEX_BINARY_PATH
505 ? { binaryPath: process.env.CODEX_BINARY_PATH }
506 : {}),
503 ...(process.env.CODEX_HOME_PATH507 ...(process.env.CODEX_HOME_PATH
504 ? { homePath: process.env.CODEX_HOME_PATH }508 ? { homePath: process.env.CODEX_HOME_PATH }
505 : {}),509 : {}),
...@@ -533,7 +537,9 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"...@@ -533,7 +537,9 @@ describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume"
533 resumeCursor: firstSession.resumeCursor,537 resumeCursor: firstSession.resumeCursor,
534 providerOptions: {538 providerOptions: {
535 codex: {539 codex: {
536 binaryPath: process.env.CODEX_BINARY_PATH,540 ...(process.env.CODEX_BINARY_PATH
541 ? { binaryPath: process.env.CODEX_BINARY_PATH }
542 : {}),
537 ...(process.env.CODEX_HOME_PATH543 ...(process.env.CODEX_HOME_PATH
538 ? { homePath: process.env.CODEX_HOME_PATH }544 ? { homePath: process.env.CODEX_HOME_PATH }
539 : {}),545 : {}),
apps/server/src/codexAppServerManager.ts+29-7
...@@ -11,13 +11,15 @@ import {...@@ -11,13 +11,15 @@ import {
11 type ProviderUserInputAnswers,11 type ProviderUserInputAnswers,
12 ThreadId,12 ThreadId,
13 TurnId,13 TurnId,
14 normalizeModelSlug,
15 type ProviderApprovalDecision,14 type ProviderApprovalDecision,
16 type ProviderEvent,15 type ProviderEvent,
17 type ProviderSession,16 type ProviderSession,
18 type ProviderSessionStartInput,17 type ProviderSessionStartInput,
19 type ProviderTurnStartResult,18 type ProviderTurnStartResult,
19 RuntimeMode,
20 ProviderInteractionMode,
20} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import { normalizeModelSlug } from "@t3tools/shared/model";
21import { Effect, ServiceMap } from "effect";23import { Effect, ServiceMap } from "effect";
2224
23type PendingRequestKey = string;25type PendingRequestKey = string;
...@@ -90,7 +92,19 @@ export interface CodexAppServerSendTurnInput {...@@ -90,7 +92,19 @@ export interface CodexAppServerSendTurnInput {
90 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;92 readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>;
91 readonly model?: string;93 readonly model?: string;
92 readonly effort?: string;94 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;
94}108}
95109
96export interface CodexThreadTurnSnapshot {110export interface CodexThreadTurnSnapshot {
...@@ -240,7 +254,7 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o...@@ -240,7 +254,7 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o
240Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec.254Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec.
241</collaboration_mode>`;255</collaboration_mode>`;
242256
243function mapCodexRuntimeMode(runtimeMode: "approval-required" | "full-access"): {257function mapCodexRuntimeMode(runtimeMode: RuntimeMode): {
244 readonly approvalPolicy: "on-request" | "never";258 readonly approvalPolicy: "on-request" | "never";
245 readonly sandbox: "workspace-write" | "danger-full-access";259 readonly sandbox: "workspace-write" | "danger-full-access";
246} {260} {
...@@ -360,7 +374,10 @@ function toCodexUserInputAnswers(...@@ -360,7 +374,10 @@ function toCodexUserInputAnswers(
360 answers: ProviderUserInputAnswers,374 answers: ProviderUserInputAnswers,
361): Record<string, CodexUserInputAnswer> {375): Record<string, CodexUserInputAnswer> {
362 return Object.fromEntries(376 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 ]),
364 );381 );
365}382}
366383
...@@ -408,7 +425,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -408,7 +425,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
408 this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise;425 this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise;
409 }426 }
410427
411 async startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {428 async startSession(input: CodexAppServerStartSessionInput): Promise<ProviderSession> {
412 const threadId = input.threadId;429 const threadId = input.threadId;
413 const now = new Date().toISOString();430 const now = new Date().toISOString();
414 let context: CodexSessionContext | undefined;431 let context: CodexSessionContext | undefined;
...@@ -465,6 +482,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -465,6 +482,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
465 const sessionOverrides = {482 const sessionOverrides = {
466 model: normalizedModel ?? null,483 model: normalizedModel ?? null,
467 cwd: input.cwd ?? null,484 cwd: input.cwd ?? null,
485 ...(input.serviceTier !== undefined ? { serviceTier: input.serviceTier } : {}),
468 ...mapCodexRuntimeMode(input.runtimeMode ?? "full-access"),486 ...mapCodexRuntimeMode(input.runtimeMode ?? "full-access"),
469 };487 };
470488
...@@ -625,6 +643,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -625,6 +643,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
625 >;643 >;
626 model?: string;644 model?: string;
627 effort?: string;645 effort?: string;
646 serviceTier?: string;
628 collaborationMode?: {647 collaborationMode?: {
629 mode: "plan";648 mode: "plan";
630 settings: {649 settings: {
...@@ -644,6 +663,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -644,6 +663,9 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
644 if (input.effort) {663 if (input.effort) {
645 turnStartParams.effort = input.effort;664 turnStartParams.effort = input.effort;
646 }665 }
666 if (input.serviceTier) {
667 turnStartParams.serviceTier = input.serviceTier;
668 }
647 const collaborationMode = buildCodexCollaborationMode({669 const collaborationMode = buildCodexCollaborationMode({
648 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),670 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
649 ...(normalizedModel !== undefined ? { model: normalizedModel } : {}),671 ...(normalizedModel !== undefined ? { model: normalizedModel } : {}),
...@@ -1345,7 +1367,7 @@ function normalizeProviderThreadId(value: string | undefined): string | undefine...@@ -1345,7 +1367,7 @@ function normalizeProviderThreadId(value: string | undefined): string | undefine
1345 return brandIfNonEmpty(value, (normalized) => normalized);1367 return brandIfNonEmpty(value, (normalized) => normalized);
1346}1368}
13471369
1348function readCodexProviderOptions(input: ProviderSessionStartInput): {1370function readCodexProviderOptions(input: CodexAppServerStartSessionInput): {
1349 readonly binaryPath?: string;1371 readonly binaryPath?: string;
1350 readonly homePath?: string;1372 readonly homePath?: string;
1351} {1373} {
...@@ -1367,7 +1389,7 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined {...@@ -1367,7 +1389,7 @@ function readResumeCursorThreadId(resumeCursor: unknown): string | undefined {
1367 return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined;1389 return typeof rawThreadId === "string" ? normalizeProviderThreadId(rawThreadId) : undefined;
1368}1390}
13691391
1370function readResumeThreadId(input: ProviderSessionStartInput): string | undefined {1392function readResumeThreadId(input: CodexAppServerStartSessionInput): string | undefined {
1371 return readResumeCursorThreadId(input.resumeCursor);1393 return readResumeCursorThreadId(input.resumeCursor);
1372}1394}
13731395
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+52
...@@ -295,6 +295,58 @@ describe("ProviderCommandReactor", () => {...@@ -295,6 +295,58 @@ describe("ProviderCommandReactor", () => {
295 expect(thread?.session?.runtimeMode).toBe("approval-required");295 expect(thread?.session?.runtimeMode).toBe("approval-required");
296 });296 });
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
298 it("forwards plan interaction mode to the provider turn request", async () => {350 it("forwards plan interaction mode to the provider turn request", async () => {
299 const harness = await createHarness();351 const harness = await createHarness();
300 const now = new Date().toISOString();352 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+7-3
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 CommandId,3 CommandId,
4 EventId,4 EventId,
5 type OrchestrationEvent,5 type OrchestrationEvent,
6 type ProviderModelOptions,
6 type ProviderKind,7 type ProviderKind,
7 type OrchestrationSession,8 type OrchestrationSession,
8 ThreadId,9 ThreadId,
...@@ -199,6 +200,7 @@ const make = Effect.gen(function* () {...@@ -199,6 +200,7 @@ const make = Effect.gen(function* () {
199 options?: {200 options?: {
200 readonly provider?: ProviderKind;201 readonly provider?: ProviderKind;
201 readonly model?: string;202 readonly model?: string;
203 readonly modelOptions?: ProviderModelOptions;
202 },204 },
203 ) {205 ) {
204 const readModel = yield* orchestrationEngine.getReadModel();206 const readModel = yield* orchestrationEngine.getReadModel();
...@@ -237,6 +239,7 @@ const make = Effect.gen(function* () {...@@ -237,6 +239,7 @@ const make = Effect.gen(function* () {
237 : {}),239 : {}),
238 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),240 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
239 ...(desiredModel ? { model: desiredModel } : {}),241 ...(desiredModel ? { model: desiredModel } : {}),
242 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),
240 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),243 ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
241 runtimeMode: desiredRuntimeMode,244 runtimeMode: desiredRuntimeMode,
242 });245 });
...@@ -321,7 +324,7 @@ const make = Effect.gen(function* () {...@@ -321,7 +324,7 @@ const make = Effect.gen(function* () {
321 readonly attachments?: ReadonlyArray<ChatAttachment>;324 readonly attachments?: ReadonlyArray<ChatAttachment>;
322 readonly provider?: ProviderKind;325 readonly provider?: ProviderKind;
323 readonly model?: string;326 readonly model?: string;
324 readonly effort?: string;327 readonly modelOptions?: ProviderModelOptions;
325 readonly interactionMode?: "default" | "plan";328 readonly interactionMode?: "default" | "plan";
326 readonly createdAt: string;329 readonly createdAt: string;
327 }) {330 }) {
...@@ -332,6 +335,7 @@ const make = Effect.gen(function* () {...@@ -332,6 +335,7 @@ const make = Effect.gen(function* () {
332 yield* ensureSessionForThread(input.threadId, input.createdAt, {335 yield* ensureSessionForThread(input.threadId, input.createdAt, {
333 ...(input.provider !== undefined ? { provider: input.provider } : {}),336 ...(input.provider !== undefined ? { provider: input.provider } : {}),
334 ...(input.model !== undefined ? { model: input.model } : {}),337 ...(input.model !== undefined ? { model: input.model } : {}),
338 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
335 });339 });
336 const normalizedInput = toNonEmptyProviderInput(input.messageText);340 const normalizedInput = toNonEmptyProviderInput(input.messageText);
337 const normalizedAttachments = input.attachments ?? [];341 const normalizedAttachments = input.attachments ?? [];
...@@ -350,7 +354,7 @@ const make = Effect.gen(function* () {...@@ -350,7 +354,7 @@ const make = Effect.gen(function* () {
350 ...(normalizedInput ? { input: normalizedInput } : {}),354 ...(normalizedInput ? { input: normalizedInput } : {}),
351 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),355 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
352 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),356 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
353 ...(input.effort !== undefined ? { effort: input.effort } : {}),357 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
354 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),358 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
355 });359 });
356 });360 });
...@@ -464,7 +468,7 @@ const make = Effect.gen(function* () {...@@ -464,7 +468,7 @@ const make = Effect.gen(function* () {
464 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),468 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
465 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),469 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
466 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),470 ...(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 } : {}),
468 interactionMode: event.payload.interactionMode,472 interactionMode: event.payload.interactionMode,
469 createdAt: event.payload.createdAt,473 createdAt: event.payload.createdAt,
470 });474 });
apps/server/src/orchestration/decider.projectScripts.test.ts+16-6
...@@ -159,9 +159,14 @@ describe("decider project scripts", () => {...@@ -159,9 +159,14 @@ describe("decider project scripts", () => {
159 text: "hello",159 text: "hello",
160 attachments: [],160 attachments: [],
161 },161 },
162 provider: "claudeCode",162 provider: "codex",
163 model: "gpt-5",163 model: "gpt-5.3-codex",
164 effort: "high",164 modelOptions: {
165 codex: {
166 reasoningEffort: "high",
167 fastMode: true,
168 },
169 },
165 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,170 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
166 runtimeMode: "approval-required",171 runtimeMode: "approval-required",
167 createdAt: now,172 createdAt: now,
...@@ -184,9 +189,14 @@ describe("decider project scripts", () => {...@@ -184,9 +189,14 @@ describe("decider project scripts", () => {
184 expect(turnStartEvent.payload).toMatchObject({189 expect(turnStartEvent.payload).toMatchObject({
185 threadId: ThreadId.makeUnsafe("thread-1"),190 threadId: ThreadId.makeUnsafe("thread-1"),
186 messageId: asMessageId("message-user-1"),191 messageId: asMessageId("message-user-1"),
187 provider: "claudeCode",192 provider: "codex",
188 model: "gpt-5",193 model: "gpt-5.3-codex",
189 effort: "high",194 modelOptions: {
195 codex: {
196 reasoningEffort: "high",
197 fastMode: true,
198 },
199 },
190 runtimeMode: "approval-required",200 runtimeMode: "approval-required",
191 });201 });
192 });202 });
apps/server/src/orchestration/decider.ts+1-1
...@@ -301,7 +301,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -301,7 +301,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
301 messageId: command.message.messageId,301 messageId: command.message.messageId,
302 ...(command.provider !== undefined ? { provider: command.provider } : {}),302 ...(command.provider !== undefined ? { provider: command.provider } : {}),
303 ...(command.model !== undefined ? { model: command.model } : {}),303 ...(command.model !== undefined ? { model: command.model } : {}),
304 ...(command.effort !== undefined ? { effort: command.effort } : {}),304 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),
305 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,305 assistantDeliveryMode: command.assistantDeliveryMode ?? DEFAULT_ASSISTANT_DELIVERY_MODE,
306 runtimeMode:306 runtimeMode:
307 readModel.threads.find((entry) => entry.id === command.threadId)?.runtimeMode ??307 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) {...@@ -1712,10 +1712,7 @@ function makeClaudeCodeAdapter(options?: ClaudeCodeAdapterLiveOptions) {
1712 createdAt: turnStartedStamp.createdAt,1712 createdAt: turnStartedStamp.createdAt,
1713 threadId: context.session.threadId,1713 threadId: context.session.threadId,
1714 turnId,1714 turnId,
1715 payload: {1715 payload: input.model ? { model: input.model } : {},
1716 ...(input.model ? { model: input.model } : {}),
1717 ...(input.effort ? { effort: input.effort } : {}),
1718 },
1719 providerRefs: {1716 providerRefs: {
1720 providerTurnId: String(turnId),1717 providerTurnId: String(turnId),
1721 },1718 },
apps/server/src/provider/Layers/CodexAdapter.test.ts+132-69
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import assert from "node:assert/strict";
1import {2import {
2 ApprovalRequestId,3 ApprovalRequestId,
3 EventId,4 EventId,
...@@ -5,20 +6,19 @@ import {...@@ -5,20 +6,19 @@ import {
5 type ProviderApprovalDecision,6 type ProviderApprovalDecision,
6 type ProviderEvent,7 type ProviderEvent,
7 type ProviderSession,8 type ProviderSession,
8 type ProviderSessionStartInput,
9 type ProviderTurnStartResult,9 type ProviderTurnStartResult,
10 type ProviderUserInputAnswers,10 type ProviderUserInputAnswers,
11 ThreadId,11 ThreadId,
12 TurnId,12 TurnId,
13} from "@t3tools/contracts";13} from "@t3tools/contracts";
14import * as NodeServices from "@effect/platform-node/NodeServices";14import * as NodeServices from "@effect/platform-node/NodeServices";
15import { afterAll, assert, it, vi } from "@effect/vitest";15import { afterAll, it, vi } from "@effect/vitest";
16import { assertFailure } from "@effect/vitest/utils";
1716
18import { Effect, Fiber, Layer, Option, Stream } from "effect";17import { Effect, Fiber, Layer, Option, Stream } from "effect";
1918
20import {19import {
21 CodexAppServerManager,20 CodexAppServerManager,
21 type CodexAppServerStartSessionInput,
22 type CodexAppServerSendTurnInput,22 type CodexAppServerSendTurnInput,
23} from "../../codexAppServerManager.ts";23} from "../../codexAppServerManager.ts";
24import { ServerConfig } from "../../config.ts";24import { ServerConfig } from "../../config.ts";
...@@ -34,7 +34,7 @@ const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(va...@@ -34,7 +34,7 @@ const asItemId = (value: string): ProviderItemId => ProviderItemId.makeUnsafe(va
3434
35class FakeCodexManager extends CodexAppServerManager {35class FakeCodexManager extends CodexAppServerManager {
36 public startSessionImpl = vi.fn(36 public startSessionImpl = vi.fn(
37 async (input: ProviderSessionStartInput): Promise<ProviderSession> => {37 async (input: CodexAppServerStartSessionInput): Promise<ProviderSession> => {
38 const now = new Date().toISOString();38 const now = new Date().toISOString();
39 return {39 return {
40 provider: "codex",40 provider: "codex",
...@@ -87,7 +87,7 @@ class FakeCodexManager extends CodexAppServerManager {...@@ -87,7 +87,7 @@ class FakeCodexManager extends CodexAppServerManager {
8787
88 public stopAllImpl = vi.fn(() => undefined);88 public stopAllImpl = vi.fn(() => undefined);
8989
90 override startSession(input: ProviderSessionStartInput): Promise<ProviderSession> {90 override startSession(input: CodexAppServerStartSessionInput): Promise<ProviderSession> {
91 return this.startSessionImpl(input);91 return this.startSessionImpl(input);
92 }92 }
9393
...@@ -168,8 +168,9 @@ validationLayer("CodexAdapterLive validation", (it) => {...@@ -168,8 +168,9 @@ validationLayer("CodexAdapterLive validation", (it) => {
168 })168 })
169 .pipe(Effect.result);169 .pipe(Effect.result);
170170
171 assertFailure(171 assert.equal(result._tag, "Failure");
172 result,172 assert.deepStrictEqual(
173 result.failure,
173 new ProviderAdapterValidationError({174 new ProviderAdapterValidationError({
174 provider: "codex",175 provider: "codex",
175 operation: "startSession",176 operation: "startSession",
...@@ -179,6 +180,33 @@ validationLayer("CodexAdapterLive validation", (it) => {...@@ -179,6 +180,33 @@ validationLayer("CodexAdapterLive validation", (it) => {
179 assert.equal(validationManager.startSessionImpl.mock.calls.length, 0);180 assert.equal(validationManager.startSessionImpl.mock.calls.length, 0);
180 }),181 }),
181 );182 );
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 );
182});210});
183211
184const sessionErrorManager = new FakeCodexManager();212const sessionErrorManager = new FakeCodexManager();
...@@ -216,7 +244,37 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {...@@ -216,7 +244,37 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
216 }244 }
217 assert.equal(result.failure.provider, "codex");245 assert.equal(result.failure.provider, "codex");
218 assert.equal(result.failure.threadId, "sess-missing");246 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 });
220 }),278 }),
221 );279 );
222});280});
...@@ -452,69 +510,71 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -452,69 +510,71 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
452 }),510 }),
453 );511 );
454512
455 it.effect("maps requestUserInput requests and answered notifications to canonical user-input events", () =>513 it.effect(
456 Effect.gen(function* () {514 "maps requestUserInput requests and answered notifications to canonical user-input events",
457 const adapter = yield* CodexAdapter;515 () =>
458 const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe(516 Effect.gen(function* () {
459 Effect.forkChild,517 const adapter = yield* CodexAdapter;
460 );518 const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 2)).pipe(
519 Effect.forkChild,
520 );
461521
462 lifecycleManager.emit("event", {522 lifecycleManager.emit("event", {
463 id: asEventId("evt-user-input-requested"),523 id: asEventId("evt-user-input-requested"),
464 kind: "request",524 kind: "request",
465 provider: "codex",525 provider: "codex",
466 threadId: asThreadId("thread-1"),526 threadId: asThreadId("thread-1"),
467 createdAt: new Date().toISOString(),527 createdAt: new Date().toISOString(),
468 method: "item/tool/requestUserInput",528 method: "item/tool/requestUserInput",
469 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),529 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),
470 payload: {530 payload: {
471 questions: [531 questions: [
472 {532 {
473 id: "sandbox_mode",533 id: "sandbox_mode",
474 header: "Sandbox",534 header: "Sandbox",
475 question: "Which mode should be used?",535 question: "Which mode should be used?",
476 options: [536 options: [
477 {537 {
478 label: "workspace-write",538 label: "workspace-write",
479 description: "Allow workspace writes only",539 description: "Allow workspace writes only",
480 },540 },
481 ],541 ],
482 },542 },
483 ],543 ],
484 },544 },
485 } satisfies ProviderEvent);545 } satisfies ProviderEvent);
486 lifecycleManager.emit("event", {546 lifecycleManager.emit("event", {
487 id: asEventId("evt-user-input-resolved"),547 id: asEventId("evt-user-input-resolved"),
488 kind: "notification",548 kind: "notification",
489 provider: "codex",549 provider: "codex",
490 threadId: asThreadId("thread-1"),550 threadId: asThreadId("thread-1"),
491 createdAt: new Date().toISOString(),551 createdAt: new Date().toISOString(),
492 method: "item/tool/requestUserInput/answered",552 method: "item/tool/requestUserInput/answered",
493 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),553 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),
494 payload: {554 payload: {
495 answers: {555 answers: {
496 sandbox_mode: {556 sandbox_mode: {
497 answers: ["workspace-write"],557 answers: ["workspace-write"],
558 },
498 },559 },
499 },560 },
500 },561 } satisfies ProviderEvent);
501 } satisfies ProviderEvent);562
502563 const events = Array.from(yield* Fiber.join(eventsFiber));
503 const events = Array.from(yield* Fiber.join(eventsFiber));564 assert.equal(events[0]?.type, "user-input.requested");
504 assert.equal(events[0]?.type, "user-input.requested");565 if (events[0]?.type === "user-input.requested") {
505 if (events[0]?.type === "user-input.requested") {566 assert.equal(events[0].requestId, "req-user-input-1");
506 assert.equal(events[0].requestId, "req-user-input-1");567 assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode");
507 assert.equal(events[0].payload.questions[0]?.id, "sandbox_mode");568 }
508 }569
509570 assert.equal(events[1]?.type, "user-input.resolved");
510 assert.equal(events[1]?.type, "user-input.resolved");571 if (events[1]?.type === "user-input.resolved") {
511 if (events[1]?.type === "user-input.resolved") {572 assert.equal(events[1].requestId, "req-user-input-1");
512 assert.equal(events[1].requestId, "req-user-input-1");573 assert.deepEqual(events[1].payload.answers, {
513 assert.deepEqual(events[1].payload.answers, {574 sandbox_mode: "workspace-write",
514 sandbox_mode: "workspace-write",575 });
515 });576 }
516 }577 }),
517 }),
518 );578 );
519579
520 it.effect("maps Codex task and reasoning event chunks into canonical runtime events", () =>580 it.effect("maps Codex task and reasoning event chunks into canonical runtime events", () =>
...@@ -636,5 +696,8 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {...@@ -636,5 +696,8 @@ lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
636});696});
637697
638afterAll(() => {698afterAll(() => {
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);
640});703});
apps/server/src/provider/Layers/CodexAdapter.ts+20-3
...@@ -31,7 +31,10 @@ import {...@@ -31,7 +31,10 @@ import {
31 type ProviderAdapterError,31 type ProviderAdapterError,
32} from "../Errors.ts";32} from "../Errors.ts";
33import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";33import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";
34import { CodexAppServerManager } from "../../codexAppServerManager.ts";34import {
35 CodexAppServerManager,
36 type CodexAppServerStartSessionInput,
37} from "../../codexAppServerManager.ts";
35import { resolveAttachmentPath } from "../../attachmentStore.ts";38import { resolveAttachmentPath } from "../../attachmentStore.ts";
36import { ServerConfig } from "../../config.ts";39import { ServerConfig } from "../../config.ts";
37import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";40import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
...@@ -1293,8 +1296,19 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1293,8 +1296,19 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1293 );1296 );
1294 }1297 }
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
1296 return Effect.tryPromise({1310 return Effect.tryPromise({
1297 try: () => manager.startSession(input),1311 try: () => manager.startSession(managerInput),
1298 catch: (cause) =>1312 catch: (cause) =>
1299 new ProviderAdapterProcessError({1313 new ProviderAdapterProcessError({
1300 provider: PROVIDER,1314 provider: PROVIDER,
...@@ -1343,7 +1357,10 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1343,7 +1357,10 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1343 threadId: input.threadId,1357 threadId: input.threadId,
1344 ...(input.input !== undefined ? { input: input.input } : {}),1358 ...(input.input !== undefined ? { input: input.input } : {}),
1345 ...(input.model !== undefined ? { model: input.model } : {}),1359 ...(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" } : {}),
1347 ...(input.interactionMode !== undefined1364 ...(input.interactionMode !== undefined
1348 ? { interactionMode: input.interactionMode }1365 ? { interactionMode: input.interactionMode }
1349 : {}),1366 : {}),
apps/server/src/provider/Layers/CursorAdapter.ts+1-4
...@@ -1316,10 +1316,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {...@@ -1316,10 +1316,7 @@ function makeCursorAdapter(options?: CursorAdapterLiveOptions) {
1316 createdAt: startedStamp.createdAt,1316 createdAt: startedStamp.createdAt,
1317 threadId: context.session.threadId,1317 threadId: context.session.threadId,
1318 turnId,1318 turnId,
1319 payload: {1319 payload: input.model ? { model: input.model } : {},
1320 ...(input.model ? { model: input.model } : {}),
1321 ...(input.effort ? { effort: input.effort } : {}),
1322 },
1323 providerRefs: {1320 providerRefs: {
1324 providerTurnId: String(turnId),1321 providerTurnId: String(turnId),
1325 },1322 },
apps/web/src/appSettings.ts+2-1
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { useCallback, useSyncExternalStore } from "react";1import { useCallback, useSyncExternalStore } from "react";
2import { Option, Schema } from "effect";2import { 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
5const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";6const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
6const MAX_CUSTOM_MODEL_COUNT = 32;7const MAX_CUSTOM_MODEL_COUNT = 32;
apps/web/src/components/ChatView.tsx+230-140
...@@ -8,16 +8,10 @@ import {...@@ -8,16 +8,10 @@ import {
8 type CodexReasoningEffort,8 type CodexReasoningEffort,
9 type CursorReasoningOption,9 type CursorReasoningOption,
10 type MessageId,10 type MessageId,
11 getDefaultModel,
12 getDefaultReasoningEffort,
13 getCursorModelCapabilities,
14 getCursorModelFamilyOptions,
15 getReasoningEffortOptions,
16 type ProjectId,11 type ProjectId,
17 type ProjectEntry,12 type ProjectEntry,
18 type ProjectScript,13 type ProjectScript,
19 type ModelSlug,14 type ModelSlug,
20 parseCursorModelSelection,
21 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,15 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
22 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,16 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
23 type ResolvedKeybindingsConfig,17 type ResolvedKeybindingsConfig,
...@@ -26,21 +20,29 @@ import {...@@ -26,21 +20,29 @@ import {
26 type ProviderKind,20 type ProviderKind,
27 type ThreadId,21 type ThreadId,
28 type TurnId,22 type TurnId,
23 OrchestrationThreadActivity,
24 RuntimeMode,
25 ProviderInteractionMode,
26} from "@t3tools/contracts";
27import {
28 getDefaultModel,
29 getDefaultReasoningEffort,
30 getCursorModelCapabilities,
31 getCursorModelFamilyOptions,
32 getReasoningEffortOptions,
29 normalizeModelSlug,33 normalizeModelSlug,
34 parseCursorModelSelection,
30 resolveCursorModelFromSelection,35 resolveCursorModelFromSelection,
31 resolveModelSlugForProvider,36 resolveModelSlugForProvider,
32 OrchestrationThreadActivity,37} from "@t3tools/shared/model";
33} from "@t3tools/contracts";
34import {38import {
35 memo,39 memo,
36 useCallback,40 useCallback,
37 useEffect,41 useEffect,
38 useLayoutEffect,42 useLayoutEffect,
39 useMemo,43 useMemo,
40 useOptimistic,
41 useRef,44 useRef,
42 useState,45 useState,
43 useTransition,
44 useId,46 useId,
45} from "react";47} from "react";
46import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";48import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
...@@ -146,7 +148,6 @@ import {...@@ -146,7 +148,6 @@ import {
146} from "lucide-react";148} from "lucide-react";
147import { Button } from "./ui/button";149import { Button } from "./ui/button";
148import { Input } from "./ui/input";150import { Input } from "./ui/input";
149import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select";
150import { Separator } from "./ui/separator";151import { Separator } from "./ui/separator";
151import { Group, GroupSeparator } from "./ui/group";152import { Group, GroupSeparator } from "./ui/group";
152import {153import {
...@@ -578,7 +579,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -578,7 +579,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
578 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);579 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
579 const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);580 const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);
580 const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);581 const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);
582 const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);
583 const setComposerDraftInteractionMode = useComposerDraftStore((store) => store.setInteractionMode);
581 const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort);584 const setComposerDraftEffort = useComposerDraftStore((store) => store.setEffort);
585 const setComposerDraftCodexFastMode = useComposerDraftStore((store) => store.setCodexFastMode);
582 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);586 const addComposerDraftImage = useComposerDraftStore((store) => store.addImage);
583 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);587 const addComposerDraftImages = useComposerDraftStore((store) => store.addImages);
584 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);588 const removeComposerDraftImage = useComposerDraftStore((store) => store.removeImage);
...@@ -711,12 +715,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -711,12 +715,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
711 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],715 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],
712 );716 );
713 const activeThread = serverThread ?? localDraftThread;717 const activeThread = serverThread ?? localDraftThread;
714 const runtimeMode = activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;718 const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE;
715 const interactionMode = activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;719 const interactionMode =
716 const [optimisticRuntimeMode, setOptimisticRuntimeMode] = useOptimistic(runtimeMode);720 composerDraft.interactionMode ?? activeThread?.interactionMode ?? DEFAULT_INTERACTION_MODE;
717 const [optimisticInteractionMode, setOptimisticInteractionMode] = useOptimistic(interactionMode);
718 const [isRuntimeModePending, startRuntimeModeTransition] = useTransition();
719 const [isInteractionModePending, startInteractionModeTransition] = useTransition();
720 const isServerThread = serverThread !== undefined;721 const isServerThread = serverThread !== undefined;
721 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;722 const isLocalDraftThread = !isServerThread && localDraftThread !== undefined;
722 const diffSearch = useMemo(723 const diffSearch = useMemo(
...@@ -794,9 +795,21 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -794,9 +795,21 @@ export default function ChatView({ threadId }: ChatViewProps) {
794795
795 return resolveModelSlugForProvider(selectedProvider, draftModel);796 return resolveModelSlugForProvider(selectedProvider, draftModel);
796 }, [baseThreadModel, composerDraft.model, selectedProvider, settings]);797 }, [baseThreadModel, composerDraft.model, selectedProvider, settings]);
797 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);
798 const reasoningOptions = getReasoningEffortOptions(selectedProvider);798 const reasoningOptions = getReasoningEffortOptions(selectedProvider);
799 const supportsReasoningEffort = reasoningOptions.length > 0;799 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]);
800 const selectedCursorModel = useMemo(813 const selectedCursorModel = useMemo(
801 () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null),814 () => (selectedProvider === "cursor" ? parseCursorModelSelection(selectedModel) : null),
802 [selectedModel, selectedProvider],815 [selectedModel, selectedProvider],
...@@ -920,7 +933,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -920,7 +933,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
920 );933 );
921 const showPlanFollowUpPrompt =934 const showPlanFollowUpPrompt =
922 pendingUserInputs.length === 0 &&935 pendingUserInputs.length === 0 &&
923 optimisticInteractionMode === "plan" &&936 interactionMode === "plan" &&
924 latestTurnSettled &&937 latestTurnSettled &&
925 activeProposedPlan !== null;938 activeProposedPlan !== null;
926 const hasComposerHeader =939 const hasComposerHeader =
...@@ -1592,65 +1605,92 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1592,65 +1605,92 @@ export default function ChatView({ threadId }: ChatViewProps) {
1592 [activeProject, persistProjectScripts],1605 [activeProject, persistProjectScripts],
1593 );1606 );
15941607
1595 const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => {1608 const handleRuntimeModeChange = useCallback(
1596 if (mode === runtimeMode) return;1609 (mode: RuntimeMode) => {
1597 if (isLocalDraftThread) {1610 if (mode === runtimeMode) return;
1598 setDraftThreadContext(threadId, { runtimeMode: mode });1611 setComposerDraftRuntimeMode(threadId, mode);
1612 if (isLocalDraftThread) {
1613 setDraftThreadContext(threadId, { runtimeMode: mode });
1614 }
1599 scheduleComposerFocus();1615 scheduleComposerFocus();
1600 return;1616 },
1601 }1617 [
1602 const api = readNativeApi();1618 isLocalDraftThread,
1603 if (!api || !isServerThread) return;1619 runtimeMode,
1604 scheduleComposerFocus();1620 scheduleComposerFocus,
1605 startRuntimeModeTransition(() => {1621 setComposerDraftRuntimeMode,
1606 setOptimisticRuntimeMode(mode);1622 setDraftThreadContext,
1607 void api.orchestration1623 threadId,
1608 .dispatchCommand({1624 ],
1609 type: "thread.runtime-mode.set",1625 );
1610 commandId: newCommandId(),
1611 threadId,
1612 runtimeMode: mode,
1613 createdAt: new Date().toISOString(),
1614 })
1615 .catch(() => undefined);
1616 });
1617 };
16181626
1619 const handleInteractionModeChange = useCallback(1627 const handleInteractionModeChange = useCallback(
1620 async (mode: "default" | "plan") => {1628 (mode: ProviderInteractionMode) => {
1621 if (mode === interactionMode) return;1629 if (mode === interactionMode) return;
1630 setComposerDraftInteractionMode(threadId, mode);
1622 if (isLocalDraftThread) {1631 if (isLocalDraftThread) {
1623 setDraftThreadContext(threadId, { interactionMode: mode });1632 setDraftThreadContext(threadId, { interactionMode: mode });
1624 scheduleComposerFocus();
1625 return;
1626 }1633 }
1627 const api = readNativeApi();
1628 if (!api || !isServerThread) return;
1629 scheduleComposerFocus();1634 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 });
1642 },1635 },
1643 [1636 [
1644 interactionMode,1637 interactionMode,
1645 isLocalDraftThread,1638 isLocalDraftThread,
1646 isServerThread,
1647 scheduleComposerFocus,1639 scheduleComposerFocus,
1640 setComposerDraftInteractionMode,
1648 setDraftThreadContext,1641 setDraftThreadContext,
1649 setOptimisticInteractionMode,
1650 threadId,1642 threadId,
1651 ],1643 ],
1652 );1644 );
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
1654 useEffect(() => {1694 useEffect(() => {
1655 try {1695 try {
1656 if (Object.keys(lastInvokedScriptByProjectId).length === 0) {1696 if (Object.keys(lastInvokedScriptByProjectId).length === 0) {
...@@ -2519,6 +2559,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2519,6 +2559,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
2519 });2559 });
2520 }2560 }
25212561
2562 if (isServerThread) {
2563 await persistThreadSettingsForNextTurn({
2564 threadId: threadIdForSend,
2565 createdAt: messageCreatedAt,
2566 ...(selectedModel ? { model: selectedModel } : {}),
2567 runtimeMode,
2568 interactionMode,
2569 });
2570 }
2571
2522 setSendPhase("sending-turn");2572 setSendPhase("sending-turn");
2523 const turnAttachments = await turnAttachmentsPromise;2573 const turnAttachments = await turnAttachmentsPromise;
2524 await api.orchestration.dispatchCommand({2574 await api.orchestration.dispatchCommand({
...@@ -2532,8 +2582,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2532,8 +2582,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
2532 attachments: turnAttachments,2582 attachments: turnAttachments,
2533 },2583 },
2534 model: selectedModel || undefined,2584 model: selectedModel || undefined,
2585 ...(selectedModelOptionsForDispatch
2586 ? { modelOptions: selectedModelOptionsForDispatch }
2587 : {}),
2535 provider: selectedProvider,2588 provider: selectedProvider,
2536 ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}),
2537 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",2589 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2538 runtimeMode,2590 runtimeMode,
2539 interactionMode,2591 interactionMode,
...@@ -2781,16 +2833,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2781,16 +2833,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
2781 forceStickToBottom();2833 forceStickToBottom();
27822834
2783 try {2835 try {
2784 if (nextInteractionMode !== interactionMode) {2836 await persistThreadSettingsForNextTurn({
2785 setOptimisticInteractionMode(nextInteractionMode);2837 threadId: threadIdForSend,
2786 await api.orchestration.dispatchCommand({2838 createdAt: messageCreatedAt,
2787 type: "thread.interaction-mode.set",2839 ...(selectedModel ? { model: selectedModel } : {}),
2788 commandId: newCommandId(),2840 runtimeMode,
2789 threadId: threadIdForSend,2841 interactionMode: nextInteractionMode,
2790 interactionMode: nextInteractionMode,2842 });
2791 createdAt: messageCreatedAt,
2792 });
2793 }
27942843
2795 await api.orchestration.dispatchCommand({2844 await api.orchestration.dispatchCommand({
2796 type: "thread.turn.start",2845 type: "thread.turn.start",
...@@ -2804,7 +2853,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2804,7 +2853,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
2804 },2853 },
2805 provider: selectedProvider,2854 provider: selectedProvider,
2806 model: selectedModel || undefined,2855 model: selectedModel || undefined,
2807 ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}),2856 ...(selectedModelOptionsForDispatch
2857 ? { modelOptions: selectedModelOptionsForDispatch }
2858 : {}),
2808 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",2859 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2809 runtimeMode,2860 runtimeMode,
2810 interactionMode: nextInteractionMode,2861 interactionMode: nextInteractionMode,
...@@ -2816,9 +2867,6 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2816,9 +2867,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
2816 setOptimisticUserMessages((existing) =>2867 setOptimisticUserMessages((existing) =>
2817 existing.filter((message) => message.id !== messageIdForSend),2868 existing.filter((message) => message.id !== messageIdForSend),
2818 );2869 );
2819 if (nextInteractionMode !== interactionMode) {
2820 setOptimisticInteractionMode(interactionMode);
2821 }
2822 setThreadError(2870 setThreadError(
2823 threadIdForSend,2871 threadIdForSend,
2824 err instanceof Error ? err.message : "Failed to send plan follow-up.",2872 err instanceof Error ? err.message : "Failed to send plan follow-up.",
...@@ -2830,18 +2878,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2830,18 +2878,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
2830 [2878 [
2831 activeThread,2879 activeThread,
2832 forceStickToBottom,2880 forceStickToBottom,
2833 interactionMode,
2834 isConnecting,2881 isConnecting,
2835 isSendBusy,2882 isSendBusy,
2836 isServerThread,2883 isServerThread,
2884 persistThreadSettingsForNextTurn,
2837 runtimeMode,2885 runtimeMode,
2838 selectedEffort,
2839 selectedModel,2886 selectedModel,
2887 selectedModelOptionsForDispatch,
2840 selectedProvider,2888 selectedProvider,
2841 setOptimisticInteractionMode,
2842 setThreadError,2889 setThreadError,
2843 settings.enableAssistantStreaming,2890 settings.enableAssistantStreaming,
2844 supportsReasoningEffort,
2845 ],2891 ],
2846 );2892 );
28472893
...@@ -2905,7 +2951,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2905,7 +2951,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
2905 },2951 },
2906 provider: selectedProvider,2952 provider: selectedProvider,
2907 model: selectedModel || undefined,2953 model: selectedModel || undefined,
2908 ...(supportsReasoningEffort && selectedEffort ? { effort: selectedEffort } : {}),2954 ...(selectedModelOptionsForDispatch
2955 ? { modelOptions: selectedModelOptionsForDispatch }
2956 : {}),
2909 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",2957 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2910 runtimeMode,2958 runtimeMode,
2911 interactionMode: "default",2959 interactionMode: "default",
...@@ -2951,11 +2999,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2951,11 +2999,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
2951 isServerThread,2999 isServerThread,
2952 navigate,3000 navigate,
2953 runtimeMode,3001 runtimeMode,
2954 selectedEffort,
2955 selectedModel,3002 selectedModel,
3003 selectedModelOptionsForDispatch,
2956 selectedProvider,3004 selectedProvider,
2957 settings.enableAssistantStreaming,3005 settings.enableAssistantStreaming,
2958 supportsReasoningEffort,
2959 syncServerReadModel,3006 syncServerReadModel,
2960 ]);3007 ]);
29613008
...@@ -2970,27 +3017,17 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2970,27 +3017,17 @@ export default function ChatView({ threadId }: ChatViewProps) {
2970 scheduleComposerFocus();3017 scheduleComposerFocus();
2971 return;3018 return;
2972 }3019 }
2973 const api = readNativeApi();
2974 const resolvedModel =3020 const resolvedModel =
2975 provider === "cursor"3021 provider === "cursor"
2976 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))3022 ? resolveCursorModelFromSelection(parseCursorModelSelection(model))
2977 : resolveModelSlugForProvider(provider, model);3023 : resolveModelSlugForProvider(provider, model);
2978 setComposerDraftProvider(activeThread.id, provider);3024 setComposerDraftProvider(activeThread.id, provider);
2979 setComposerDraftModel(activeThread.id, resolvedModel);3025 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 }
2988 scheduleComposerFocus();3026 scheduleComposerFocus();
2989 },3027 },
2990 [3028 [
2991 activeThread,3029 activeThread,
2992 cursorModelSelectionLockedReason,3030 cursorModelSelectionLockedReason,
2993 isServerThread,
2994 lockedProvider,3031 lockedProvider,
2995 scheduleComposerFocus,3032 scheduleComposerFocus,
2996 setComposerDraftModel,3033 setComposerDraftModel,
...@@ -3046,6 +3083,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3046,6 +3083,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
3046 },3083 },
3047 [scheduleComposerFocus, setComposerDraftEffort, threadId],3084 [scheduleComposerFocus, setComposerDraftEffort, threadId],
3048 );3085 );
3086 const onCodexFastModeChange = useCallback(
3087 (enabled: boolean) => {
3088 setComposerDraftCodexFastMode(threadId, enabled);
3089 scheduleComposerFocus();
3090 },
3091 [scheduleComposerFocus, setComposerDraftCodexFastMode, threadId],
3092 );
3049 const onEnvModeChange = useCallback(3093 const onEnvModeChange = useCallback(
3050 (mode: DraftThreadEnvMode) => {3094 (mode: DraftThreadEnvMode) => {
3051 if (isLocalDraftThread) {3095 if (isLocalDraftThread) {
...@@ -3625,19 +3669,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3625,19 +3669,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
3625 </>3669 </>
3626 )}3670 )}
3627 </>3671 </>
3628 ) : supportsReasoningEffort ? (3672 ) : selectedProvider === "codex" && selectedEffort != null ? (
3629 <>3673 <>
3630 {/* Divider */}
3631 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />3674 <Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
36323675 <CodexTraitsPicker
3633 {/* Reasoning effort */}3676 effort={selectedEffort}
3634 {selectedEffort != null && (3677 fastModeEnabled={selectedCodexFastModeEnabled}
3635 <ReasoningEffortPicker3678 options={reasoningOptions}
3636 effort={selectedEffort}3679 onEffortChange={onEffortSelect}
3637 options={reasoningOptions}3680 onFastModeChange={onCodexFastModeChange}
3638 onEffortChange={onEffortSelect}3681 />
3639 />
3640 )}
3641 </>3682 </>
3642 ) : null}3683 ) : null}
36433684
...@@ -3650,21 +3691,20 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3650,21 +3691,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
3650 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"3691 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
3651 size="sm"3692 size="sm"
3652 type="button"3693 type="button"
3653 disabled={isInteractionModePending}
3654 onClick={() =>3694 onClick={() =>
3655 void handleInteractionModeChange(3695 void handleInteractionModeChange(
3656 optimisticInteractionMode === "plan" ? "default" : "plan",3696 interactionMode === "plan" ? "default" : "plan",
3657 )3697 )
3658 }3698 }
3659 title={3699 title={
3660 optimisticInteractionMode === "plan"3700 interactionMode === "plan"
3661 ? "Plan mode — click to return to normal chat mode"3701 ? "Plan mode — click to return to normal chat mode"
3662 : "Default mode — click to enter plan mode"3702 : "Default mode — click to enter plan mode"
3663 }3703 }
3664 >3704 >
3665 <BotIcon />3705 <BotIcon />
3666 <span className="sr-only sm:not-sr-only">3706 <span className="sr-only sm:not-sr-only">
3667 {optimisticInteractionMode === "plan" ? "Plan" : "Chat"}3707 {interactionMode === "plan" ? "Plan" : "Chat"}
3668 </span>3708 </span>
3669 </Button>3709 </Button>
36703710
...@@ -3677,21 +3717,20 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3677,21 +3717,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
3677 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"3717 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
3678 size="sm"3718 size="sm"
3679 type="button"3719 type="button"
3680 disabled={isRuntimeModePending}
3681 onClick={() =>3720 onClick={() =>
3682 void handleRuntimeModeChange(3721 void handleRuntimeModeChange(
3683 optimisticRuntimeMode === "full-access" ? "approval-required" : "full-access",3722 runtimeMode === "full-access" ? "approval-required" : "full-access",
3684 )3723 )
3685 }3724 }
3686 title={3725 title={
3687 optimisticRuntimeMode === "full-access"3726 runtimeMode === "full-access"
3688 ? "Full access — click to require approvals"3727 ? "Full access — click to require approvals"
3689 : "Approval required — click for full access"3728 : "Approval required — click for full access"
3690 }3729 }
3691 >3730 >
3692 {optimisticRuntimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}3731 {runtimeMode === "full-access" ? <LockOpenIcon /> : <LockIcon />}
3693 <span className="sr-only sm:not-sr-only">3732 <span className="sr-only sm:not-sr-only">
3694 {optimisticRuntimeMode === "full-access" ? "Full access" : "Supervised"}3733 {runtimeMode === "full-access" ? "Full access" : "Supervised"}
3695 </span>3734 </span>
3696 </Button>3735 </Button>
3697 </div>3736 </div>
...@@ -4397,7 +4436,10 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -4397,7 +4436,10 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
4397}) {4436}) {
4398 const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props;4437 const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props;
4399 const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]);4438 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 );
4401 const allDirectoryExpansionState = useMemo(4443 const allDirectoryExpansionState = useMemo(
4402 () =>4444 () =>
4403 buildDirectoryExpansionState(4445 buildDirectoryExpansionState(
...@@ -4406,8 +4448,8 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -4406,8 +4448,8 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
4406 ),4448 ),
4407 [allDirectoriesExpanded, directoryPathsKey],4449 [allDirectoriesExpanded, directoryPathsKey],
4408 );4450 );
4409 const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(4451 const [expandedDirectories, setExpandedDirectories] = useState<Record<string, boolean>>(() =>
4410 () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true),4452 buildDirectoryExpansionState(directoryPathsKey ? directoryPathsKey.split("\u0000") : [], true),
4411 );4453 );
4412 useEffect(() => {4454 useEffect(() => {
4413 setExpandedDirectories(allDirectoryExpansionState);4455 setExpandedDirectories(allDirectoryExpansionState);
...@@ -4489,11 +4531,7 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -4489,11 +4531,7 @@ const ChangedFilesTree = memo(function ChangedFilesTree(props: {
4489 );4531 );
4490 };4532 };
44914533
4492 return (4534 return <div className="space-y-0.5">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>;
4493 <div className="space-y-0.5">
4494 {treeNodes.map((node) => renderTreeNode(node, 0))}
4495 </div>
4496 );
4497});4535});
44984536
4499const ProposedPlanCard = memo(function ProposedPlanCard({4537const ProposedPlanCard = memo(function ProposedPlanCard({
...@@ -5337,7 +5375,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5337,7 +5375,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5337 <Button5375 <Button
5338 size="sm"5376 size="sm"
5339 variant="ghost"5377 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"
5341 disabled={props.disabled}5379 disabled={props.disabled}
5342 />5380 />
5343 }5381 }
...@@ -5347,7 +5385,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5347,7 +5385,7 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5347 aria-hidden="true"5385 aria-hidden="true"
5348 className={cn(5386 className={cn(
5349 "size-4 shrink-0",5387 "size-4 shrink-0",
5350 props.provider === "claudeCode" ? "" : "text-foreground/80",5388 props.provider === "claudeCode" ? "" : "text-muted-foreground/70",
5351 )}5389 )}
5352 />5390 />
5353 <span className="truncate">{selectedModelLabel}</span>5391 <span className="truncate">{selectedModelLabel}</span>
...@@ -5419,30 +5457,82 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {...@@ -5419,30 +5457,82 @@ const ProviderModelPicker = memo(function ProviderModelPicker(props: {
5419 );5457 );
5420});5458});
54215459
5422const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: {5460const CodexTraitsPicker = memo(function CodexTraitsPicker(props: {
5423 effort: CodexReasoningEffort;5461 effort: CodexReasoningEffort;
5462 fastModeEnabled: boolean;
5424 options: ReadonlyArray<CodexReasoningEffort>;5463 options: ReadonlyArray<CodexReasoningEffort>;
5425 onEffortChange: (effort: CodexReasoningEffort) => void;5464 onEffortChange: (effort: CodexReasoningEffort) => void;
5465 onFastModeChange: (enabled: boolean) => void;
5426}) {5466}) {
5467 const [isMenuOpen, setIsMenuOpen] = useState(false);
5427 const defaultReasoningEffort = getDefaultReasoningEffort("codex");5468 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
5429 return (5482 return (
5430 <Select5483 <Menu
5431 value={props.effort}5484 open={isMenuOpen}
5432 onValueChange={(value) => (value ? props.onEffortChange(value) : undefined)}5485 onOpenChange={(open) => {
5486 setIsMenuOpen(open);
5487 }}
5433 >5488 >
5434 <SelectTrigger variant="ghost" size="sm">5489 <MenuTrigger
5435 <SelectValue />5490 render={
5436 </SelectTrigger>5491 <Button
5437 <SelectPopup alignItemWithTrigger={false}>5492 size="sm"
5438 {props.options.map((effort) => (5493 variant="ghost"
5439 <SelectItem key={effort} value={effort}>5494 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
5440 {effort}5495 />
5441 {effort === defaultReasoningEffort ? " (default)" : ""}5496 }
5442 </SelectItem>5497 >
5443 ))}5498 <span>{triggerLabel}</span>
5444 </SelectPopup>5499 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
5445 </Select>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>
5446 );5536 );
5447});5537});
54485538
...@@ -5459,7 +5549,7 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {...@@ -5459,7 +5549,7 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
5459 low: "Low",5549 low: "Low",
5460 normal: "Normal",5550 normal: "Normal",
5461 high: "High",5551 high: "High",
5462 xhigh: "XHigh",5552 xhigh: "Extra High",
5463 };5553 };
5464 const traitSummary = [5554 const traitSummary = [
5465 ...(props.capabilities.supportsReasoning5555 ...(props.capabilities.supportsReasoning
...@@ -5486,7 +5576,7 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {...@@ -5486,7 +5576,7 @@ const CursorTraitsPicker = memo(function CursorTraitsPicker(props: {
5486 <Button5576 <Button
5487 size="sm"5577 size="sm"
5488 variant="ghost"5578 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"
5490 disabled={props.disabled}5580 disabled={props.disabled}
5491 />5581 />
5492 }5582 }
apps/web/src/composerDraftStore.test.ts+74
...@@ -184,6 +184,8 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -184,6 +184,8 @@ describe("composerDraftStore project draft thread mapping", () => {
184 branch: "feature/test",184 branch: "feature/test",
185 worktreePath: "/tmp/worktree-test",185 worktreePath: "/tmp/worktree-test",
186 envMode: "worktree",186 envMode: "worktree",
187 runtimeMode: "full-access",
188 interactionMode: "default",
187 createdAt: "2026-01-01T00:00:00.000Z",189 createdAt: "2026-01-01T00:00:00.000Z",
188 });190 });
189 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({191 expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({
...@@ -191,6 +193,8 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -191,6 +193,8 @@ describe("composerDraftStore project draft thread mapping", () => {
191 branch: "feature/test",193 branch: "feature/test",
192 worktreePath: "/tmp/worktree-test",194 worktreePath: "/tmp/worktree-test",
193 envMode: "worktree",195 envMode: "worktree",
196 runtimeMode: "full-access",
197 interactionMode: "default",
194 createdAt: "2026-01-01T00:00:00.000Z",198 createdAt: "2026-01-01T00:00:00.000Z",
195 });199 });
196 });200 });
...@@ -329,6 +333,33 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -329,6 +333,33 @@ describe("composerDraftStore project draft thread mapping", () => {
329 });333 });
330});334});
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
332describe("composerDraftStore setModel", () => {363describe("composerDraftStore setModel", () => {
333 const threadId = ThreadId.makeUnsafe("thread-model");364 const threadId = ThreadId.makeUnsafe("thread-model");
334365
...@@ -377,3 +408,46 @@ describe("composerDraftStore setProvider", () => {...@@ -377,3 +408,46 @@ describe("composerDraftStore setProvider", () => {
377 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();408 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
378 });409 });
379});410});
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 {...@@ -3,12 +3,12 @@ import {
3 ProjectId,3 ProjectId,
4 REASONING_EFFORT_OPTIONS_BY_PROVIDER,4 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
5 ThreadId,5 ThreadId,
6 normalizeModelSlug,
7 type CodexReasoningEffort,6 type CodexReasoningEffort,
8 type ProviderKind,7 type ProviderKind,
9 type ProviderInteractionMode,8 type ProviderInteractionMode,
10 type RuntimeMode,9 type RuntimeMode,
11} from "@t3tools/contracts";10} from "@t3tools/contracts";
11import { normalizeModelSlug } from "@t3tools/shared/model";
12import {12import {
13 DEFAULT_INTERACTION_MODE,13 DEFAULT_INTERACTION_MODE,
14 DEFAULT_RUNTIME_MODE,14 DEFAULT_RUNTIME_MODE,
...@@ -38,7 +38,11 @@ interface PersistedComposerThreadDraftState {...@@ -38,7 +38,11 @@ interface PersistedComposerThreadDraftState {
38 attachments: PersistedComposerImageAttachment[];38 attachments: PersistedComposerImageAttachment[];
39 provider?: ProviderKind | null;39 provider?: ProviderKind | null;
40 model?: string | null;40 model?: string | null;
41 runtimeMode?: RuntimeMode | null;
42 interactionMode?: ProviderInteractionMode | null;
41 effort?: CodexReasoningEffort | null;43 effort?: CodexReasoningEffort | null;
44 codexFastMode?: boolean | null;
45 serviceTier?: string | null;
42}46}
4347
44interface PersistedDraftThreadState {48interface PersistedDraftThreadState {
...@@ -64,7 +68,10 @@ interface ComposerThreadDraftState {...@@ -64,7 +68,10 @@ interface ComposerThreadDraftState {
64 persistedAttachments: PersistedComposerImageAttachment[];68 persistedAttachments: PersistedComposerImageAttachment[];
65 provider: ProviderKind | null;69 provider: ProviderKind | null;
66 model: string | null;70 model: string | null;
71 runtimeMode: RuntimeMode | null;
72 interactionMode: ProviderInteractionMode | null;
67 effort: CodexReasoningEffort | null;73 effort: CodexReasoningEffort | null;
74 codexFastMode: boolean;
68}75}
6976
70export interface DraftThreadState {77export interface DraftThreadState {
...@@ -117,7 +124,13 @@ interface ComposerDraftStoreState {...@@ -117,7 +124,13 @@ interface ComposerDraftStoreState {
117 setPrompt: (threadId: ThreadId, prompt: string) => void;124 setPrompt: (threadId: ThreadId, prompt: string) => void;
118 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;125 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;
119 setModel: (threadId: ThreadId, model: string | null | undefined) => void;126 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;
120 setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void;132 setEffort: (threadId: ThreadId, effort: CodexReasoningEffort | null | undefined) => void;
133 setCodexFastMode: (threadId: ThreadId, enabled: boolean | null | undefined) => void;
121 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;134 addImage: (threadId: ThreadId, image: ComposerImageAttachment) => void;
122 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;135 addImages: (threadId: ThreadId, images: ComposerImageAttachment[]) => void;
123 removeImage: (threadId: ThreadId, imageId: string) => void;136 removeImage: (threadId: ThreadId, imageId: string) => void;
...@@ -149,7 +162,10 @@ const EMPTY_THREAD_DRAFT = Object.freeze({...@@ -149,7 +162,10 @@ const EMPTY_THREAD_DRAFT = Object.freeze({
149 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,162 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
150 provider: null,163 provider: null,
151 model: null,164 model: null,
165 runtimeMode: null,
166 interactionMode: null,
152 effort: null,167 effort: null,
168 codexFastMode: false,
153}) as ComposerThreadDraftState;169}) as ComposerThreadDraftState;
154170
155const REASONING_EFFORT_VALUES = new Set<CodexReasoningEffort>(171const REASONING_EFFORT_VALUES = new Set<CodexReasoningEffort>(
...@@ -164,7 +180,10 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {...@@ -164,7 +180,10 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
164 persistedAttachments: [],180 persistedAttachments: [],
165 provider: null,181 provider: null,
166 model: null,182 model: null,
183 runtimeMode: null,
184 interactionMode: null,
167 effort: null,185 effort: null,
186 codexFastMode: false,
168 };187 };
169}188}
170189
...@@ -181,7 +200,10 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {...@@ -181,7 +200,10 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
181 draft.persistedAttachments.length === 0 &&200 draft.persistedAttachments.length === 0 &&
182 draft.provider === null &&201 draft.provider === null &&
183 draft.model === null &&202 draft.model === null &&
184 draft.effort === null203 draft.runtimeMode === null &&
204 draft.interactionMode === null &&
205 draft.effort === null &&
206 draft.codexFastMode === false
185 );207 );
186}208}
187209
...@@ -349,13 +371,34 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -349,13 +371,34 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
349 typeof draftCandidate.model === "string"371 typeof draftCandidate.model === "string"
350 ? normalizeModelSlug(draftCandidate.model, provider ?? "codex")372 ? normalizeModelSlug(draftCandidate.model, provider ?? "codex")
351 : null;373 : 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;
352 const effortCandidate =383 const effortCandidate =
353 typeof draftCandidate.effort === "string" ? draftCandidate.effort : null;384 typeof draftCandidate.effort === "string" ? draftCandidate.effort : null;
354 const effort =385 const effort =
355 effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as CodexReasoningEffort)386 effortCandidate && REASONING_EFFORT_VALUES.has(effortCandidate as CodexReasoningEffort)
356 ? (effortCandidate as CodexReasoningEffort)387 ? (effortCandidate as CodexReasoningEffort)
357 : null;388 : 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 ) {
359 continue;402 continue;
360 }403 }
361 nextDraftsByThreadId[threadId as ThreadId] = {404 nextDraftsByThreadId[threadId as ThreadId] = {
...@@ -363,7 +406,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer...@@ -363,7 +406,10 @@ function normalizePersistedComposerDraftState(value: unknown): PersistedComposer
363 attachments,406 attachments,
364 ...(provider ? { provider } : {}),407 ...(provider ? { provider } : {}),
365 ...(model ? { model } : {}),408 ...(model ? { model } : {}),
409 ...(runtimeMode ? { runtimeMode } : {}),
410 ...(interactionMode ? { interactionMode } : {}),
366 ...(effort ? { effort } : {}),411 ...(effort ? { effort } : {}),
412 ...(codexFastMode ? { codexFastMode } : {}),
367 };413 };
368 }414 }
369 return {415 return {
...@@ -467,7 +513,10 @@ function toHydratedThreadDraft(...@@ -467,7 +513,10 @@ function toHydratedThreadDraft(
467 persistedAttachments: persistedDraft.attachments,513 persistedAttachments: persistedDraft.attachments,
468 provider: persistedDraft.provider ?? null,514 provider: persistedDraft.provider ?? null,
469 model: persistedDraft.model ?? null,515 model: persistedDraft.model ?? null,
516 runtimeMode: persistedDraft.runtimeMode ?? null,
517 interactionMode: persistedDraft.interactionMode ?? null,
470 effort: persistedDraft.effort ?? null,518 effort: persistedDraft.effort ?? null,
519 codexFastMode: persistedDraft.codexFastMode === true,
471 };520 };
472}521}
473522
...@@ -783,6 +832,62 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -783,6 +832,62 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
783 return { draftsByThreadId: nextDraftsByThreadId };832 return { draftsByThreadId: nextDraftsByThreadId };
784 });833 });
785 },834 },
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 },
786 setEffort: (threadId, effort) => {891 setEffort: (threadId, effort) => {
787 if (threadId.length === 0) {892 if (threadId.length === 0) {
788 return;893 return;
...@@ -815,6 +920,33 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -815,6 +920,33 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
815 return { draftsByThreadId: nextDraftsByThreadId };920 return { draftsByThreadId: nextDraftsByThreadId };
816 });921 });
817 },922 },
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 },
818 addImage: (threadId, image) => {950 addImage: (threadId, image) => {
819 if (threadId.length === 0) {951 if (threadId.length === 0) {
820 return;952 return;
...@@ -1049,7 +1181,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1049,7 +1181,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1049 draft.persistedAttachments.length === 0 &&1181 draft.persistedAttachments.length === 0 &&
1050 draft.provider === null &&1182 draft.provider === null &&
1051 draft.model === null &&1183 draft.model === null &&
1052 draft.effort === null1184 draft.runtimeMode === null &&
1185 draft.interactionMode === null &&
1186 draft.effort === null &&
1187 draft.codexFastMode === false
1053 ) {1188 ) {
1054 continue;1189 continue;
1055 }1190 }
...@@ -1063,9 +1198,18 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1063,9 +1198,18 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1063 if (draft.provider) {1198 if (draft.provider) {
1064 persistedDraft.provider = draft.provider;1199 persistedDraft.provider = draft.provider;
1065 }1200 }
1201 if (draft.runtimeMode) {
1202 persistedDraft.runtimeMode = draft.runtimeMode;
1203 }
1204 if (draft.interactionMode) {
1205 persistedDraft.interactionMode = draft.interactionMode;
1206 }
1066 if (draft.effort) {1207 if (draft.effort) {
1067 persistedDraft.effort = draft.effort;1208 persistedDraft.effort = draft.effort;
1068 }1209 }
1210 if (draft.codexFastMode) {
1211 persistedDraft.codexFastMode = true;
1212 }
1069 persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;1213 persistedDraftsByThreadId[threadId as ThreadId] = persistedDraft;
1070 }1214 }
1071 return {1215 return {
apps/web/src/routes/_chat.settings.tsx+2-1
...@@ -1,7 +1,8 @@...@@ -1,7 +1,8 @@
1import { createFileRoute } from "@tanstack/react-router";1import { createFileRoute } from "@tanstack/react-router";
2import { useQuery } from "@tanstack/react-query";2import { useQuery } from "@tanstack/react-query";
3import { useCallback, useState } from "react";3import { 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
6import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";7import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
7import { isElectron } from "../env";8import { isElectron } from "../env";
apps/web/src/store.ts+5-3
...@@ -1,15 +1,17 @@...@@ -1,15 +1,17 @@
1import { Fragment, type ReactNode, createElement, useEffect } from "react";1import { Fragment, type ReactNode, createElement, useEffect } from "react";
2import {2import {
3 DEFAULT_MODEL,3 DEFAULT_MODEL,
4 getModelOptions,
5 normalizeModelSlug,
6 type ProviderKind,4 type ProviderKind,
7 ThreadId,5 ThreadId,
8 type OrchestrationReadModel,6 type OrchestrationReadModel,
9 type OrchestrationSessionStatus,7 type OrchestrationSessionStatus,
8} from "@t3tools/contracts";
9import {
10 getModelOptions,
11 normalizeModelSlug,
10 resolveModelSlug,12 resolveModelSlug,
11 resolveModelSlugForProvider,13 resolveModelSlugForProvider,
12} from "@t3tools/contracts";14} from "@t3tools/shared/model";
13import { create } from "zustand";15import { create } from "zustand";
14import {16import {
15 type ChatMessage,17 type ChatMessage,
apps/web/src/types.ts+4-4
...@@ -11,13 +11,13 @@ import type {...@@ -11,13 +11,13 @@ import type {
11 CheckpointRef,11 CheckpointRef,
12 ProviderKind,12 ProviderKind,
13 ProviderInteractionMode,13 ProviderInteractionMode,
14 RuntimeMode,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
1516
16export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";17export type SessionPhase = "disconnected" | "connecting" | "ready" | "running";
17export type RuntimeMode = "approval-required" | "full-access";
18export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";18export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
19export type InteractionMode = ProviderInteractionMode;19
20export const DEFAULT_INTERACTION_MODE: InteractionMode = "default";20export const DEFAULT_INTERACTION_MODE: ProviderInteractionMode = "default";
21export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;21export const DEFAULT_THREAD_TERMINAL_HEIGHT = 280;
22export const DEFAULT_THREAD_TERMINAL_ID = "default";22export const DEFAULT_THREAD_TERMINAL_ID = "default";
23export const MAX_THREAD_TERMINAL_COUNT = 4;23export const MAX_THREAD_TERMINAL_COUNT = 4;
...@@ -90,7 +90,7 @@ export interface Thread {...@@ -90,7 +90,7 @@ export interface Thread {
90 title: string;90 title: string;
91 model: string;91 model: string;
92 runtimeMode: RuntimeMode;92 runtimeMode: RuntimeMode;
93 interactionMode: InteractionMode;93 interactionMode: ProviderInteractionMode;
94 session: ThreadSession | null;94 session: ThreadSession | null;
95 messages: ChatMessage[];95 messages: ChatMessage[];
96 proposedPlans: ProposedPlan[];96 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 @@...@@ -1,13 +1,42 @@
1import { Schema } from "effect";
1import type { ProviderKind } from "./orchestration";2import 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
3type ModelOption = {35type ModelOption = {
4 readonly slug: string;36 readonly slug: string;
5 readonly name: string;37 readonly name: string;
6};38};
739
8export const CURSOR_REASONING_OPTIONS = ["low", "normal", "high", "xhigh"] as const;
9export type CursorReasoningOption = (typeof CURSOR_REASONING_OPTIONS)[number];
10
11type CursorModelFamilyOption = {40type CursorModelFamilyOption = {
12 readonly slug: string;41 readonly slug: string;
13 readonly name: string;42 readonly name: string;
...@@ -29,14 +58,15 @@ export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["sl...@@ -29,14 +58,15 @@ export type CursorModelFamily = (typeof CURSOR_MODEL_FAMILY_OPTIONS)[number]["sl
2958
30export const MODEL_OPTIONS_BY_PROVIDER = {59export const MODEL_OPTIONS_BY_PROVIDER = {
31 codex: [60 codex: [
61 { slug: "gpt-5.4", name: "GPT-5.4" },
32 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },62 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
33 { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },63 { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },
34 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },64 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
35 { slug: "gpt-5.2", name: "GPT-5.2" },65 { slug: "gpt-5.2", name: "GPT-5.2" },
36 ],66 ],
37 claudeCode: [67 claudeCode: [
38 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
39 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },68 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
69 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
40 { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" },70 { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
41 ],71 ],
42 cursor: [72 cursor: [
...@@ -66,91 +96,6 @@ type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]...@@ -66,91 +96,6 @@ type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]
66export type ModelSlug = BuiltInModelSlug | (string & {});96export type ModelSlug = BuiltInModelSlug | (string & {});
67export type CursorModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)["cursor"][number]["slug"];97export 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
154export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {99export const DEFAULT_MODEL_BY_PROVIDER: Record<ProviderKind, ModelSlug> = {
155 codex: "gpt-5.3-codex",100 codex: "gpt-5.3-codex",
156 claudeCode: "claude-sonnet-4-6",101 claudeCode: "claude-sonnet-4-6",
...@@ -163,6 +108,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;...@@ -163,6 +108,7 @@ export const DEFAULT_MODEL = DEFAULT_MODEL_BY_PROVIDER.codex;
163108
164export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, ModelSlug>> = {109export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string, ModelSlug>> = {
165 codex: {110 codex: {
111 "5.4": "gpt-5.4",
166 "5.3": "gpt-5.3-codex",112 "5.3": "gpt-5.3-codex",
167 "gpt-5.3": "gpt-5.3-codex",113 "gpt-5.3": "gpt-5.3-codex",
168 "5.3-spark": "gpt-5.3-codex-spark",114 "5.3-spark": "gpt-5.3-codex-spark",
...@@ -198,172 +144,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,...@@ -198,172 +144,6 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
198 },144 },
199};145};
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
367export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {147export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {
368 codex: CODEX_REASONING_EFFORT_OPTIONS,148 codex: CODEX_REASONING_EFFORT_OPTIONS,
369 claudeCode: [],149 claudeCode: [],
...@@ -375,17 +155,3 @@ export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = {...@@ -375,17 +155,3 @@ export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = {
375 claudeCode: null,155 claudeCode: null,
376 cursor: null,156 cursor: null,
377} as const satisfies Record<ProviderKind, CodexReasoningEffort | null>;157} 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 @@...@@ -1,4 +1,5 @@
1import { assert, it } from "@effect/vitest";1import assert from "node:assert/strict";
2import { it } from "@effect/vitest";
2import { Effect, Schema } from "effect";3import { Effect, Schema } from "effect";
34
4import {5import {
...@@ -137,39 +138,69 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (...@@ -137,39 +138,69 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
137 }),138 }),
138);139);
139140
140it.effect("accepts cursor provider in thread.turn.start", () =>141it.effect("accepts provider-scoped model options in thread.turn.start", () =>
141 Effect.gen(function* () {142 Effect.gen(function* () {
142 const parsed = yield* decodeThreadTurnStartCommand({143 const parsed = yield* decodeThreadTurnStartCommand({
143 type: "thread.turn.start",144 type: "thread.turn.start",
144 commandId: "cmd-turn-cursor",145 commandId: "cmd-turn-options",
145 threadId: "thread-1",146 threadId: "thread-1",
146 message: {147 message: {
147 messageId: "msg-3",148 messageId: "msg-options",
148 role: "user",149 role: "user",
149 text: "hello",150 text: "hello",
150 attachments: [],151 attachments: [],
151 },152 },
152 provider: "cursor",153 provider: "codex",
154 model: "gpt-5.3-codex",
155 modelOptions: {
156 codex: {
157 reasoningEffort: "high",
158 fastMode: true,
159 },
160 },
153 createdAt: "2026-01-01T00:00:00.000Z",161 createdAt: "2026-01-01T00:00:00.000Z",
154 });162 });
155 assert.strictEqual(parsed.provider, "cursor");163 assert.strictEqual(parsed.provider, "codex");
156 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);164 assert.strictEqual(parsed.modelOptions?.codex?.reasoningEffort, "high");
165 assert.strictEqual(parsed.modelOptions?.codex?.fastMode, true);
157 }),166 }),
158);167);
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", () =>
161 Effect.gen(function* () {170 Effect.gen(function* () {
162 const parsed = yield* decodeThreadTurnStartRequestedPayload({171 const parsed = yield* decodeThreadTurnStartCommand({
172 type: "thread.turn.start",
173 commandId: "cmd-turn-cursor",
163 threadId: "thread-1",174 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",
165 createdAt: "2026-01-01T00:00:00.000Z",182 createdAt: "2026-01-01T00:00:00.000Z",
166 });183 });
167 assert.strictEqual(parsed.provider, undefined);184 assert.strictEqual(parsed.provider, "cursor");
168 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
169 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);185 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
170 }),186 }),
171);187);
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
173it.effect("decodes orchestration session runtime mode defaults", () =>204it.effect("decodes orchestration session runtime mode defaults", () =>
174 Effect.gen(function* () {205 Effect.gen(function* () {
175 const parsed = yield* decodeOrchestrationSession({206 const parsed = yield* decodeOrchestrationSession({
packages/contracts/src/orchestration.ts+4-4
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { Option, Schema, SchemaIssue, Struct } from "effect";1import { Option, Schema, SchemaIssue, Struct } from "effect";
2import { ProviderModelOptions } from "./model";
2import {3import {
3 ApprovalRequestId,4 ApprovalRequestId,
4 CheckpointRef,5 CheckpointRef,
...@@ -54,7 +55,6 @@ export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;...@@ -54,7 +55,6 @@ export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
54export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;55export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
55const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;56const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
56const CHAT_ATTACHMENT_ID_MAX_CHARS = 128;57const CHAT_ATTACHMENT_ID_MAX_CHARS = 128;
57
58// Correlation id is command id by design in this model.58// Correlation id is command id by design in this model.
59export const CorrelationId = CommandId;59export const CorrelationId = CommandId;
60export type CorrelationId = typeof CorrelationId.Type;60export type CorrelationId = typeof CorrelationId.Type;
...@@ -349,7 +349,7 @@ export const ThreadTurnStartCommand = Schema.Struct({...@@ -349,7 +349,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
349 }),349 }),
350 provider: Schema.optional(ProviderKind),350 provider: Schema.optional(ProviderKind),
351 model: Schema.optional(TrimmedNonEmptyString),351 model: Schema.optional(TrimmedNonEmptyString),
352 effort: Schema.optional(TrimmedNonEmptyString),352 modelOptions: Schema.optional(ProviderModelOptions),
353 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),353 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
354 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),354 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
355 interactionMode: ProviderInteractionMode.pipe(355 interactionMode: ProviderInteractionMode.pipe(
...@@ -370,7 +370,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({...@@ -370,7 +370,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
370 }),370 }),
371 provider: Schema.optional(ProviderKind),371 provider: Schema.optional(ProviderKind),
372 model: Schema.optional(TrimmedNonEmptyString),372 model: Schema.optional(TrimmedNonEmptyString),
373 effort: Schema.optional(TrimmedNonEmptyString),373 modelOptions: Schema.optional(ProviderModelOptions),
374 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),374 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
375 runtimeMode: RuntimeMode,375 runtimeMode: RuntimeMode,
376 interactionMode: ProviderInteractionMode,376 interactionMode: ProviderInteractionMode,
...@@ -649,7 +649,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({...@@ -649,7 +649,7 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({
649 messageId: MessageId,649 messageId: MessageId,
650 provider: Schema.optional(ProviderKind),650 provider: Schema.optional(ProviderKind),
651 model: Schema.optional(TrimmedNonEmptyString),651 model: Schema.optional(TrimmedNonEmptyString),
652 effort: Schema.optional(TrimmedNonEmptyString),652 modelOptions: Schema.optional(ProviderModelOptions),
653 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),653 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
654 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),654 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
655 interactionMode: ProviderInteractionMode.pipe(655 interactionMode: ProviderInteractionMode.pipe(
packages/contracts/src/provider.test.ts+39-1
...@@ -1,16 +1,24 @@...@@ -1,16 +1,24 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2import { Schema } from "effect";2import { Schema } from "effect";
33
4import { ProviderSessionStartInput } from "./provider";4import { ProviderSendTurnInput, ProviderSessionStartInput } from "./provider";
55
6const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);6const decodeProviderSessionStartInput = Schema.decodeUnknownSync(ProviderSessionStartInput);
7const decodeProviderSendTurnInput = Schema.decodeUnknownSync(ProviderSendTurnInput);
78
8describe("ProviderSessionStartInput", () => {9describe("ProviderSessionStartInput", () => {
9 it("accepts codex-compatible payloads", () => {10 it("accepts codex-compatible payloads", () => {
10 const parsed = decodeProviderSessionStartInput({11 const parsed = decodeProviderSessionStartInput({
12 threadId: "thread-1",
11 provider: "codex",13 provider: "codex",
12 cwd: "/tmp/workspace",14 cwd: "/tmp/workspace",
13 model: "gpt-5.3-codex",15 model: "gpt-5.3-codex",
16 modelOptions: {
17 codex: {
18 reasoningEffort: "high",
19 fastMode: true,
20 },
21 },
14 runtimeMode: "full-access",22 runtimeMode: "full-access",
15 providerOptions: {23 providerOptions: {
16 codex: {24 codex: {
...@@ -20,6 +28,8 @@ describe("ProviderSessionStartInput", () => {...@@ -20,6 +28,8 @@ describe("ProviderSessionStartInput", () => {
20 },28 },
21 });29 });
22 expect(parsed.runtimeMode).toBe("full-access");30 expect(parsed.runtimeMode).toBe("full-access");
31 expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("high");
32 expect(parsed.modelOptions?.codex?.fastMode).toBe(true);
23 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");33 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");
24 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");34 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");
25 });35 });
...@@ -27,6 +37,7 @@ describe("ProviderSessionStartInput", () => {...@@ -27,6 +37,7 @@ describe("ProviderSessionStartInput", () => {
27 it("rejects payloads without runtime mode", () => {37 it("rejects payloads without runtime mode", () => {
28 expect(() =>38 expect(() =>
29 decodeProviderSessionStartInput({39 decodeProviderSessionStartInput({
40 threadId: "thread-1",
30 provider: "codex",41 provider: "codex",
31 }),42 }),
32 ).toThrow();43 ).toThrow();
...@@ -34,6 +45,7 @@ describe("ProviderSessionStartInput", () => {...@@ -34,6 +45,7 @@ describe("ProviderSessionStartInput", () => {
3445
35 it("accepts claude runtime knobs", () => {46 it("accepts claude runtime knobs", () => {
36 const parsed = decodeProviderSessionStartInput({47 const parsed = decodeProviderSessionStartInput({
48 threadId: "thread-1",
37 provider: "claudeCode",49 provider: "claudeCode",
38 cwd: "/tmp/workspace",50 cwd: "/tmp/workspace",
39 model: "claude-sonnet-4-6",51 model: "claude-sonnet-4-6",
...@@ -55,9 +67,15 @@ describe("ProviderSessionStartInput", () => {...@@ -55,9 +67,15 @@ describe("ProviderSessionStartInput", () => {
5567
56 it("accepts cursor provider payloads", () => {68 it("accepts cursor provider payloads", () => {
57 const parsed = decodeProviderSessionStartInput({69 const parsed = decodeProviderSessionStartInput({
70 threadId: "thread-1",
58 provider: "cursor",71 provider: "cursor",
59 cwd: "/tmp/workspace",72 cwd: "/tmp/workspace",
60 model: "composer-1.5",73 model: "composer-1.5",
74 modelOptions: {
75 cursor: {
76 thinking: true,
77 },
78 },
61 providerOptions: {79 providerOptions: {
62 cursor: {80 cursor: {
63 binaryPath: "/usr/local/bin/agent",81 binaryPath: "/usr/local/bin/agent",
...@@ -67,7 +85,27 @@ describe("ProviderSessionStartInput", () => {...@@ -67,7 +85,27 @@ describe("ProviderSessionStartInput", () => {
67 });85 });
68 expect(parsed.provider).toBe("cursor");86 expect(parsed.provider).toBe("cursor");
69 expect(parsed.model).toBe("composer-1.5");87 expect(parsed.model).toBe("composer-1.5");
88 expect(parsed.modelOptions?.cursor?.thinking).toBe(true);
70 expect(parsed.providerOptions?.cursor?.binaryPath).toBe("/usr/local/bin/agent");89 expect(parsed.providerOptions?.cursor?.binaryPath).toBe("/usr/local/bin/agent");
71 expect(parsed.runtimeMode).toBe("approval-required");90 expect(parsed.runtimeMode).toBe("approval-required");
72 });91 });
73});92});
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 @@...@@ -1,5 +1,6 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";2import { TrimmedNonEmptyString } from "./baseSchemas";
3import { ProviderModelOptions } from "./model";
3import {4import {
4 ApprovalRequestId,5 ApprovalRequestId,
5 EventId,6 EventId,
...@@ -22,7 +23,6 @@ import {...@@ -22,7 +23,6 @@ import {
22} from "./orchestration";23} from "./orchestration";
2324
24const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;25const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
25
26const ProviderSessionStatus = Schema.Literals([26const ProviderSessionStatus = Schema.Literals([
27 "connecting",27 "connecting",
28 "ready",28 "ready",
...@@ -72,6 +72,7 @@ export const ProviderSessionStartInput = Schema.Struct({...@@ -72,6 +72,7 @@ export const ProviderSessionStartInput = Schema.Struct({
72 provider: Schema.optional(ProviderKind),72 provider: Schema.optional(ProviderKind),
73 cwd: Schema.optional(TrimmedNonEmptyStringSchema),73 cwd: Schema.optional(TrimmedNonEmptyStringSchema),
74 model: Schema.optional(TrimmedNonEmptyStringSchema),74 model: Schema.optional(TrimmedNonEmptyStringSchema),
75 modelOptions: Schema.optional(ProviderModelOptions),
75 resumeCursor: Schema.optional(Schema.Unknown),76 resumeCursor: Schema.optional(Schema.Unknown),
76 providerOptions: Schema.optional(ProviderStartOptions),77 providerOptions: Schema.optional(ProviderStartOptions),
77 runtimeMode: RuntimeMode,78 runtimeMode: RuntimeMode,
...@@ -87,7 +88,7 @@ export const ProviderSendTurnInput = Schema.Struct({...@@ -87,7 +88,7 @@ export const ProviderSendTurnInput = Schema.Struct({
87 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),88 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
88 ),89 ),
89 model: Schema.optional(TrimmedNonEmptyStringSchema),90 model: Schema.optional(TrimmedNonEmptyStringSchema),
90 effort: Schema.optional(TrimmedNonEmptyStringSchema),91 modelOptions: Schema.optional(ProviderModelOptions),
91 interactionMode: Schema.optional(ProviderInteractionMode),92 interactionMode: Schema.optional(ProviderInteractionMode),
92});93});
93export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;94export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
packages/shared/package.json+5
...@@ -4,6 +4,10 @@...@@ -4,6 +4,10 @@
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "exports": {6 "exports": {
7 "./model": {
8 "types": "./src/model.ts",
9 "import": "./src/model.ts"
10 },
7 "./git": {11 "./git": {
8 "types": "./src/git.ts",12 "types": "./src/git.ts",
9 "import": "./src/git.ts"13 "import": "./src/git.ts"
...@@ -23,6 +27,7 @@...@@ -23,6 +27,7 @@
23 "test": "vitest run"27 "test": "vitest run"
24 },28 },
25 "dependencies": {29 "dependencies": {
30 "@t3tools/contracts": "workspace:*",
26 "effect": "catalog:"31 "effect": "catalog:"
27 },32 },
28 "devDependencies": {33 "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 };