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