History

Unify provider model selection across orchestration and UI

- Replace provider-specific model options with typed modelSelection - Persist and replay selected provider, model, and options end to end - Update adapter, service, and chat composer tests for the new shape

Julius Marminge committed 7 months ago

Commit 96d61601 parent 97ff0ac16 files · +287 −320Browse files

Unified · Split

16 files
  1. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +44 −30
  2. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +24 −32
  3. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +52 −37
  4. apps/server/src/provider/Layers/ClaudeAdapter.ts +29 −15
  5. apps/server/src/provider/Layers/CodexAdapter.test.ts +8 −6
  6. apps/server/src/provider/Layers/CodexAdapter.ts +16 −6
  7. apps/server/src/provider/Layers/ProviderService.test.ts +9 −5
  8. apps/server/src/provider/Layers/ProviderService.ts +16 −13
  9. apps/web/src/components/ChatView.tsx +3 −19
  10. apps/web/src/components/chat/composerProviderRegistry.test.tsx +9 −61
  11. apps/web/src/components/chat/composerProviderRegistry.tsx +27 −13
  12. packages/contracts/src/orchestration.ts +1 −1
  13. packages/contracts/src/provider.test.ts +46 −24
  14. packages/contracts/src/provider.ts +3 −5
  15. packages/shared/src/model.test.ts +0 −40
  16. packages/shared/src/model.ts +0 −13
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+44−30
@@ -313,7 +313,10 @@ describe("ProviderCommandReactor", () => { · 312 unmodified lines
313 expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1"));313 expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1"));
314 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({314 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
315 cwd: "/tmp/provider-project",315 cwd: "/tmp/provider-project",
316 model: "gpt-5-codex",316 modelSelection: {
317 provider: "codex",
318 model: "gpt-5-codex",
319 },
317 runtimeMode: "approval-required",320 runtimeMode: "approval-required",
318 });321 });
319 322
@@ -355,9 +358,10 @@ describe("ProviderCommandReactor", () => { · 35 unmodified lines
355 await waitFor(() => harness.startSession.mock.calls.length === 1);358 await waitFor(() => harness.startSession.mock.calls.length === 1);
356 await waitFor(() => harness.sendTurn.mock.calls.length === 1);359 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
357 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({360 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
358 model: "gpt-5.3-codex",361 modelSelection: {
359 modelOptions: {362 provider: "codex",
360 codex: {363 model: "gpt-5.3-codex",
364 options: {
361 reasoningEffort: "high",365 reasoningEffort: "high",
362 fastMode: true,366 fastMode: true,
363 },367 },
@@ -365,9 +369,10 @@ describe("ProviderCommandReactor", () => { · 1 unmodified line
365 });369 });
366 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({370 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
367 threadId: ThreadId.makeUnsafe("thread-1"),371 threadId: ThreadId.makeUnsafe("thread-1"),
368 model: "gpt-5.3-codex",372 modelSelection: {
369 modelOptions: {373 provider: "codex",
370 codex: {374 model: "gpt-5.3-codex",
375 options: {
371 reasoningEffort: "high",376 reasoningEffort: "high",
372 fastMode: true,377 fastMode: true,
373 },378 },
@@ -406,19 +411,20 @@ describe("ProviderCommandReactor", () => { · 32 unmodified lines
406 await waitFor(() => harness.startSession.mock.calls.length === 1);411 await waitFor(() => harness.startSession.mock.calls.length === 1);
407 await waitFor(() => harness.sendTurn.mock.calls.length === 1);412 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
408 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({413 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
409 provider: "claudeAgent",414 modelSelection: {
410 model: "claude-sonnet-4-6",415 provider: "claudeAgent",
411 modelOptions: {416 model: "claude-sonnet-4-6",
412 claudeAgent: {417 options: {
413 effort: "max",418 effort: "max",
414 },419 },
415 },420 },
416 });421 });
417 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({422 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
418 threadId: ThreadId.makeUnsafe("thread-1"),423 threadId: ThreadId.makeUnsafe("thread-1"),
419 model: "claude-sonnet-4-6",424 modelSelection: {
420 modelOptions: {425 provider: "claudeAgent",
421 claudeAgent: {426 model: "claude-sonnet-4-6",
427 options: {
422 effort: "max",428 effort: "max",
423 },429 },
424 },430 },
@@ -456,19 +462,20 @@ describe("ProviderCommandReactor", () => { · 31 unmodified lines
456 await waitFor(() => harness.startSession.mock.calls.length === 1);462 await waitFor(() => harness.startSession.mock.calls.length === 1);
457 await waitFor(() => harness.sendTurn.mock.calls.length === 1);463 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
458 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({464 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
459 provider: "claudeAgent",465 modelSelection: {
460 model: "claude-opus-4-6",466 provider: "claudeAgent",
461 modelOptions: {467 model: "claude-opus-4-6",
462 claudeAgent: {468 options: {
463 fastMode: true,469 fastMode: true,
464 },470 },
465 },471 },
466 });472 });
467 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({473 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
468 threadId: ThreadId.makeUnsafe("thread-1"),474 threadId: ThreadId.makeUnsafe("thread-1"),
469 model: "claude-opus-4-6",475 modelSelection: {
470 modelOptions: {476 provider: "claudeAgent",
471 claudeAgent: {477 model: "claude-opus-4-6",
478 options: {
472 fastMode: true,479 fastMode: true,
473 },480 },
474 },481 },
@@ -594,12 +601,17 @@ describe("ProviderCommandReactor", () => { · 119 unmodified lines
594 await waitFor(() => harness.sendTurn.mock.calls.length === 1);601 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
595 602
596 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({603 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
597 provider: "codex",604 modelSelection: {
598 model: "claude-sonnet-4-6",605 provider: "codex",
606 model: "claude-sonnet-4-6",
607 },
599 });608 });
600 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({609 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
601 threadId: ThreadId.makeUnsafe("thread-1"),610 threadId: ThreadId.makeUnsafe("thread-1"),
602 model: "claude-sonnet-4-6",611 modelSelection: {
612 provider: "codex",
613 model: "claude-sonnet-4-6",
614 },
603 });615 });
604 });616 });
605 617
@@ -707,10 +719,11 @@ describe("ProviderCommandReactor", () => { · 101 unmodified lines
707 await waitFor(() => harness.startSession.mock.calls.length === 2);719 await waitFor(() => harness.startSession.mock.calls.length === 2);
708 await waitFor(() => harness.sendTurn.mock.calls.length === 2);720 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
709 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({721 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
710 provider: "claudeAgent",
711 resumeCursor: { opaque: "resume-1" },722 resumeCursor: { opaque: "resume-1" },
712 modelOptions: {723 modelSelection: {
713 claudeAgent: {724 provider: "claudeAgent",
725 model: "claude-sonnet-4-6",
726 options: {
714 effort: "max",727 effort: "max",
715 },728 },
716 },729 },
@@ -841,11 +854,12 @@ describe("ProviderCommandReactor", () => { · 124 unmodified lines
841 await waitFor(() => harness.startSession.mock.calls.length === 1);854 await waitFor(() => harness.startSession.mock.calls.length === 1);
842 855
843 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({856 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
844 provider: "claudeAgent",857 modelSelection: {
845 model: "claude-opus-4-6",858 provider: "claudeAgent",
859 model: "claude-opus-4-6",
860 },
846 runtimeMode: "approval-required",861 runtimeMode: "approval-required",
847 });862 });
848 expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("modelOptions");
849 });863 });
850 864
851 it("rejects provider changes after a thread is already bound to a session provider", async () => {865 it("rejects provider changes after a thread is already bound to a session provider", async () => {
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+24−32
@@ -5,7 +5,6 @@ import { · 4 unmodified lines
5 EventId,5 EventId,
6 type ModelSelection,6 type ModelSelection,
7 type OrchestrationEvent,7 type OrchestrationEvent,
8 type ProviderModelOptions,
9 ProviderKind,8 ProviderKind,
10 type ProviderStartOptions,9 type ProviderStartOptions,
11 type OrchestrationSession,10 type OrchestrationSession,
@@ -16,7 +15,6 @@ import { · 4 unmodified lines
16} from "@t3tools/contracts";15} from "@t3tools/contracts";
17import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect";16import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect";
18import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";17import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
19import { toProviderModelOptions } from "@t3tools/shared/model";
20 18
21import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";19import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
22import { GitCore } from "../../git/Services/GitCore.ts";20import { GitCore } from "../../git/Services/GitCore.ts";
@@ -77,9 +75,9 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; · 54 unmodified lines
77const WORKTREE_BRANCH_PREFIX = "t3code";75const WORKTREE_BRANCH_PREFIX = "t3code";
78const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);76const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
79 77
80const sameModelOptions = (78const sameModelSelectionOptions = (
81 left: ProviderModelOptions | undefined,79 left: ModelSelection | undefined,
82 right: ProviderModelOptions | undefined,80 right: ModelSelection | undefined,
83): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);81): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
84 82
85function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {83function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
@@ -159,7 +157,7 @@ const make = Effect.gen(function* () { · 73 unmodified lines
159 );157 );
160 158
161 const threadProviderOptions = new Map<string, ProviderStartOptions>();159 const threadProviderOptions = new Map<string, ProviderStartOptions>();
162 const threadModelOptions = new Map<string, ProviderModelOptions>();160 const threadModelSelections = new Map<string, ModelSelection>();
163 161
164 const appendProviderFailureActivity = (input: {162 const appendProviderFailureActivity = (input: {
165 readonly threadId: ThreadId;163 readonly threadId: ThreadId;
@@ -217,7 +215,6 @@ const make = Effect.gen(function* () { · 51 unmodified lines
217 createdAt: string,215 createdAt: string,
218 options?: {216 options?: {
219 readonly modelSelection?: ModelSelection;217 readonly modelSelection?: ModelSelection;
220 readonly modelOptions?: ProviderModelOptions;
221 readonly providerOptions?: ProviderStartOptions;218 readonly providerOptions?: ProviderStartOptions;
222 },219 },
223 ) {220 ) {
@@ -247,9 +244,6 @@ const make = Effect.gen(function* () { · 23 unmodified lines
247 }244 }
248 const preferredProvider: ProviderKind = currentProvider ?? threadProvider;245 const preferredProvider: ProviderKind = currentProvider ?? threadProvider;
249 const desiredModelSelection = requestedModelSelection ?? thread.modelSelection;246 const desiredModelSelection = requestedModelSelection ?? thread.modelSelection;
250 const desiredModel = desiredModelSelection.model;
251 const desiredModelOptions =
252 options?.modelOptions ?? toProviderModelOptions(desiredModelSelection);
253 const effectiveCwd = resolveThreadWorkspaceCwd({247 const effectiveCwd = resolveThreadWorkspaceCwd({
254 thread,248 thread,
255 projects: readModel.projects,249 projects: readModel.projects,
@@ -268,8 +262,7 @@ const make = Effect.gen(function* () { · 12 unmodified lines
268 threadId,262 threadId,
269 ...(preferredProvider ? { provider: preferredProvider } : {}),263 ...(preferredProvider ? { provider: preferredProvider } : {}),
270 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),264 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
271 ...(desiredModel ? { model: desiredModel } : {}),265 modelSelection: desiredModelSelection,
272 ...(desiredModelOptions !== undefined ? { modelOptions: desiredModelOptions } : {}),
273 ...(options?.providerOptions !== undefined266 ...(options?.providerOptions !== undefined
274 ? { providerOptions: options.providerOptions }267 ? { providerOptions: options.providerOptions }
275 : {}),268 : {}),
@@ -309,17 +302,17 @@ const make = Effect.gen(function* () { · 33 unmodified lines
309 requestedModelSelection !== undefined &&302 requestedModelSelection !== undefined &&
310 requestedModelSelection.model !== activeSession?.model;303 requestedModelSelection.model !== activeSession?.model;
311 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";304 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";
312 const previousModelOptions = threadModelOptions.get(threadId);305 const previousModelSelection = threadModelSelections.get(threadId);
313 const shouldRestartForModelOptionsChange =306 const shouldRestartForModelSelectionChange =
314 currentProvider === "claudeAgent" &&307 currentProvider === "claudeAgent" &&
315 options?.modelOptions !== undefined &&308 requestedModelSelection !== undefined &&
316 !sameModelOptions(previousModelOptions, options.modelOptions);309 !sameModelSelectionOptions(previousModelSelection, requestedModelSelection);
317 310
318 if (311 if (
319 !runtimeModeChanged &&312 !runtimeModeChanged &&
320 !providerChanged &&313 !providerChanged &&
321 !shouldRestartForModelChange &&314 !shouldRestartForModelChange &&
322 !shouldRestartForModelOptionsChange315 !shouldRestartForModelSelectionChange
323 ) {316 ) {
324 return existingSessionThreadId;317 return existingSessionThreadId;
325 }318 }
@@ -339,7 +332,7 @@ const make = Effect.gen(function* () { · 13 unmodified lines
339 providerChanged,332 providerChanged,
340 modelChanged,333 modelChanged,
341 shouldRestartForModelChange,334 shouldRestartForModelChange,
342 shouldRestartForModelOptionsChange,335 shouldRestartForModelSelectionChange,
343 hasResumeCursor: resumeCursor !== undefined,336 hasResumeCursor: resumeCursor !== undefined,
344 });337 });
345 const restartedSession = yield* startProviderSession(338 const restartedSession = yield* startProviderSession(
@@ -366,7 +359,6 @@ const make = Effect.gen(function* () { · 20 unmodified lines
366 readonly messageText: string;359 readonly messageText: string;
367 readonly attachments?: ReadonlyArray<ChatAttachment>;360 readonly attachments?: ReadonlyArray<ChatAttachment>;
368 readonly modelSelection?: ModelSelection;361 readonly modelSelection?: ModelSelection;
369 readonly modelOptions?: ProviderModelOptions;
370 readonly providerOptions?: ProviderStartOptions;362 readonly providerOptions?: ProviderStartOptions;
371 readonly interactionMode?: "default" | "plan";363 readonly interactionMode?: "default" | "plan";
372 readonly createdAt: string;364 readonly createdAt: string;
@@ -377,14 +369,13 @@ const make = Effect.gen(function* () { · 4 unmodified lines
377 }369 }
378 yield* ensureSessionForThread(input.threadId, input.createdAt, {370 yield* ensureSessionForThread(input.threadId, input.createdAt, {
379 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),371 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
380 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
381 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),372 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
382 });373 });
383 if (input.providerOptions !== undefined) {374 if (input.providerOptions !== undefined) {
384 threadProviderOptions.set(input.threadId, input.providerOptions);375 threadProviderOptions.set(input.threadId, input.providerOptions);
385 }376 }
386 if (input.modelOptions !== undefined) {377 if (input.modelSelection !== undefined) {
387 threadModelOptions.set(input.threadId, input.modelOptions);378 threadModelSelections.set(input.threadId, input.modelSelection);
388 }379 }
389 const normalizedInput = toNonEmptyProviderInput(input.messageText);380 const normalizedInput = toNonEmptyProviderInput(input.messageText);
390 const normalizedAttachments = input.attachments ?? [];381 const normalizedAttachments = input.attachments ?? [];
@@ -398,14 +389,20 @@ const make = Effect.gen(function* () { · 7 unmodified lines
398 ? "in-session"389 ? "in-session"
399 : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch;390 : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch;
400 const modelForTurn =391 const modelForTurn =
401 sessionModelSwitch === "unsupported" ? activeSession?.model : input.modelSelection?.model;392 sessionModelSwitch === "unsupported"
393 ? input.modelSelection
394 ? {
395 ...input.modelSelection,
396 model: activeSession?.model ?? input.modelSelection.model,
397 }
398 : undefined
399 : input.modelSelection;
402 400
403 yield* providerService.sendTurn({401 yield* providerService.sendTurn({
404 threadId: input.threadId,402 threadId: input.threadId,
405 ...(normalizedInput ? { input: normalizedInput } : {}),403 ...(normalizedInput ? { input: normalizedInput } : {}),
406 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),404 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
407 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),405 ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}),
408 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
409 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),406 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
410 });407 });
411 });408 });
@@ -514,10 +511,6 @@ const make = Effect.gen(function* () { · 102 unmodified lines
514 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),511 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
515 }).pipe(Effect.forkScoped);512 }).pipe(Effect.forkScoped);
516 513
517 const requestedModelOptions = event.payload.modelSelection
518 ? toProviderModelOptions(event.payload.modelSelection)
519 : undefined;
520
521 yield* sendTurnForThread({514 yield* sendTurnForThread({
522 threadId: event.payload.threadId,515 threadId: event.payload.threadId,
523 messageText: message.text,516 messageText: message.text,
@@ -525,7 +518,6 @@ const make = Effect.gen(function* () { · 1 unmodified line
525 ...(event.payload.modelSelection !== undefined518 ...(event.payload.modelSelection !== undefined
526 ? { modelSelection: event.payload.modelSelection }519 ? { modelSelection: event.payload.modelSelection }
527 : {}),520 : {}),
528 ...(requestedModelOptions !== undefined ? { modelOptions: requestedModelOptions } : {}),
529 ...(event.payload.providerOptions !== undefined521 ...(event.payload.providerOptions !== undefined
530 ? { providerOptions: event.payload.providerOptions }522 ? { providerOptions: event.payload.providerOptions }
531 : {}),523 : {}),
@@ -695,12 +687,12 @@ const make = Effect.gen(function* () { · 163 unmodified lines
695 return;687 return;
696 }688 }
697 const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);689 const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);
698 const cachedModelOptions = threadModelOptions.get(event.payload.threadId);690 const cachedModelSelection = threadModelSelections.get(event.payload.threadId);
699 yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {691 yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {
700 ...(cachedProviderOptions !== undefined692 ...(cachedProviderOptions !== undefined
701 ? { providerOptions: cachedProviderOptions }693 ? { providerOptions: cachedProviderOptions }
702 : {}),694 : {}),
703 ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}),695 ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}),
704 });696 });
705 return;697 return;
706 }698 }
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+52−37
@@ -332,13 +332,14 @@ describe("ClaudeAdapterLive", () => { · 331 unmodified lines
332 yield* adapter.startSession({332 yield* adapter.startSession({
333 threadId: THREAD_ID,333 threadId: THREAD_ID,
334 provider: "claudeAgent",334 provider: "claudeAgent",
335 model: "claude-opus-4-6",335 modelSelection: {
336 runtimeMode: "full-access",336 provider: "claudeAgent",
337 modelOptions: {337 model: "claude-opus-4-6",
338 claudeAgent: {338 options: {
339 effort: "max",339 effort: "max",
340 },340 },
341 },341 },
342 runtimeMode: "full-access",
342 });343 });
343 344
344 const createInput = harness.getLastCreateQueryInput();345 const createInput = harness.getLastCreateQueryInput();
@@ -356,13 +357,14 @@ describe("ClaudeAdapterLive", () => { · 11 unmodified lines
356 yield* adapter.startSession({357 yield* adapter.startSession({
357 threadId: THREAD_ID,358 threadId: THREAD_ID,
358 provider: "claudeAgent",359 provider: "claudeAgent",
359 model: "claude-sonnet-4-6",360 modelSelection: {
360 runtimeMode: "full-access",361 provider: "claudeAgent",
361 modelOptions: {362 model: "claude-sonnet-4-6",
362 claudeAgent: {363 options: {
363 effort: "max",364 effort: "max",
364 },365 },
365 },366 },
367 runtimeMode: "full-access",
366 });368 });
367 369
368 const createInput = harness.getLastCreateQueryInput();370 const createInput = harness.getLastCreateQueryInput();
@@ -380,13 +382,14 @@ describe("ClaudeAdapterLive", () => { · 11 unmodified lines
380 yield* adapter.startSession({382 yield* adapter.startSession({
381 threadId: THREAD_ID,383 threadId: THREAD_ID,
382 provider: "claudeAgent",384 provider: "claudeAgent",
383 model: "claude-haiku-4-5",385 modelSelection: {
384 runtimeMode: "full-access",386 provider: "claudeAgent",
385 modelOptions: {387 model: "claude-haiku-4-5",
386 claudeAgent: {388 options: {
387 effort: "high",389 effort: "high",
388 },390 },
389 },391 },
392 runtimeMode: "full-access",
390 });393 });
391 394
392 const createInput = harness.getLastCreateQueryInput();395 const createInput = harness.getLastCreateQueryInput();
@@ -404,13 +407,14 @@ describe("ClaudeAdapterLive", () => { · 11 unmodified lines
404 yield* adapter.startSession({407 yield* adapter.startSession({
405 threadId: THREAD_ID,408 threadId: THREAD_ID,
406 provider: "claudeAgent",409 provider: "claudeAgent",
407 model: "claude-haiku-4-5",410 modelSelection: {
408 runtimeMode: "full-access",411 provider: "claudeAgent",
409 modelOptions: {412 model: "claude-haiku-4-5",
410 claudeAgent: {413 options: {
411 thinking: false,414 thinking: false,
412 },415 },
413 },416 },
417 runtimeMode: "full-access",
414 });418 });
415 419
416 const createInput = harness.getLastCreateQueryInput();420 const createInput = harness.getLastCreateQueryInput();
@@ -430,13 +434,14 @@ describe("ClaudeAdapterLive", () => { · 13 unmodified lines
430 yield* adapter.startSession({434 yield* adapter.startSession({
431 threadId: THREAD_ID,435 threadId: THREAD_ID,
432 provider: "claudeAgent",436 provider: "claudeAgent",
433 model: "claude-sonnet-4-6",437 modelSelection: {
434 runtimeMode: "full-access",438 provider: "claudeAgent",
435 modelOptions: {439 model: "claude-sonnet-4-6",
436 claudeAgent: {440 options: {
437 thinking: false,441 thinking: false,
438 },442 },
439 },443 },
444 runtimeMode: "full-access",
440 });445 });
441 446
442 const createInput = harness.getLastCreateQueryInput();447 const createInput = harness.getLastCreateQueryInput();
@@ -454,13 +459,14 @@ describe("ClaudeAdapterLive", () => { · 11 unmodified lines
454 yield* adapter.startSession({459 yield* adapter.startSession({
455 threadId: THREAD_ID,460 threadId: THREAD_ID,
456 provider: "claudeAgent",461 provider: "claudeAgent",
457 model: "claude-opus-4-6",462 modelSelection: {
458 runtimeMode: "full-access",463 provider: "claudeAgent",
459 modelOptions: {464 model: "claude-opus-4-6",
460 claudeAgent: {465 options: {
461 fastMode: true,466 fastMode: true,
462 },467 },
463 },468 },
469 runtimeMode: "full-access",
464 });470 });
465 471
466 const createInput = harness.getLastCreateQueryInput();472 const createInput = harness.getLastCreateQueryInput();
@@ -480,13 +486,14 @@ describe("ClaudeAdapterLive", () => { · 13 unmodified lines
480 yield* adapter.startSession({486 yield* adapter.startSession({
481 threadId: THREAD_ID,487 threadId: THREAD_ID,
482 provider: "claudeAgent",488 provider: "claudeAgent",
483 model: "claude-sonnet-4-6",489 modelSelection: {
484 runtimeMode: "full-access",490 provider: "claudeAgent",
485 modelOptions: {491 model: "claude-sonnet-4-6",
486 claudeAgent: {492 options: {
487 fastMode: true,493 fastMode: true,
488 },494 },
489 },495 },
496 runtimeMode: "full-access",
490 });497 });
491 498
492 const createInput = harness.getLastCreateQueryInput();499 const createInput = harness.getLastCreateQueryInput();
@@ -504,22 +511,24 @@ describe("ClaudeAdapterLive", () => { · 11 unmodified lines
504 const session = yield* adapter.startSession({511 const session = yield* adapter.startSession({
505 threadId: THREAD_ID,512 threadId: THREAD_ID,
506 provider: "claudeAgent",513 provider: "claudeAgent",
507 model: "claude-sonnet-4-6",514 modelSelection: {
508 runtimeMode: "full-access",515 provider: "claudeAgent",
509 modelOptions: {516 model: "claude-sonnet-4-6",
510 claudeAgent: {517 options: {
511 effort: "ultrathink",518 effort: "ultrathink",
512 },519 },
513 },520 },
521 runtimeMode: "full-access",
514 });522 });
515 523
516 yield* adapter.sendTurn({524 yield* adapter.sendTurn({
517 threadId: session.threadId,525 threadId: session.threadId,
518 input: "Investigate the edge cases",526 input: "Investigate the edge cases",
519 attachments: [],527 attachments: [],
520 model: "claude-sonnet-4-6",528 modelSelection: {
521 modelOptions: {529 provider: "claudeAgent",
522 claudeAgent: {530 model: "claude-sonnet-4-6",
531 options: {
523 effort: "ultrathink",532 effort: "ultrathink",
524 },533 },
525 },534 },
@@ -613,7 +622,10 @@ describe("ClaudeAdapterLive", () => { · 87 unmodified lines
613 const session = yield* adapter.startSession({622 const session = yield* adapter.startSession({
614 threadId: THREAD_ID,623 threadId: THREAD_ID,
615 provider: "claudeAgent",624 provider: "claudeAgent",
616 model: "claude-sonnet-4-5",625 modelSelection: {
626 provider: "claudeAgent",
627 model: "claude-sonnet-4-5",
628 },
617 runtimeMode: "full-access",629 runtimeMode: "full-access",
618 });630 });
619 631
@@ -2239,7 +2251,10 @@ describe("ClaudeAdapterLive", () => { · 1619 unmodified lines
2239 yield* adapter.sendTurn({2251 yield* adapter.sendTurn({
2240 threadId: session.threadId,2252 threadId: session.threadId,
2241 input: "hello",2253 input: "hello",
2242 model: "claude-opus-4-6",2254 modelSelection: {
2255 provider: "claudeAgent",
2256 model: "claude-opus-4-6",
2257 },
2243 attachments: [],2258 attachments: [],
2244 });2259 });
2245 2260
apps/server/src/provider/Layers/ClaudeAdapter.ts+29−15
@@ -436,11 +436,17 @@ const CLAUDE_SETTING_SOURCES = [ · 435 unmodified lines
436function buildPromptText(input: ProviderSendTurnInput): string {436function buildPromptText(input: ProviderSendTurnInput): string {
437 const requestedEffort = resolveReasoningEffortForProvider(437 const requestedEffort = resolveReasoningEffortForProvider(
438 "claudeAgent",438 "claudeAgent",
439 input.modelOptions?.claudeAgent?.effort ?? null,439 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null,
440 );
441 const supportedEffortOptions = getReasoningEffortOptions(
442 "claudeAgent",
443 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined,
440 );444 );
441 const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model);
442 const promptEffort =445 const promptEffort =
443 requestedEffort === "ultrathink" && supportsClaudeUltrathinkKeyword(input.model)446 requestedEffort === "ultrathink" &&
447 supportsClaudeUltrathinkKeyword(
448 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined,
449 )
444 ? "ultrathink"450 ? "ultrathink"
445 : requestedEffort && supportedEffortOptions.includes(requestedEffort)451 : requestedEffort && supportedEffortOptions.includes(requestedEffort)
446 ? requestedEffort452 ? requestedEffort
@@ -2542,21 +2548,27 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 2095 unmodified lines
2542 );2548 );
2543 2549
2544 const providerOptions = input.providerOptions?.claudeAgent;2550 const providerOptions = input.providerOptions?.claudeAgent;
2551 const modelSelection =
2552 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
2545 const requestedEffort = resolveReasoningEffortForProvider(2553 const requestedEffort = resolveReasoningEffortForProvider(
2546 "claudeAgent",2554 "claudeAgent",
2547 input.modelOptions?.claudeAgent?.effort ?? null,2555 modelSelection?.options?.effort ?? null,
2556 );
2557 const supportedEffortOptions = getReasoningEffortOptions(
2558 "claudeAgent",
2559 modelSelection?.model,
2548 );2560 );
2549 const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model);
2550 const effort =2561 const effort =
2551 requestedEffort && supportedEffortOptions.includes(requestedEffort)2562 requestedEffort && supportedEffortOptions.includes(requestedEffort)
2552 ? requestedEffort2563 ? requestedEffort
2553 : null;2564 : null;
2554 const fastMode =2565 const fastMode =
2555 input.modelOptions?.claudeAgent?.fastMode === true && supportsClaudeFastMode(input.model);2566 modelSelection?.options?.fastMode === true &&
2567 supportsClaudeFastMode(modelSelection?.model);
2556 const thinking =2568 const thinking =
2557 typeof input.modelOptions?.claudeAgent?.thinking === "boolean" &&2569 typeof modelSelection?.options?.thinking === "boolean" &&
2558 supportsClaudeThinkingToggle(input.model)2570 supportsClaudeThinkingToggle(modelSelection?.model)
2559 ? input.modelOptions.claudeAgent.thinking2571 ? modelSelection.options.thinking
2560 : undefined;2572 : undefined;
2561 const effectiveEffort = getEffectiveClaudeCodeEffort(effort);2573 const effectiveEffort = getEffectiveClaudeCodeEffort(effort);
2562 const permissionMode =2574 const permissionMode =
@@ -2569,7 +2581,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 6 unmodified lines
2569 2581
2570 const queryOptions: ClaudeQueryOptions = {2582 const queryOptions: ClaudeQueryOptions = {
2571 ...(input.cwd ? { cwd: input.cwd } : {}),2583 ...(input.cwd ? { cwd: input.cwd } : {}),
2572 ...(input.model ? { model: input.model } : {}),2584 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
2573 pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude",2585 pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude",
2574 settingSources: [...CLAUDE_SETTING_SOURCES],2586 settingSources: [...CLAUDE_SETTING_SOURCES],
2575 ...(effectiveEffort ? { effort: effectiveEffort } : {}),2587 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
@@ -2610,7 +2622,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 34 unmodified lines
2610 status: "ready",2622 status: "ready",
2611 runtimeMode: input.runtimeMode,2623 runtimeMode: input.runtimeMode,
2612 ...(input.cwd ? { cwd: input.cwd } : {}),2624 ...(input.cwd ? { cwd: input.cwd } : {}),
2613 ...(input.model ? { model: input.model } : {}),2625 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
2614 ...(threadId ? { threadId } : {}),2626 ...(threadId ? { threadId } : {}),
2615 resumeCursor: {2627 resumeCursor: {
2616 ...(threadId ? { threadId } : {}),2628 ...(threadId ? { threadId } : {}),
@@ -2664,7 +2676,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 47 unmodified lines
2664 threadId,2676 threadId,
2665 payload: {2677 payload: {
2666 config: {2678 config: {
2667 ...(input.model ? { model: input.model } : {}),2679 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
2668 ...(input.cwd ? { cwd: input.cwd } : {}),2680 ...(input.cwd ? { cwd: input.cwd } : {}),
2669 ...(effectiveEffort ? { effort: effectiveEffort } : {}),2681 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
2670 ...(permissionMode ? { permissionMode } : {}),2682 ...(permissionMode ? { permissionMode } : {}),
@@ -2710,6 +2722,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 39 unmodified lines
2710 const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) =>2722 const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) =>
2711 Effect.gen(function* () {2723 Effect.gen(function* () {
2712 const context = yield* requireSession(input.threadId);2724 const context = yield* requireSession(input.threadId);
2725 const modelSelection =
2726 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
2713 2727
2714 if (context.turnState) {2728 if (context.turnState) {
2715 // Auto-close a stale synthetic turn (from background agent responses2729 // Auto-close a stale synthetic turn (from background agent responses
@@ -2717,9 +2731,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 1 unmodified line
2717 yield* completeTurn(context, "completed");2731 yield* completeTurn(context, "completed");
2718 }2732 }
2719 2733
2720 if (input.model) {2734 if (modelSelection?.model) {
2721 yield* Effect.tryPromise({2735 yield* Effect.tryPromise({
2722 try: () => context.query.setModel(input.model),2736 try: () => context.query.setModel(modelSelection.model),
2723 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),2737 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),
2724 });2738 });
2725 }2739 }
@@ -2769,7 +2783,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { · 43 unmodified lines
2769 createdAt: turnStartedStamp.createdAt,2783 createdAt: turnStartedStamp.createdAt,
2770 threadId: context.session.threadId,2784 threadId: context.session.threadId,
2771 turnId,2785 turnId,
2772 payload: input.model ? { model: input.model } : {},2786 payload: modelSelection?.model ? { model: modelSelection.model } : {},
2773 providerRefs: {},2787 providerRefs: {},
2774 });2788 });
2775 2789
apps/server/src/provider/Layers/CodexAdapter.test.ts+8−6
@@ -188,9 +188,10 @@ validationLayer("CodexAdapterLive validation", (it) => { · 187 unmodified lines
188 yield* adapter.startSession({188 yield* adapter.startSession({
189 provider: "codex",189 provider: "codex",
190 threadId: asThreadId("thread-1"),190 threadId: asThreadId("thread-1"),
191 model: "gpt-5.3-codex",191 modelSelection: {
192 modelOptions: {192 provider: "codex",
193 codex: {193 model: "gpt-5.3-codex",
194 options: {
194 fastMode: true,195 fastMode: true,
195 },196 },
196 },197 },
@@ -256,9 +257,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { · 59 unmodified lines
256 adapter.sendTurn({257 adapter.sendTurn({
257 threadId: asThreadId("sess-missing"),258 threadId: asThreadId("sess-missing"),
258 input: "hello",259 input: "hello",
259 model: "gpt-5.3-codex",260 modelSelection: {
260 modelOptions: {261 provider: "codex",
261 codex: {262 model: "gpt-5.3-codex",
263 options: {
262 reasoningEffort: "high",264 reasoningEffort: "high",
263 fastMode: true,265 fastMode: true,
264 },266 },
apps/server/src/provider/Layers/CodexAdapter.ts+16−6
@@ -1313,8 +1313,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => · 1312 unmodified lines
1313 ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),1313 ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
1314 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),1314 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
1315 runtimeMode: input.runtimeMode,1315 runtimeMode: input.runtimeMode,
1316 ...(input.model !== undefined ? { model: input.model } : {}),1316 ...(input.modelSelection?.provider === "codex"
1317 ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}),1317 ? { model: input.modelSelection.model }
1318 : {}),
1319 ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode
1320 ? { serviceTier: "fast" }
1321 : {}),
1318 };1322 };
1319 1323
1320 return Effect.tryPromise({1324 return Effect.tryPromise({
@@ -1370,11 +1374,17 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => · 49 unmodified lines
1370 const managerInput = {1374 const managerInput = {
1371 threadId: input.threadId,1375 threadId: input.threadId,
1372 ...(input.input !== undefined ? { input: input.input } : {}),1376 ...(input.input !== undefined ? { input: input.input } : {}),
1373 ...(input.model !== undefined ? { model: input.model } : {}),1377 ...(input.modelSelection?.provider === "codex"
1374 ...(input.modelOptions?.codex?.reasoningEffort !== undefined1378 ? { model: input.modelSelection.model }
1375 ? { effort: input.modelOptions.codex.reasoningEffort }1379 : {}),
1380 ...(input.modelSelection?.provider === "codex" &&
1381 input.modelSelection.options?.reasoningEffort !== undefined
1382 ? { effort: input.modelSelection.options.reasoningEffort }
1383 : {}),
1384 ...(input.modelSelection?.provider === "codex" &&
1385 input.modelSelection.options?.fastMode
1386 ? { serviceTier: "fast" }
1376 : {}),1387 : {}),
1377 ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}),
1378 ...(input.interactionMode !== undefined1388 ...(input.interactionMode !== undefined
1379 ? { interactionMode: input.interactionMode }1389 ? { interactionMode: input.interactionMode }
1380 : {}),1390 : {}),
apps/server/src/provider/Layers/ProviderService.test.ts+9−5
@@ -633,8 +633,10 @@ routing.layer("ProviderServiceLive routing", (it) => { · 632 unmodified lines
633 provider: "claudeAgent",633 provider: "claudeAgent",
634 threadId: asThreadId("thread-claude-send-turn"),634 threadId: asThreadId("thread-claude-send-turn"),
635 cwd: "/tmp/project-claude-send-turn",635 cwd: "/tmp/project-claude-send-turn",
636 modelOptions: {636 modelSelection: {
637 claudeAgent: {637 provider: "claudeAgent",
638 model: "claude-opus-4-6",
639 options: {
638 effort: "max",640 effort: "max",
639 },641 },
640 },642 },
@@ -658,14 +660,16 @@ routing.layer("ProviderServiceLive routing", (it) => { · 17 unmodified lines
658 const startPayload = resumedStartInput as {660 const startPayload = resumedStartInput as {
659 provider?: string;661 provider?: string;
660 cwd?: string;662 cwd?: string;
661 modelOptions?: unknown;663 modelSelection?: unknown;
662 resumeCursor?: unknown;664 resumeCursor?: unknown;
663 threadId?: string;665 threadId?: string;
664 };666 };
665 assert.equal(startPayload.provider, "claudeAgent");667 assert.equal(startPayload.provider, "claudeAgent");
666 assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn");668 assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn");
667 assert.deepEqual(startPayload.modelOptions, {669 assert.deepEqual(startPayload.modelSelection, {
668 claudeAgent: {670 provider: "claudeAgent",
671 model: "claude-opus-4-6",
672 options: {
669 effort: "max",673 effort: "max",
670 },674 },
671 });675 });
apps/server/src/provider/Layers/ProviderService.ts+16−13
@@ -10,6 +10,7 @@ · 9 unmodified lines
10 * @module ProviderServiceLive10 * @module ProviderServiceLive
11 */11 */
12import {12import {
13 ModelSelection,
13 NonNegativeInt,14 NonNegativeInt,
14 ThreadId,15 ThreadId,
15 ProviderInterruptTurnInput,16 ProviderInterruptTurnInput,
@@ -89,7 +90,7 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st · 73 unmodified lines
89function toRuntimePayloadFromSession(90function toRuntimePayloadFromSession(
90 session: ProviderSession,91 session: ProviderSession,
91 extra?: {92 extra?: {
92 readonly modelOptions?: unknown;93 readonly modelSelection?: unknown;
93 readonly providerOptions?: unknown;94 readonly providerOptions?: unknown;
94 readonly lastRuntimeEvent?: string;95 readonly lastRuntimeEvent?: string;
95 readonly lastRuntimeEventAt?: string;96 readonly lastRuntimeEventAt?: string;
@@ -100,7 +101,7 @@ function toRuntimePayloadFromSession( · 4 unmodified lines
100 model: session.model ?? null,101 model: session.model ?? null,
101 activeTurnId: session.activeTurnId ?? null,102 activeTurnId: session.activeTurnId ?? null,
102 lastError: session.lastError ?? null,103 lastError: session.lastError ?? null,
103 ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}),104 ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}),
104 ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}),105 ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}),
105 ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),106 ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),
106 ...(extra?.lastRuntimeEventAt !== undefined107 ...(extra?.lastRuntimeEventAt !== undefined
@@ -109,15 +110,14 @@ function toRuntimePayloadFromSession( · 2 unmodified lines
109 };110 };
110}111}
111 112
112function readPersistedModelOptions(113function readPersistedModelSelection(
113 runtimePayload: ProviderRuntimeBinding["runtimePayload"],114 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
114): Record<string, unknown> | undefined {115): ModelSelection | undefined {
115 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {116 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
116 return undefined;117 return undefined;
117 }118 }
118 const raw = "modelOptions" in runtimePayload ? runtimePayload.modelOptions : undefined;119 const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined;
119 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;120 return Schema.is(ModelSelection)(raw) ? raw : undefined;
120 return raw as Record<string, unknown>;
121}121}
122 122
123function readPersistedProviderOptions(123function readPersistedProviderOptions(
@@ -172,7 +172,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 48 unmodified lines
172 session: ProviderSession,172 session: ProviderSession,
173 threadId: ThreadId,173 threadId: ThreadId,
174 extra?: {174 extra?: {
175 readonly modelOptions?: unknown;175 readonly modelSelection?: unknown;
176 readonly providerOptions?: unknown;176 readonly providerOptions?: unknown;
177 readonly lastRuntimeEvent?: string;177 readonly lastRuntimeEvent?: string;
178 readonly lastRuntimeEventAt?: string;178 readonly lastRuntimeEventAt?: string;
@@ -238,14 +238,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 59 unmodified lines
238 }238 }
239 239
240 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);240 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
241 const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload);241 const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload);
242 const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload);242 const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload);
243 243
244 const resumed = yield* adapter.startSession({244 const resumed = yield* adapter.startSession({
245 threadId: input.binding.threadId,245 threadId: input.binding.threadId,
246 provider: input.binding.provider,246 provider: input.binding.provider,
247 ...(persistedCwd ? { cwd: persistedCwd } : {}),247 ...(persistedCwd ? { cwd: persistedCwd } : {}),
248 ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}),248 ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}),
249 ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}),249 ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}),
250 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),250 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
251 runtimeMode: input.binding.runtimeMode ?? "full-access",251 runtimeMode: input.binding.runtimeMode ?? "full-access",
@@ -328,7 +328,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 76 unmodified lines
328 }328 }
329 329
330 yield* upsertSessionBinding(session, threadId, {330 yield* upsertSessionBinding(session, threadId, {
331 modelOptions: input.modelOptions,331 modelSelection: input.modelSelection,
332 providerOptions: input.providerOptions,332 providerOptions: input.providerOptions,
333 });333 });
334 yield* analytics.record("provider.session.started", {334 yield* analytics.record("provider.session.started", {
@@ -336,7 +336,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 1 unmodified line
336 runtimeMode: input.runtimeMode,336 runtimeMode: input.runtimeMode,
337 hasResumeCursor: session.resumeCursor !== undefined,337 hasResumeCursor: session.resumeCursor !== undefined,
338 hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0,338 hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0,
339 hasModel: typeof input.model === "string" && input.model.trim().length > 0,339 hasModel:
340 typeof input.modelSelection?.model === "string" &&
341 input.modelSelection.model.trim().length > 0,
340 });342 });
341 343
342 return session;344 return session;
@@ -372,6 +374,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 29 unmodified lines
372 status: "running",374 status: "running",
373 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),375 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
374 runtimePayload: {376 runtimePayload: {
377 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
375 activeTurnId: turn.turnId,378 activeTurnId: turn.turnId,
376 lastRuntimeEvent: "provider.sendTurn",379 lastRuntimeEvent: "provider.sendTurn",
377 lastRuntimeEventAt: new Date().toISOString(),380 lastRuntimeEventAt: new Date().toISOString(),
@@ -379,7 +382,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => · 1 unmodified line
379 });382 });
380 yield* analytics.record("provider.turn.sent", {383 yield* analytics.record("provider.turn.sent", {
381 provider: routed.adapter.provider,384 provider: routed.adapter.provider,
382 model: input.model,385 model: input.modelSelection?.model,
383 interactionMode: input.interactionMode,386 interactionMode: input.interactionMode,
384 attachmentCount: input.attachments.length,387 attachmentCount: input.attachments.length,
385 hasInput: typeof input.input === "string" && input.input.trim().length > 0,388 hasInput: typeof input.input === "string" && input.input.trim().length > 0,
apps/web/src/components/ChatView.tsx+3−19
@@ -7,7 +7,6 @@ import { · 6 unmodified lines
7 type ProjectScript,7 type ProjectScript,
8 type ModelSlug,8 type ModelSlug,
9 type ProviderKind,9 type ProviderKind,
10 type ProviderModelOptions,
11 type ProjectEntry,10 type ProjectEntry,
12 type ProjectId,11 type ProjectId,
13 type ProviderApprovalDecision,12 type ProviderApprovalDecision,
@@ -28,7 +27,6 @@ import { · 14 unmodified lines
28 getDefaultModel,27 getDefaultModel,
29 normalizeModelSlug,28 normalizeModelSlug,
30 resolveModelSlugForProvider,29 resolveModelSlugForProvider,
31 toProviderModelOptions,
32} from "@t3tools/shared/model";30} from "@t3tools/shared/model";
33import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";31import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
34import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";32import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -205,12 +203,6 @@ function formatOutgoingPrompt(params: { · 170 unmodified lines
205 }203 }
206 return params.text;204 return params.text;
207}205}
208function extractModelSelectionOptions(
209 provider: ProviderKind,
210 modelOptions: ProviderModelOptions | undefined,
211): ModelSelection["options"] | undefined {
212 return provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent;
213}
214const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;206const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
215const SCRIPT_TERMINAL_COLS = 120;207const SCRIPT_TERMINAL_COLS = 120;
216const SCRIPT_TERMINAL_ROWS = 30;208const SCRIPT_TERMINAL_ROWS = 30;
@@ -630,16 +622,15 @@ export default function ChatView({ threadId }: ChatViewProps) { · 413 unmodified lines
630 customModelsByProvider,622 customModelsByProvider,
631 selectedProvider,623 selectedProvider,
632 ]);624 ]);
633 const draftModelOptions = toProviderModelOptions(composerDraft.modelSelection);
634 const composerProviderState = useMemo(625 const composerProviderState = useMemo(
635 () =>626 () =>
636 getComposerProviderState({627 getComposerProviderState({
637 provider: selectedProvider,628 provider: selectedProvider,
638 model: selectedModel,629 model: selectedModel,
639 prompt,630 prompt,
640 modelOptions: draftModelOptions,631 modelOptions: composerDraft.modelSelection?.options,
641 }),632 }),
642 [draftModelOptions, prompt, selectedModel, selectedProvider],633 [composerDraft.modelSelection?.options, prompt, selectedModel, selectedProvider],
643 );634 );
644 const selectedPromptEffort = composerProviderState.promptEffort;635 const selectedPromptEffort = composerProviderState.promptEffort;
645 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;636 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
@@ -647,14 +638,7 @@ export default function ChatView({ threadId }: ChatViewProps) { · 1 unmodified line
647 () => ({638 () => ({
648 provider: selectedProvider,639 provider: selectedProvider,
649 model: selectedModel,640 model: selectedModel,
650 ...(extractModelSelectionOptions(selectedProvider, selectedModelOptionsForDispatch)641 ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}),
651 ? {
652 options: extractModelSelectionOptions(
653 selectedProvider,
654 selectedModelOptionsForDispatch,
655 ),
656 }
657 : {}),
658 }),642 }),
659 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],643 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],
660 );644 );
apps/web/src/components/chat/composerProviderRegistry.test.tsx+9−61
@@ -23,10 +23,8 @@ describe("getComposerProviderState", () => { · 22 unmodified lines
23 model: "gpt-5.4",23 model: "gpt-5.4",
24 prompt: "",24 prompt: "",
25 modelOptions: {25 modelOptions: {
26 codex: {26 reasoningEffort: "low",
27 reasoningEffort: "low",27 fastMode: true,
28 fastMode: true,
29 },
30 },28 },
31 });29 });
32 30
@@ -34,10 +32,8 @@ describe("getComposerProviderState", () => { · 1 unmodified line
34 provider: "codex",32 provider: "codex",
35 promptEffort: "low",33 promptEffort: "low",
36 modelOptionsForDispatch: {34 modelOptionsForDispatch: {
37 codex: {35 reasoningEffort: "low",
38 reasoningEffort: "low",36 fastMode: true,
39 fastMode: true,
40 },
41 },37 },
42 });38 });
43 });39 });
@@ -63,9 +59,7 @@ describe("getComposerProviderState", () => { · 19 unmodified lines
63 model: "claude-sonnet-4-6",59 model: "claude-sonnet-4-6",
64 prompt: "Ultrathink:\nInvestigate this failure",60 prompt: "Ultrathink:\nInvestigate this failure",
65 modelOptions: {61 modelOptions: {
66 claudeAgent: {62 effort: "medium",
67 effort: "medium",
68 },
69 },63 },
70 });64 });
71 65
@@ -73,9 +67,7 @@ describe("getComposerProviderState", () => { · 1 unmodified line
73 provider: "claudeAgent",67 provider: "claudeAgent",
74 promptEffort: "medium",68 promptEffort: "medium",
75 modelOptionsForDispatch: {69 modelOptionsForDispatch: {
76 claudeAgent: {70 effort: "medium",
77 effort: "medium",
78 },
79 },71 },
80 composerFrameClassName: "ultrathink-frame",72 composerFrameClassName: "ultrathink-frame",
81 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]",73 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]",
@@ -89,10 +81,8 @@ describe("getComposerProviderState", () => { · 7 unmodified lines
89 model: "claude-haiku-4-5",81 model: "claude-haiku-4-5",
90 prompt: "",82 prompt: "",
91 modelOptions: {83 modelOptions: {
92 claudeAgent: {84 effort: "max",
93 effort: "max",85 thinking: false,
94 thinking: false,
95 },
96 },86 },
97 });87 });
98 88
@@ -100,50 +90,8 @@ describe("getComposerProviderState", () => { · 1 unmodified line
100 provider: "claudeAgent",90 provider: "claudeAgent",
101 promptEffort: null,91 promptEffort: null,
102 modelOptionsForDispatch: {92 modelOptionsForDispatch: {
103 claudeAgent: {93 thinking: false,
104 thinking: false,
105 },
106 },94 },
107 });95 });
108 });96 });
109
110 it("ignores codex options while resolving Claude state", () => {
111 const state = getComposerProviderState({
112 provider: "claudeAgent",
113 model: "claude-opus-4-6",
114 prompt: "",
115 modelOptions: {
116 codex: {
117 reasoningEffort: "low",
118 fastMode: true,
119 },
120 },
121 });
122
123 expect(state).toEqual({
124 provider: "claudeAgent",
125 promptEffort: "high",
126 modelOptionsForDispatch: undefined,
127 });
128 });
129
130 it("ignores Claude options while resolving codex state", () => {
131 const state = getComposerProviderState({
132 provider: "codex",
133 model: "gpt-5.4",
134 prompt: "Ultrathink:\nThis should not matter",
135 modelOptions: {
136 claudeAgent: {
137 effort: "max",
138 fastMode: true,
139 },
140 },
141 });
142
143 expect(state).toEqual({
144 provider: "codex",
145 promptEffort: "high",
146 modelOptionsForDispatch: undefined,
147 });
148 });
149});97});
apps/web/src/components/chat/composerProviderRegistry.tsx+27−13
@@ -1,7 +1,9 @@
1import {1import {
2 type ClaudeModelOptions,
3 type CodexModelOptions,
2 type ModelSlug,4 type ModelSlug,
5 type ModelSelection,
3 type ProviderKind,6 type ProviderKind,
4 type ProviderModelOptions,
5 type ThreadId,7 type ThreadId,
6} from "@t3tools/contracts";8} from "@t3tools/contracts";
7import {9import {
@@ -21,13 +23,13 @@ export type ComposerProviderStateInput = { · 13 unmodified lines
21 provider: ProviderKind;23 provider: ProviderKind;
22 model: ModelSlug;24 model: ModelSlug;
23 prompt: string;25 prompt: string;
24 modelOptions: ProviderModelOptions | null | undefined;26 modelOptions: ModelSelection["options"] | null | undefined;
25};27};
26 28
27export type ComposerProviderState = {29export type ComposerProviderState = {
28 provider: ProviderKind;30 provider: ProviderKind;
29 promptEffort: string | null;31 promptEffort: string | null;
30 modelOptionsForDispatch: ProviderModelOptions | undefined;32 modelOptionsForDispatch: ModelSelection["options"] | undefined;
31 composerFrameClassName?: string;33 composerFrameClassName?: string;
32 composerSurfaceClassName?: string;34 composerSurfaceClassName?: string;
33 modelPickerIconClassName?: string;35 modelPickerIconClassName?: string;
@@ -47,20 +49,33 @@ type ProviderRegistryEntry = { · 13 unmodified lines
47 }) => ReactNode;49 }) => ReactNode;
48};50};
49 51
52function asCodexModelOptions(
53 modelOptions: ModelSelection["options"] | null | undefined,
54): CodexModelOptions | undefined {
55 return modelOptions && "reasoningEffort" in modelOptions ? modelOptions : undefined;
56}
57
58function asClaudeModelOptions(
59 modelOptions: ModelSelection["options"] | null | undefined,
60): ClaudeModelOptions | undefined {
61 return modelOptions && ("effort" in modelOptions || "thinking" in modelOptions)
62 ? modelOptions
63 : undefined;
64}
65
50const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {66const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
51 codex: {67 codex: {
52 getState: ({ modelOptions }) => {68 getState: ({ modelOptions }) => {
69 const codexModelOptions = asCodexModelOptions(modelOptions);
53 const promptEffort =70 const promptEffort =
54 resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ??71 resolveReasoningEffortForProvider("codex", codexModelOptions?.reasoningEffort) ??
55 getDefaultReasoningEffort("codex");72 getDefaultReasoningEffort("codex");
56 const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex);73 const normalizedCodexOptions = normalizeCodexModelOptions(codexModelOptions);
57 74
58 return {75 return {
59 provider: "codex",76 provider: "codex",
60 promptEffort,77 promptEffort,
61 modelOptionsForDispatch: normalizedCodexOptions78 modelOptionsForDispatch: normalizedCodexOptions,
62 ? { codex: normalizedCodexOptions }
63 : undefined,
64 };79 };
65 },80 },
66 renderTraitsMenuContent: ({ threadId }) => <CodexTraitsMenuContent threadId={threadId} />,81 renderTraitsMenuContent: ({ threadId }) => <CodexTraitsMenuContent threadId={threadId} />,
@@ -68,10 +83,11 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 1 unmodified line
68 },83 },
69 claudeAgent: {84 claudeAgent: {
70 getState: ({ model, prompt, modelOptions }) => {85 getState: ({ model, prompt, modelOptions }) => {
86 const claudeModelOptions = asClaudeModelOptions(modelOptions);
71 const reasoningOptions = getReasoningEffortOptions("claudeAgent", model);87 const reasoningOptions = getReasoningEffortOptions("claudeAgent", model);
72 const draftEffort = resolveReasoningEffortForProvider(88 const draftEffort = resolveReasoningEffortForProvider(
73 "claudeAgent",89 "claudeAgent",
74 modelOptions?.claudeAgent?.effort,90 claudeModelOptions?.effort,
75 );91 );
76 const defaultEffort = getDefaultReasoningEffort("claudeAgent");92 const defaultEffort = getDefaultReasoningEffort("claudeAgent");
77 const promptEffort =93 const promptEffort =
@@ -80,16 +96,14 @@ const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { · 2 unmodified lines
80 : reasoningOptions.includes(defaultEffort)96 : reasoningOptions.includes(defaultEffort)
81 ? defaultEffort97 ? defaultEffort
82 : null;98 : null;
83 const normalizedClaudeOptions = normalizeClaudeModelOptions(model, modelOptions?.claudeAgent);99 const normalizedClaudeOptions = normalizeClaudeModelOptions(model, claudeModelOptions);
84 const ultrathinkActive =100 const ultrathinkActive =
85 supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt);101 supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt);
86 102
87 return {103 return {
88 provider: "claudeAgent",104 provider: "claudeAgent",
89 promptEffort,105 promptEffort,
90 modelOptionsForDispatch: normalizedClaudeOptions106 modelOptionsForDispatch: normalizedClaudeOptions,
91 ? { claudeAgent: normalizedClaudeOptions }
92 : undefined,
93 ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}),107 ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}),
94 ...(ultrathinkActive108 ...(ultrathinkActive
95 ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" }109 ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" }
packages/contracts/src/orchestration.ts+1−1
@@ -133,7 +133,7 @@ function normalizeLegacyThreadModelSelection( · 132 unmodified lines
133 return Effect.succeed(input.modelSelection);133 return Effect.succeed(input.modelSelection);
134 }134 }
135 if (input.model === undefined) {135 if (input.model === undefined) {
136 return Effect.succeed(undefined);136 return Effect.succeed<ModelSelection | undefined>(undefined);
137 }137 }
138 return Effect.try({138 return Effect.try({
139 try: () =>139 try: () =>
packages/contracts/src/provider.test.ts+46−24
@@ -12,9 +12,10 @@ describe("ProviderSessionStartInput", () => { · 11 unmodified lines
12 threadId: "thread-1",12 threadId: "thread-1",
13 provider: "codex",13 provider: "codex",
14 cwd: "/tmp/workspace",14 cwd: "/tmp/workspace",
15 model: "gpt-5.3-codex",15 modelSelection: {
16 modelOptions: {16 provider: "codex",
17 codex: {17 model: "gpt-5.3-codex",
18 options: {
18 reasoningEffort: "high",19 reasoningEffort: "high",
19 fastMode: true,20 fastMode: true,
20 },21 },
@@ -28,8 +29,13 @@ describe("ProviderSessionStartInput", () => { · 7 unmodified lines
28 },29 },
29 });30 });
30 expect(parsed.runtimeMode).toBe("full-access");31 expect(parsed.runtimeMode).toBe("full-access");
31 expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("high");32 expect(parsed.modelSelection?.provider).toBe("codex");
32 expect(parsed.modelOptions?.codex?.fastMode).toBe(true);33 expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex");
34 if (parsed.modelSelection?.provider !== "codex") {
35 throw new Error("Expected codex modelSelection");
36 }
37 expect(parsed.modelSelection.options?.reasoningEffort).toBe("high");
38 expect(parsed.modelSelection.options?.fastMode).toBe(true);
33 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");39 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");
34 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");40 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");
35 });41 });
@@ -48,9 +54,10 @@ describe("ProviderSessionStartInput", () => { · 12 unmodified lines
48 threadId: "thread-1",54 threadId: "thread-1",
49 provider: "claudeAgent",55 provider: "claudeAgent",
50 cwd: "/tmp/workspace",56 cwd: "/tmp/workspace",
51 model: "claude-sonnet-4-6",57 modelSelection: {
52 modelOptions: {58 provider: "claudeAgent",
53 claudeAgent: {59 model: "claude-sonnet-4-6",
60 options: {
54 thinking: true,61 thinking: true,
55 effort: "max",62 effort: "max",
56 fastMode: true,63 fastMode: true,
@@ -66,9 +73,14 @@ describe("ProviderSessionStartInput", () => { · 9 unmodified lines
66 runtimeMode: "full-access",73 runtimeMode: "full-access",
67 });74 });
68 expect(parsed.provider).toBe("claudeAgent");75 expect(parsed.provider).toBe("claudeAgent");
69 expect(parsed.modelOptions?.claudeAgent?.thinking).toBe(true);76 expect(parsed.modelSelection?.provider).toBe("claudeAgent");
70 expect(parsed.modelOptions?.claudeAgent?.effort).toBe("max");77 expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6");
71 expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true);78 if (parsed.modelSelection?.provider !== "claudeAgent") {
79 throw new Error("Expected claude modelSelection");
80 }
81 expect(parsed.modelSelection.options?.thinking).toBe(true);
82 expect(parsed.modelSelection.options?.effort).toBe("max");
83 expect(parsed.modelSelection.options?.fastMode).toBe(true);
72 expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude");84 expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude");
73 expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan");85 expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan");
74 expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000);86 expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000);
@@ -77,36 +89,46 @@ describe("ProviderSessionStartInput", () => { · 2 unmodified lines
77});89});
78 90
79describe("ProviderSendTurnInput", () => {91describe("ProviderSendTurnInput", () => {
80 it("accepts provider-scoped model options", () => {92 it("accepts codex modelSelection", () => {
81 const parsed = decodeProviderSendTurnInput({93 const parsed = decodeProviderSendTurnInput({
82 threadId: "thread-1",94 threadId: "thread-1",
83 model: "gpt-5.3-codex",95 modelSelection: {
84 modelOptions: {96 provider: "codex",
85 codex: {97 model: "gpt-5.3-codex",
98 options: {
86 reasoningEffort: "xhigh",99 reasoningEffort: "xhigh",
87 fastMode: true,100 fastMode: true,
88 },101 },
89 },102 },
90 });103 });
91 104
92 expect(parsed.model).toBe("gpt-5.3-codex");105 expect(parsed.modelSelection?.provider).toBe("codex");
93 expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh");106 expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex");
94 expect(parsed.modelOptions?.codex?.fastMode).toBe(true);107 if (parsed.modelSelection?.provider !== "codex") {
108 throw new Error("Expected codex modelSelection");
109 }
110 expect(parsed.modelSelection.options?.reasoningEffort).toBe("xhigh");
111 expect(parsed.modelSelection.options?.fastMode).toBe(true);
95 });112 });
96 113
97 it("accepts claude provider effort options including ultrathink", () => {114 it("accepts claude modelSelection including ultrathink", () => {
98 const parsed = decodeProviderSendTurnInput({115 const parsed = decodeProviderSendTurnInput({
99 threadId: "thread-1",116 threadId: "thread-1",
100 model: "claude-sonnet-4-6",117 modelSelection: {
101 modelOptions: {118 provider: "claudeAgent",
102 claudeAgent: {119 model: "claude-sonnet-4-6",
120 options: {
103 effort: "ultrathink",121 effort: "ultrathink",
104 fastMode: true,122 fastMode: true,
105 },123 },
106 },124 },
107 });125 });
108 126
109 expect(parsed.modelOptions?.claudeAgent?.effort).toBe("ultrathink");127 expect(parsed.modelSelection?.provider).toBe("claudeAgent");
110 expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true);128 if (parsed.modelSelection?.provider !== "claudeAgent") {
129 throw new Error("Expected claude modelSelection");
130 }
131 expect(parsed.modelSelection.options?.effort).toBe("ultrathink");
132 expect(parsed.modelSelection.options?.fastMode).toBe(true);
111 });133 });
112});134});
packages/contracts/src/provider.ts+3−5
@@ -1,6 +1,5 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";2import { TrimmedNonEmptyString } from "./baseSchemas";
3import { ProviderModelOptions } from "./model";
4import {3import {
5 ApprovalRequestId,4 ApprovalRequestId,
6 EventId,5 EventId,
@@ -11,6 +10,7 @@ import { · 4 unmodified lines
11} from "./baseSchemas";10} from "./baseSchemas";
12import {11import {
13 ChatAttachment,12 ChatAttachment,
13 ModelSelection,
14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
15 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,15 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
16 ProviderApprovalDecision,16 ProviderApprovalDecision,
@@ -51,8 +51,7 @@ export const ProviderSessionStartInput = Schema.Struct({ · 34 unmodified lines
51 threadId: ThreadId,51 threadId: ThreadId,
52 provider: Schema.optional(ProviderKind),52 provider: Schema.optional(ProviderKind),
53 cwd: Schema.optional(TrimmedNonEmptyString),53 cwd: Schema.optional(TrimmedNonEmptyString),
54 model: Schema.optional(TrimmedNonEmptyString),54 modelSelection: Schema.optional(ModelSelection),
55 modelOptions: Schema.optional(ProviderModelOptions),
56 resumeCursor: Schema.optional(Schema.Unknown),55 resumeCursor: Schema.optional(Schema.Unknown),
57 approvalPolicy: Schema.optional(ProviderApprovalPolicy),56 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
58 sandboxMode: Schema.optional(ProviderSandboxMode),57 sandboxMode: Schema.optional(ProviderSandboxMode),
@@ -69,8 +68,7 @@ export const ProviderSendTurnInput = Schema.Struct({ · 10 unmodified lines
69 attachments: Schema.optional(68 attachments: Schema.optional(
70 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),69 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
71 ),70 ),
72 model: Schema.optional(TrimmedNonEmptyString),71 modelSelection: Schema.optional(ModelSelection),
73 modelOptions: Schema.optional(ProviderModelOptions),
74 interactionMode: Schema.optional(ProviderInteractionMode),72 interactionMode: Schema.optional(ProviderInteractionMode),
75});73});
76export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;74export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
packages/shared/src/model.test.ts+0−40
@@ -27,7 +27,6 @@ import { · 26 unmodified lines
27 supportsClaudeFastMode,27 supportsClaudeFastMode,
28 supportsClaudeMaxEffort,28 supportsClaudeMaxEffort,
29 supportsClaudeThinkingToggle,29 supportsClaudeThinkingToggle,
30 toProviderModelOptions,
31 supportsClaudeUltrathinkKeyword,30 supportsClaudeUltrathinkKeyword,
32} from "./model";31} from "./model";
33 32
@@ -273,45 +272,6 @@ describe("normalizeClaudeModelOptions", () => { · 239 unmodified lines
273 });272 });
274});273});
275 274
276describe("toProviderModelOptions", () => {
277 it("returns undefined when no model options exist", () => {
278 expect(toProviderModelOptions({ provider: "codex", model: "gpt-5.4" })).toBeUndefined();
279 expect(toProviderModelOptions(null)).toBeUndefined();
280 });
281
282 it("scopes codex options under the codex provider key", () => {
283 expect(
284 toProviderModelOptions({
285 provider: "codex",
286 model: "gpt-5.4",
287 options: {
288 fastMode: true,
289 },
290 }),
291 ).toEqual({
292 codex: {
293 fastMode: true,
294 },
295 });
296 });
297
298 it("scopes claude options under the claudeAgent provider key", () => {
299 expect(
300 toProviderModelOptions({
301 provider: "claudeAgent",
302 model: "claude-opus-4-6",
303 options: {
304 effort: "max",
305 },
306 }),
307 ).toEqual({
308 claudeAgent: {
309 effort: "max",
310 },
311 });
312 });
313});
314
315describe("supportsClaudeAdaptiveReasoning", () => {275describe("supportsClaudeAdaptiveReasoning", () => {
316 it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => {276 it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => {
317 expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true);277 expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true);
packages/shared/src/model.ts+0−13
@@ -10,9 +10,7 @@ import { · 9 unmodified lines
10 type ClaudeCodeEffort,10 type ClaudeCodeEffort,
11 type CodexModelOptions,11 type CodexModelOptions,
12 type CodexReasoningEffort,12 type CodexReasoningEffort,
13 type ModelSelection,
14 type ModelSlug,13 type ModelSlug,
15 type ProviderModelOptions,
16 type ProviderReasoningEffort,14 type ProviderReasoningEffort,
17 type ProviderKind,15 type ProviderKind,
18} from "@t3tools/contracts";16} from "@t3tools/contracts";
@@ -268,15 +266,4 @@ export function applyClaudePromptEffortPrefix( · 249 unmodified lines
268 return `Ultrathink:\n${trimmed}`;266 return `Ultrathink:\n${trimmed}`;
269}267}
270 268
271export function toProviderModelOptions(
272 modelSelection: ModelSelection | null | undefined,
273): ProviderModelOptions | undefined {
274 if (!modelSelection?.options) {
275 return undefined;
276 }
277 return modelSelection.provider === "codex"
278 ? { codex: modelSelection.options }
279 : { claudeAgent: modelSelection.options };
280}
281
282export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS };269export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS };