Skip to content

Commit 96d6160

Browse files
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
1 parent 97ff0ac commit 96d6160

16 files changed

Lines changed: 287 additions & 320 deletions

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,10 @@ describe("ProviderCommandReactor", () => {
313313
expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1"));
314314
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
315315
cwd: "/tmp/provider-project",
316-
model: "gpt-5-codex",
316+
modelSelection: {
317+
provider: "codex",
318+
model: "gpt-5-codex",
319+
},
317320
runtimeMode: "approval-required",
318321
});
319322

@@ -355,19 +358,21 @@ describe("ProviderCommandReactor", () => {
355358
await waitFor(() => harness.startSession.mock.calls.length === 1);
356359
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
357360
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
358-
model: "gpt-5.3-codex",
359-
modelOptions: {
360-
codex: {
361+
modelSelection: {
362+
provider: "codex",
363+
model: "gpt-5.3-codex",
364+
options: {
361365
reasoningEffort: "high",
362366
fastMode: true,
363367
},
364368
},
365369
});
366370
expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
367371
threadId: ThreadId.makeUnsafe("thread-1"),
368-
model: "gpt-5.3-codex",
369-
modelOptions: {
370-
codex: {
372+
modelSelection: {
373+
provider: "codex",
374+
model: "gpt-5.3-codex",
375+
options: {
371376
reasoningEffort: "high",
372377
fastMode: true,
373378
},
@@ -406,19 +411,20 @@ describe("ProviderCommandReactor", () => {
406411
await waitFor(() => harness.startSession.mock.calls.length === 1);
407412
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
408413
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
409-
provider: "claudeAgent",
410-
model: "claude-sonnet-4-6",
411-
modelOptions: {
412-
claudeAgent: {
414+
modelSelection: {
415+
provider: "claudeAgent",
416+
model: "claude-sonnet-4-6",
417+
options: {
413418
effort: "max",
414419
},
415420
},
416421
});
417422
expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
418423
threadId: ThreadId.makeUnsafe("thread-1"),
419-
model: "claude-sonnet-4-6",
420-
modelOptions: {
421-
claudeAgent: {
424+
modelSelection: {
425+
provider: "claudeAgent",
426+
model: "claude-sonnet-4-6",
427+
options: {
422428
effort: "max",
423429
},
424430
},
@@ -456,19 +462,20 @@ describe("ProviderCommandReactor", () => {
456462
await waitFor(() => harness.startSession.mock.calls.length === 1);
457463
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
458464
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
459-
provider: "claudeAgent",
460-
model: "claude-opus-4-6",
461-
modelOptions: {
462-
claudeAgent: {
465+
modelSelection: {
466+
provider: "claudeAgent",
467+
model: "claude-opus-4-6",
468+
options: {
463469
fastMode: true,
464470
},
465471
},
466472
});
467473
expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
468474
threadId: ThreadId.makeUnsafe("thread-1"),
469-
model: "claude-opus-4-6",
470-
modelOptions: {
471-
claudeAgent: {
475+
modelSelection: {
476+
provider: "claudeAgent",
477+
model: "claude-opus-4-6",
478+
options: {
472479
fastMode: true,
473480
},
474481
},
@@ -594,12 +601,17 @@ describe("ProviderCommandReactor", () => {
594601
await waitFor(() => harness.sendTurn.mock.calls.length === 1);
595602

596603
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
597-
provider: "codex",
598-
model: "claude-sonnet-4-6",
604+
modelSelection: {
605+
provider: "codex",
606+
model: "claude-sonnet-4-6",
607+
},
599608
});
600609
expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
601610
threadId: ThreadId.makeUnsafe("thread-1"),
602-
model: "claude-sonnet-4-6",
611+
modelSelection: {
612+
provider: "codex",
613+
model: "claude-sonnet-4-6",
614+
},
603615
});
604616
});
605617

@@ -707,10 +719,11 @@ describe("ProviderCommandReactor", () => {
707719
await waitFor(() => harness.startSession.mock.calls.length === 2);
708720
await waitFor(() => harness.sendTurn.mock.calls.length === 2);
709721
expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
710-
provider: "claudeAgent",
711722
resumeCursor: { opaque: "resume-1" },
712-
modelOptions: {
713-
claudeAgent: {
723+
modelSelection: {
724+
provider: "claudeAgent",
725+
model: "claude-sonnet-4-6",
726+
options: {
714727
effort: "max",
715728
},
716729
},
@@ -841,11 +854,12 @@ describe("ProviderCommandReactor", () => {
841854
await waitFor(() => harness.startSession.mock.calls.length === 1);
842855

843856
expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
844-
provider: "claudeAgent",
845-
model: "claude-opus-4-6",
857+
modelSelection: {
858+
provider: "claudeAgent",
859+
model: "claude-opus-4-6",
860+
},
846861
runtimeMode: "approval-required",
847862
});
848-
expect(harness.startSession.mock.calls[0]?.[1]).not.toHaveProperty("modelOptions");
849863
});
850864

851865
it("rejects provider changes after a thread is already bound to a session provider", async () => {

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts

Lines changed: 24 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
EventId,
66
type ModelSelection,
77
type OrchestrationEvent,
8-
type ProviderModelOptions,
98
ProviderKind,
109
type ProviderStartOptions,
1110
type OrchestrationSession,
@@ -16,7 +15,6 @@ import {
1615
} from "@t3tools/contracts";
1716
import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect";
1817
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
19-
import { toProviderModelOptions } from "@t3tools/shared/model";
2018

2119
import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
2220
import { GitCore } from "../../git/Services/GitCore.ts";
@@ -77,9 +75,9 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
7775
const WORKTREE_BRANCH_PREFIX = "t3code";
7876
const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
7977

80-
const sameModelOptions = (
81-
left: ProviderModelOptions | undefined,
82-
right: ProviderModelOptions | undefined,
78+
const sameModelSelectionOptions = (
79+
left: ModelSelection | undefined,
80+
right: ModelSelection | undefined,
8381
): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
8482

8583
function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
@@ -159,7 +157,7 @@ const make = Effect.gen(function* () {
159157
);
160158

161159
const threadProviderOptions = new Map<string, ProviderStartOptions>();
162-
const threadModelOptions = new Map<string, ProviderModelOptions>();
160+
const threadModelSelections = new Map<string, ModelSelection>();
163161

164162
const appendProviderFailureActivity = (input: {
165163
readonly threadId: ThreadId;
@@ -217,7 +215,6 @@ const make = Effect.gen(function* () {
217215
createdAt: string,
218216
options?: {
219217
readonly modelSelection?: ModelSelection;
220-
readonly modelOptions?: ProviderModelOptions;
221218
readonly providerOptions?: ProviderStartOptions;
222219
},
223220
) {
@@ -247,9 +244,6 @@ const make = Effect.gen(function* () {
247244
}
248245
const preferredProvider: ProviderKind = currentProvider ?? threadProvider;
249246
const desiredModelSelection = requestedModelSelection ?? thread.modelSelection;
250-
const desiredModel = desiredModelSelection.model;
251-
const desiredModelOptions =
252-
options?.modelOptions ?? toProviderModelOptions(desiredModelSelection);
253247
const effectiveCwd = resolveThreadWorkspaceCwd({
254248
thread,
255249
projects: readModel.projects,
@@ -268,8 +262,7 @@ const make = Effect.gen(function* () {
268262
threadId,
269263
...(preferredProvider ? { provider: preferredProvider } : {}),
270264
...(effectiveCwd ? { cwd: effectiveCwd } : {}),
271-
...(desiredModel ? { model: desiredModel } : {}),
272-
...(desiredModelOptions !== undefined ? { modelOptions: desiredModelOptions } : {}),
265+
modelSelection: desiredModelSelection,
273266
...(options?.providerOptions !== undefined
274267
? { providerOptions: options.providerOptions }
275268
: {}),
@@ -309,17 +302,17 @@ const make = Effect.gen(function* () {
309302
requestedModelSelection !== undefined &&
310303
requestedModelSelection.model !== activeSession?.model;
311304
const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";
312-
const previousModelOptions = threadModelOptions.get(threadId);
313-
const shouldRestartForModelOptionsChange =
305+
const previousModelSelection = threadModelSelections.get(threadId);
306+
const shouldRestartForModelSelectionChange =
314307
currentProvider === "claudeAgent" &&
315-
options?.modelOptions !== undefined &&
316-
!sameModelOptions(previousModelOptions, options.modelOptions);
308+
requestedModelSelection !== undefined &&
309+
!sameModelSelectionOptions(previousModelSelection, requestedModelSelection);
317310

318311
if (
319312
!runtimeModeChanged &&
320313
!providerChanged &&
321314
!shouldRestartForModelChange &&
322-
!shouldRestartForModelOptionsChange
315+
!shouldRestartForModelSelectionChange
323316
) {
324317
return existingSessionThreadId;
325318
}
@@ -339,7 +332,7 @@ const make = Effect.gen(function* () {
339332
providerChanged,
340333
modelChanged,
341334
shouldRestartForModelChange,
342-
shouldRestartForModelOptionsChange,
335+
shouldRestartForModelSelectionChange,
343336
hasResumeCursor: resumeCursor !== undefined,
344337
});
345338
const restartedSession = yield* startProviderSession(
@@ -366,7 +359,6 @@ const make = Effect.gen(function* () {
366359
readonly messageText: string;
367360
readonly attachments?: ReadonlyArray<ChatAttachment>;
368361
readonly modelSelection?: ModelSelection;
369-
readonly modelOptions?: ProviderModelOptions;
370362
readonly providerOptions?: ProviderStartOptions;
371363
readonly interactionMode?: "default" | "plan";
372364
readonly createdAt: string;
@@ -377,14 +369,13 @@ const make = Effect.gen(function* () {
377369
}
378370
yield* ensureSessionForThread(input.threadId, input.createdAt, {
379371
...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
380-
...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
381372
...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
382373
});
383374
if (input.providerOptions !== undefined) {
384375
threadProviderOptions.set(input.threadId, input.providerOptions);
385376
}
386-
if (input.modelOptions !== undefined) {
387-
threadModelOptions.set(input.threadId, input.modelOptions);
377+
if (input.modelSelection !== undefined) {
378+
threadModelSelections.set(input.threadId, input.modelSelection);
388379
}
389380
const normalizedInput = toNonEmptyProviderInput(input.messageText);
390381
const normalizedAttachments = input.attachments ?? [];
@@ -398,14 +389,20 @@ const make = Effect.gen(function* () {
398389
? "in-session"
399390
: (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch;
400391
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;
402400

403401
yield* providerService.sendTurn({
404402
threadId: input.threadId,
405403
...(normalizedInput ? { input: normalizedInput } : {}),
406404
...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
407-
...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
408-
...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
405+
...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}),
409406
...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
410407
});
411408
});
@@ -514,18 +511,13 @@ const make = Effect.gen(function* () {
514511
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
515512
}).pipe(Effect.forkScoped);
516513

517-
const requestedModelOptions = event.payload.modelSelection
518-
? toProviderModelOptions(event.payload.modelSelection)
519-
: undefined;
520-
521514
yield* sendTurnForThread({
522515
threadId: event.payload.threadId,
523516
messageText: message.text,
524517
...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
525518
...(event.payload.modelSelection !== undefined
526519
? { modelSelection: event.payload.modelSelection }
527520
: {}),
528-
...(requestedModelOptions !== undefined ? { modelOptions: requestedModelOptions } : {}),
529521
...(event.payload.providerOptions !== undefined
530522
? { providerOptions: event.payload.providerOptions }
531523
: {}),
@@ -695,12 +687,12 @@ const make = Effect.gen(function* () {
695687
return;
696688
}
697689
const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);
698-
const cachedModelOptions = threadModelOptions.get(event.payload.threadId);
690+
const cachedModelSelection = threadModelSelections.get(event.payload.threadId);
699691
yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {
700692
...(cachedProviderOptions !== undefined
701693
? { providerOptions: cachedProviderOptions }
702694
: {}),
703-
...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}),
695+
...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}),
704696
});
705697
return;
706698
}

0 commit comments

Comments
 (0)