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