authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-24 22:40:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-24 22:40:32-07:00
loga542a3b168423ce5d13e46edb08c0a98cc3b57df
tree92e3e5ba189782b3538905a908e3c3253aa5e22c
parent56d13faa1a0890e34f12852c20bfba6e6d58842c
signature Signed by PGP key B5690EEEBB952194

Persist provider-aware model selections (#1371)


65 files changed, 4067 insertions(+), 2308 deletions(-)

apps/server/integration/orchestrationEngine.integration.test.ts+43-11
......@@ -11,6 +11,7 @@ import {
1111 ProjectId,
1212 ProviderKind,
1313 ThreadId,
14 ModelSelection,
1415} from "@t3tools/contracts";
1516import { assert, it } from "@effect/vitest";
1617import { Effect, Option, Schema } from "effect";
......@@ -115,7 +116,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
115116 projectId: PROJECT_ID,
116117 title: "Integration Project",
117118 workspaceRoot: harness.workspaceDir,
118 defaultModel,
119 defaultModelSelection: {
120 provider,
121 model: defaultModel,
122 },
119123 createdAt,
120124 });
121125
......@@ -125,7 +129,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
125129 threadId: THREAD_ID,
126130 projectId: PROJECT_ID,
127131 title: "Integration Thread",
128 model: defaultModel,
132 modelSelection: {
133 provider,
134 model: defaultModel,
135 },
129136 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
130137 runtimeMode: "approval-required",
131138 branch: null,
......@@ -139,7 +146,7 @@ const startTurn = (input: {
139146 readonly commandId: string;
140147 readonly messageId: string;
141148 readonly text: string;
142 readonly provider?: IntegrationProvider;
149 readonly modelSelection?: ModelSelection;
143150}) =>
144151 input.harness.engine.dispatch({
145152 type: "thread.turn.start",
......@@ -151,7 +158,11 @@ const startTurn = (input: {
151158 text: input.text,
152159 attachments: [],
153160 },
154 ...(input.provider !== undefined ? { provider: input.provider } : {}),
161 ...(input.modelSelection !== undefined
162 ? {
163 modelSelection: input.modelSelection,
164 }
165 : {}),
155166 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
156167 runtimeMode: "approval-required",
157168 createdAt: nowIso(),
......@@ -254,7 +265,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
254265 projectId: PROJECT_ID,
255266 title: "Integration Project",
256267 workspaceRoot: harness.workspaceDir,
257 defaultModel: "gpt-5.3-codex",
268 defaultModelSelection: {
269 provider: "codex",
270 model: "gpt-5.3-codex",
271 },
258272 createdAt,
259273 });
260274
......@@ -264,7 +278,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
264278 threadId: THREAD_ID,
265279 projectId: PROJECT_ID,
266280 title: "Integration Thread",
267 model: "gpt-5.3-codex",
281 modelSelection: {
282 provider: "codex",
283 model: "gpt-5.3-codex",
284 },
268285 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
269286 runtimeMode: "full-access",
270287 branch: null,
......@@ -922,7 +939,10 @@ it.live("starts a claudeAgent session on first turn when provider is requested",
922939 commandId: "cmd-turn-start-claude-initial",
923940 messageId: "msg-user-claude-initial",
924941 text: "Use Claude",
925 provider: "claudeAgent",
942 modelSelection: {
943 provider: "claudeAgent",
944 model: "claude-sonnet-4-6",
945 },
926946 });
927947
928948 const thread = yield* harness.waitForThread(
......@@ -976,7 +996,10 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re
976996 commandId: "cmd-turn-start-claude-recover-1",
977997 messageId: "msg-user-claude-recover-1",
978998 text: "Before restart",
979 provider: "claudeAgent",
999 modelSelection: {
1000 provider: "claudeAgent",
1001 model: "claude-sonnet-4-6",
1002 },
9801003 });
9811004
9821005 yield* harness.waitForThread(
......@@ -1083,7 +1106,10 @@ it.live("forwards claudeAgent approval responses to the provider session", () =>
10831106 commandId: "cmd-turn-start-claude-approval",
10841107 messageId: "msg-user-claude-approval",
10851108 text: "Need approval",
1086 provider: "claudeAgent",
1109 modelSelection: {
1110 provider: "claudeAgent",
1111 model: "claude-sonnet-4-6",
1112 },
10871113 });
10881114
10891115 const thread = yield* harness.waitForThread(THREAD_ID, (entry) =>
......@@ -1152,7 +1178,10 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () =>
11521178 commandId: "cmd-turn-start-claude-interrupt",
11531179 messageId: "msg-user-claude-interrupt",
11541180 text: "Start long turn",
1155 provider: "claudeAgent",
1181 modelSelection: {
1182 provider: "claudeAgent",
1183 model: "claude-sonnet-4-6",
1184 },
11561185 });
11571186
11581187 const thread = yield* harness.waitForThread(
......@@ -1222,7 +1251,10 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state",
12221251 commandId: "cmd-turn-start-claude-revert-1",
12231252 messageId: "msg-user-claude-revert-1",
12241253 text: "First Claude edit",
1225 provider: "claudeAgent",
1254 modelSelection: {
1255 provider: "claudeAgent",
1256 model: "claude-sonnet-4-6",
1257 },
12261258 });
12271259
12281260 yield* harness.waitForThread(
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+5-2
......@@ -31,7 +31,7 @@ function makeSnapshot(input: {
3131 id: input.projectId,
3232 title: "Project",
3333 workspaceRoot: input.workspaceRoot,
34 defaultModel: null,
34 defaultModelSelection: null,
3535 scripts: [],
3636 createdAt: "2026-01-01T00:00:00.000Z",
3737 updatedAt: "2026-01-01T00:00:00.000Z",
......@@ -43,7 +43,10 @@ function makeSnapshot(input: {
4343 id: input.threadId,
4444 projectId: input.projectId,
4545 title: "Thread",
46 model: "gpt-5-codex",
46 modelSelection: {
47 provider: "codex",
48 model: "gpt-5-codex",
49 },
4750 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
4851 runtimeMode: "full-access",
4952 branch: null,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+8-2
......@@ -282,7 +282,10 @@ describe("CheckpointReactor", () => {
282282 projectId: asProjectId("project-1"),
283283 title: "Test Project",
284284 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,
285 defaultModel: "gpt-5-codex",
285 defaultModelSelection: {
286 provider: "codex",
287 model: "gpt-5-codex",
288 },
286289 createdAt,
287290 }),
288291 );
......@@ -293,7 +296,10 @@ describe("CheckpointReactor", () => {
293296 threadId: ThreadId.makeUnsafe("thread-1"),
294297 projectId: asProjectId("project-1"),
295298 title: "Thread",
296 model: "gpt-5-codex",
299 modelSelection: {
300 provider: "codex",
301 model: "gpt-5-codex",
302 },
297303 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
298304 runtimeMode: "approval-required",
299305 branch: null,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+72-18
......@@ -72,7 +72,10 @@ describe("OrchestrationEngine", () => {
7272 projectId: asProjectId("project-1"),
7373 title: "Project 1",
7474 workspaceRoot: "/tmp/project-1",
75 defaultModel: "gpt-5-codex",
75 defaultModelSelection: {
76 provider: "codex",
77 model: "gpt-5-codex",
78 },
7679 createdAt,
7780 }),
7881 );
......@@ -83,7 +86,10 @@ describe("OrchestrationEngine", () => {
8386 threadId: ThreadId.makeUnsafe("thread-1"),
8487 projectId: asProjectId("project-1"),
8588 title: "Thread",
86 model: "gpt-5-codex",
89 modelSelection: {
90 provider: "codex",
91 model: "gpt-5-codex",
92 },
8793 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
8894 runtimeMode: "approval-required",
8995 branch: null,
......@@ -126,7 +132,10 @@ describe("OrchestrationEngine", () => {
126132 projectId: asProjectId("project-replay"),
127133 title: "Replay Project",
128134 workspaceRoot: "/tmp/project-replay",
129 defaultModel: "gpt-5-codex",
135 defaultModelSelection: {
136 provider: "codex",
137 model: "gpt-5-codex",
138 },
130139 createdAt,
131140 }),
132141 );
......@@ -137,7 +146,10 @@ describe("OrchestrationEngine", () => {
137146 threadId: ThreadId.makeUnsafe("thread-replay"),
138147 projectId: asProjectId("project-replay"),
139148 title: "replay",
140 model: "gpt-5-codex",
149 modelSelection: {
150 provider: "codex",
151 model: "gpt-5-codex",
152 },
141153 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
142154 runtimeMode: "approval-required",
143155 branch: null,
......@@ -178,7 +190,10 @@ describe("OrchestrationEngine", () => {
178190 projectId: asProjectId("project-stream"),
179191 title: "Stream Project",
180192 workspaceRoot: "/tmp/project-stream",
181 defaultModel: "gpt-5-codex",
193 defaultModelSelection: {
194 provider: "codex",
195 model: "gpt-5-codex",
196 },
182197 createdAt,
183198 }),
184199 );
......@@ -199,7 +214,10 @@ describe("OrchestrationEngine", () => {
199214 threadId: ThreadId.makeUnsafe("thread-stream"),
200215 projectId: asProjectId("project-stream"),
201216 title: "domain-stream",
202 model: "gpt-5-codex",
217 modelSelection: {
218 provider: "codex",
219 model: "gpt-5-codex",
220 },
203221 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
204222 runtimeMode: "approval-required",
205223 branch: null,
......@@ -233,7 +251,10 @@ describe("OrchestrationEngine", () => {
233251 projectId: asProjectId("project-turn-diff"),
234252 title: "Turn Diff Project",
235253 workspaceRoot: "/tmp/project-turn-diff",
236 defaultModel: "gpt-5-codex",
254 defaultModelSelection: {
255 provider: "codex",
256 model: "gpt-5-codex",
257 },
237258 createdAt,
238259 }),
239260 );
......@@ -244,7 +265,10 @@ describe("OrchestrationEngine", () => {
244265 threadId: ThreadId.makeUnsafe("thread-turn-diff"),
245266 projectId: asProjectId("project-turn-diff"),
246267 title: "Turn diff thread",
247 model: "gpt-5-codex",
268 modelSelection: {
269 provider: "codex",
270 model: "gpt-5-codex",
271 },
248272 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
249273 runtimeMode: "approval-required",
250274 branch: null,
......@@ -344,7 +368,10 @@ describe("OrchestrationEngine", () => {
344368 projectId: asProjectId("project-flaky"),
345369 title: "Flaky Project",
346370 workspaceRoot: "/tmp/project-flaky",
347 defaultModel: "gpt-5-codex",
371 defaultModelSelection: {
372 provider: "codex",
373 model: "gpt-5-codex",
374 },
348375 createdAt,
349376 }),
350377 );
......@@ -357,7 +384,10 @@ describe("OrchestrationEngine", () => {
357384 threadId: ThreadId.makeUnsafe("thread-flaky-fail"),
358385 projectId: asProjectId("project-flaky"),
359386 title: "flaky-fail",
360 model: "gpt-5-codex",
387 modelSelection: {
388 provider: "codex",
389 model: "gpt-5-codex",
390 },
361391 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
362392 runtimeMode: "approval-required",
363393 branch: null,
......@@ -374,7 +404,10 @@ describe("OrchestrationEngine", () => {
374404 threadId: ThreadId.makeUnsafe("thread-flaky-ok"),
375405 projectId: asProjectId("project-flaky"),
376406 title: "flaky-ok",
377 model: "gpt-5-codex",
407 modelSelection: {
408 provider: "codex",
409 model: "gpt-5-codex",
410 },
378411 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
379412 runtimeMode: "approval-required",
380413 branch: null,
......@@ -428,7 +461,10 @@ describe("OrchestrationEngine", () => {
428461 projectId: asProjectId("project-atomic"),
429462 title: "Atomic Project",
430463 workspaceRoot: "/tmp/project-atomic",
431 defaultModel: "gpt-5-codex",
464 defaultModelSelection: {
465 provider: "codex",
466 model: "gpt-5-codex",
467 },
432468 createdAt,
433469 }),
434470 );
......@@ -439,7 +475,10 @@ describe("OrchestrationEngine", () => {
439475 threadId: ThreadId.makeUnsafe("thread-atomic"),
440476 projectId: asProjectId("project-atomic"),
441477 title: "atomic",
442 model: "gpt-5-codex",
478 modelSelection: {
479 provider: "codex",
480 model: "gpt-5-codex",
481 },
443482 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
444483 runtimeMode: "approval-required",
445484 branch: null,
......@@ -563,7 +602,10 @@ describe("OrchestrationEngine", () => {
563602 projectId: asProjectId("project-sync"),
564603 title: "Sync Project",
565604 workspaceRoot: "/tmp/project-sync",
566 defaultModel: "gpt-5-codex",
605 defaultModelSelection: {
606 provider: "codex",
607 model: "gpt-5-codex",
608 },
567609 createdAt,
568610 }),
569611 );
......@@ -574,7 +616,10 @@ describe("OrchestrationEngine", () => {
574616 threadId: ThreadId.makeUnsafe("thread-sync"),
575617 projectId: asProjectId("project-sync"),
576618 title: "sync-before",
577 model: "gpt-5-codex",
619 modelSelection: {
620 provider: "codex",
621 model: "gpt-5-codex",
622 },
578623 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
579624 runtimeMode: "approval-required",
580625 branch: null,
......@@ -642,7 +687,10 @@ describe("OrchestrationEngine", () => {
642687 projectId: asProjectId("project-duplicate"),
643688 title: "Duplicate Project",
644689 workspaceRoot: "/tmp/project-duplicate",
645 defaultModel: "gpt-5-codex",
690 defaultModelSelection: {
691 provider: "codex",
692 model: "gpt-5-codex",
693 },
646694 createdAt,
647695 }),
648696 );
......@@ -654,7 +702,10 @@ describe("OrchestrationEngine", () => {
654702 threadId: ThreadId.makeUnsafe("thread-duplicate"),
655703 projectId: asProjectId("project-duplicate"),
656704 title: "duplicate",
657 model: "gpt-5-codex",
705 modelSelection: {
706 provider: "codex",
707 model: "gpt-5-codex",
708 },
658709 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
659710 runtimeMode: "approval-required",
660711 branch: null,
......@@ -671,7 +722,10 @@ describe("OrchestrationEngine", () => {
671722 threadId: ThreadId.makeUnsafe("thread-duplicate"),
672723 projectId: asProjectId("project-duplicate"),
673724 title: "duplicate",
674 model: "gpt-5-codex",
725 modelSelection: {
726 provider: "codex",
727 model: "gpt-5-codex",
728 },
675729 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
676730 runtimeMode: "approval-required",
677731 branch: null,
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+65-26
......@@ -68,7 +68,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
6868 projectId: ProjectId.makeUnsafe("project-1"),
6969 title: "Project 1",
7070 workspaceRoot: "/tmp/project-1",
71 defaultModel: null,
71 defaultModelSelection: null,
7272 scripts: [],
7373 createdAt: now,
7474 updatedAt: now,
......@@ -89,7 +89,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
8989 threadId: ThreadId.makeUnsafe("thread-1"),
9090 projectId: ProjectId.makeUnsafe("project-1"),
9191 title: "Thread 1",
92 model: "gpt-5-codex",
92 modelSelection: {
93 provider: "codex",
94 model: "gpt-5-codex",
95 },
9396 runtimeMode: "full-access",
9497 branch: null,
9598 worktreePath: null,
......@@ -337,7 +340,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
337340 projectId: ProjectId.makeUnsafe("project-clear-attachments"),
338341 title: "Project Clear Attachments",
339342 workspaceRoot: "/tmp/project-clear-attachments",
340 defaultModel: null,
343 defaultModelSelection: null,
341344 scripts: [],
342345 createdAt: now,
343346 updatedAt: now,
......@@ -358,7 +361,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
358361 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
359362 projectId: ProjectId.makeUnsafe("project-clear-attachments"),
360363 title: "Thread Clear Attachments",
361 model: "gpt-5-codex",
364 modelSelection: {
365 provider: "codex",
366 model: "gpt-5-codex",
367 },
362368 runtimeMode: "full-access",
363369 branch: null,
364370 worktreePath: null,
......@@ -462,7 +468,7 @@ it.layer(
462468 projectId: ProjectId.makeUnsafe("project-overwrite"),
463469 title: "Project Overwrite",
464470 workspaceRoot: "/tmp/project-overwrite",
465 defaultModel: null,
471 defaultModelSelection: null,
466472 scripts: [],
467473 createdAt: now,
468474 updatedAt: now,
......@@ -483,7 +489,10 @@ it.layer(
483489 threadId: ThreadId.makeUnsafe("thread-overwrite"),
484490 projectId: ProjectId.makeUnsafe("project-overwrite"),
485491 title: "Thread Overwrite",
486 model: "gpt-5-codex",
492 modelSelection: {
493 provider: "codex",
494 model: "gpt-5-codex",
495 },
487496 runtimeMode: "full-access",
488497 branch: null,
489498 worktreePath: null,
......@@ -607,7 +616,7 @@ it.layer(
607616 projectId: ProjectId.makeUnsafe("project-rollback"),
608617 title: "Project Rollback",
609618 workspaceRoot: "/tmp/project-rollback",
610 defaultModel: null,
619 defaultModelSelection: null,
611620 scripts: [],
612621 createdAt: now,
613622 updatedAt: now,
......@@ -628,7 +637,10 @@ it.layer(
628637 threadId: ThreadId.makeUnsafe("thread-rollback"),
629638 projectId: ProjectId.makeUnsafe("project-rollback"),
630639 title: "Thread Rollback",
631 model: "gpt-5-codex",
640 modelSelection: {
641 provider: "codex",
642 model: "gpt-5-codex",
643 },
632644 runtimeMode: "full-access",
633645 branch: null,
634646 worktreePath: null,
......@@ -733,7 +745,7 @@ it.layer(
733745 projectId: ProjectId.makeUnsafe("project-revert-files"),
734746 title: "Project Revert Files",
735747 workspaceRoot: "/tmp/project-revert-files",
736 defaultModel: null,
748 defaultModelSelection: null,
737749 scripts: [],
738750 createdAt: now,
739751 updatedAt: now,
......@@ -754,7 +766,10 @@ it.layer(
754766 threadId,
755767 projectId: ProjectId.makeUnsafe("project-revert-files"),
756768 title: "Thread Revert Files",
757 model: "gpt-5-codex",
769 modelSelection: {
770 provider: "codex",
771 model: "gpt-5-codex",
772 },
758773 runtimeMode: "full-access",
759774 branch: null,
760775 worktreePath: null,
......@@ -938,7 +953,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
938953 projectId: ProjectId.makeUnsafe("project-delete-files"),
939954 title: "Project Delete Files",
940955 workspaceRoot: "/tmp/project-delete-files",
941 defaultModel: null,
956 defaultModelSelection: null,
942957 scripts: [],
943958 createdAt: now,
944959 updatedAt: now,
......@@ -959,7 +974,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
959974 threadId,
960975 projectId: ProjectId.makeUnsafe("project-delete-files"),
961976 title: "Thread Delete Files",
962 model: "gpt-5-codex",
977 modelSelection: {
978 provider: "codex",
979 model: "gpt-5-codex",
980 },
963981 runtimeMode: "full-access",
964982 branch: null,
965983 worktreePath: null,
......@@ -1098,7 +1116,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
10981116 projectId: ProjectId.makeUnsafe("project-a"),
10991117 title: "Project A",
11001118 workspaceRoot: "/tmp/project-a",
1101 defaultModel: null,
1119 defaultModelSelection: null,
11021120 scripts: [],
11031121 createdAt: now,
11041122 updatedAt: now,
......@@ -1119,7 +1137,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
11191137 threadId: ThreadId.makeUnsafe("thread-a"),
11201138 projectId: ProjectId.makeUnsafe("project-a"),
11211139 title: "Thread A",
1122 model: "gpt-5-codex",
1140 modelSelection: {
1141 provider: "codex",
1142 model: "gpt-5-codex",
1143 },
11231144 runtimeMode: "full-access",
11241145 branch: null,
11251146 worktreePath: null,
......@@ -1222,7 +1243,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
12221243 projectId: ProjectId.makeUnsafe("project-empty"),
12231244 title: "Project Empty",
12241245 workspaceRoot: "/tmp/project-empty",
1225 defaultModel: null,
1246 defaultModelSelection: null,
12261247 scripts: [],
12271248 createdAt: now,
12281249 updatedAt: now,
......@@ -1243,7 +1264,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
12431264 threadId: ThreadId.makeUnsafe("thread-empty"),
12441265 projectId: ProjectId.makeUnsafe("project-empty"),
12451266 title: "Thread Empty",
1246 model: "gpt-5-codex",
1267 modelSelection: {
1268 provider: "codex",
1269 model: "gpt-5-codex",
1270 },
12471271 runtimeMode: "full-access",
12481272 branch: null,
12491273 worktreePath: null,
......@@ -1359,7 +1383,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
13591383 projectId: ProjectId.makeUnsafe("project-conflict"),
13601384 title: "Project Conflict",
13611385 workspaceRoot: "/tmp/project-conflict",
1362 defaultModel: null,
1386 defaultModelSelection: null,
13631387 scripts: [],
13641388 createdAt: "2026-02-26T13:00:00.000Z",
13651389 updatedAt: "2026-02-26T13:00:00.000Z",
......@@ -1380,7 +1404,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
13801404 threadId: ThreadId.makeUnsafe("thread-conflict"),
13811405 projectId: ProjectId.makeUnsafe("project-conflict"),
13821406 title: "Thread Conflict",
1383 model: "gpt-5-codex",
1407 modelSelection: {
1408 provider: "codex",
1409 model: "gpt-5-codex",
1410 },
13841411 runtimeMode: "full-access",
13851412 branch: null,
13861413 worktreePath: null,
......@@ -1500,7 +1527,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
15001527 projectId: ProjectId.makeUnsafe("project-revert"),
15011528 title: "Project Revert",
15021529 workspaceRoot: "/tmp/project-revert",
1503 defaultModel: null,
1530 defaultModelSelection: null,
15041531 scripts: [],
15051532 createdAt: "2026-02-26T12:00:00.000Z",
15061533 updatedAt: "2026-02-26T12:00:00.000Z",
......@@ -1521,7 +1548,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
15211548 threadId: ThreadId.makeUnsafe("thread-revert"),
15221549 projectId: ProjectId.makeUnsafe("project-revert"),
15231550 title: "Thread Revert",
1524 model: "gpt-5-codex",
1551 modelSelection: {
1552 provider: "codex",
1553 model: "gpt-5-codex",
1554 },
15251555 runtimeMode: "full-access",
15261556 branch: null,
15271557 worktreePath: null,
......@@ -1837,7 +1867,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
18371867 projectId: ProjectId.makeUnsafe("project-live"),
18381868 title: "Live Project",
18391869 workspaceRoot: "/tmp/project-live",
1840 defaultModel: "gpt-5-codex",
1870 defaultModelSelection: {
1871 provider: "codex",
1872 model: "gpt-5-codex",
1873 },
18411874 createdAt,
18421875 });
18431876
......@@ -1872,7 +1905,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
18721905 projectId: ProjectId.makeUnsafe("project-scripts"),
18731906 title: "Scripts Project",
18741907 workspaceRoot: "/tmp/project-scripts",
1875 defaultModel: "gpt-5-codex",
1908 defaultModelSelection: {
1909 provider: "codex",
1910 model: "gpt-5-codex",
1911 },
18761912 createdAt,
18771913 });
18781914
......@@ -1889,16 +1925,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
18891925 runOnWorktreeCreate: false,
18901926 },
18911927 ],
1892 defaultModel: "gpt-5",
1928 defaultModelSelection: {
1929 provider: "codex",
1930 model: "gpt-5",
1931 },
18931932 });
18941933
18951934 const projectRows = yield* sql<{
18961935 readonly scriptsJson: string;
1897 readonly defaultModel: string;
1936 readonly defaultModelSelection: string;
18981937 }>`
18991938 SELECT
19001939 scripts_json AS "scriptsJson",
1901 default_model AS "defaultModel"
1940 default_model_selection_json AS "defaultModelSelection"
19021941 FROM projection_projects
19031942 WHERE project_id = 'project-scripts'
19041943 `;
......@@ -1906,7 +1945,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
19061945 {
19071946 scriptsJson:
19081947 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
1909 defaultModel: "gpt-5",
1948 defaultModelSelection: '{"provider":"codex","model":"gpt-5"}',
19101949 },
19111950 ]);
19121951 }),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+7-5
......@@ -362,7 +362,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
362362 projectId: event.payload.projectId,
363363 title: event.payload.title,
364364 workspaceRoot: event.payload.workspaceRoot,
365 defaultModel: event.payload.defaultModel,
365 defaultModelSelection: event.payload.defaultModelSelection,
366366 scripts: event.payload.scripts,
367367 createdAt: event.payload.createdAt,
368368 updatedAt: event.payload.updatedAt,
......@@ -383,8 +383,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
383383 ...(event.payload.workspaceRoot !== undefined
384384 ? { workspaceRoot: event.payload.workspaceRoot }
385385 : {}),
386 ...(event.payload.defaultModel !== undefined
387 ? { defaultModel: event.payload.defaultModel }
386 ...(event.payload.defaultModelSelection !== undefined
387 ? { defaultModelSelection: event.payload.defaultModelSelection }
388388 : {}),
389389 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
390390 updatedAt: event.payload.updatedAt,
......@@ -420,7 +420,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
420420 threadId: event.payload.threadId,
421421 projectId: event.payload.projectId,
422422 title: event.payload.title,
423 model: event.payload.model,
423 modelSelection: event.payload.modelSelection,
424424 runtimeMode: event.payload.runtimeMode,
425425 interactionMode: event.payload.interactionMode,
426426 branch: event.payload.branch,
......@@ -442,7 +442,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
442442 yield* projectionThreadRepository.upsert({
443443 ...existingRow.value,
444444 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),
445 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
445 ...(event.payload.modelSelection !== undefined
446 ? { modelSelection: event.payload.modelSelection }
447 : {}),
446448 ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}),
447449 ...(event.payload.worktreePath !== undefined
448450 ? { worktreePath: event.payload.worktreePath }
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+12-6
......@@ -34,7 +34,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
3434 project_id,
3535 title,
3636 workspace_root,
37 default_model,
37 default_model_selection_json,
3838 scripts_json,
3939 created_at,
4040 updated_at,
......@@ -44,7 +44,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
4444 'project-1',
4545 'Project 1',
4646 '/tmp/project-1',
47 'gpt-5-codex',
47 '{"provider":"codex","model":"gpt-5-codex"}',
4848 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
4949 '2026-02-24T00:00:00.000Z',
5050 '2026-02-24T00:00:01.000Z',
......@@ -57,7 +57,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
5757 thread_id,
5858 project_id,
5959 title,
60 model,
60 model_selection_json,
6161 branch,
6262 worktree_path,
6363 latest_turn_id,
......@@ -69,7 +69,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
6969 'thread-1',
7070 'project-1',
7171 'Thread 1',
72 'gpt-5-codex',
72 '{"provider":"codex","model":"gpt-5-codex"}',
7373 NULL,
7474 NULL,
7575 'turn-1',
......@@ -234,7 +234,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
234234 id: asProjectId("project-1"),
235235 title: "Project 1",
236236 workspaceRoot: "/tmp/project-1",
237 defaultModel: "gpt-5-codex",
237 defaultModelSelection: {
238 provider: "codex",
239 model: "gpt-5-codex",
240 },
238241 scripts: [
239242 {
240243 id: "script-1",
......@@ -254,7 +257,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
254257 id: ThreadId.makeUnsafe("thread-1"),
255258 projectId: asProjectId("project-1"),
256259 title: "Thread 1",
257 model: "gpt-5-codex",
260 modelSelection: {
261 provider: "codex",
262 model: "gpt-5-codex",
263 },
258264 interactionMode: "default",
259265 runtimeMode: "full-access",
260266 branch: null,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+13-7
......@@ -17,6 +17,7 @@ import {
1717 type OrchestrationSession,
1818 type OrchestrationThread,
1919 type OrchestrationThreadActivity,
20 ModelSelection,
2021} from "@t3tools/contracts";
2122import { Effect, Layer, Schema, Struct } from "effect";
2223import * as SqlClient from "effect/unstable/sql/SqlClient";
......@@ -45,6 +46,7 @@ import {
4546const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
4647const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
4748 Struct.assign({
49 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
4850 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
4951 }),
5052);
......@@ -55,7 +57,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
5557 }),
5658);
5759const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
58const ProjectionThreadDbRowSchema = ProjectionThread;
60const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
61 Struct.assign({
62 modelSelection: Schema.fromJsonString(ModelSelection),
63 }),
64);
5965const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
6066 Struct.assign({
6167 payload: Schema.fromJsonString(Schema.Unknown),
......@@ -141,7 +147,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
141147 project_id AS "projectId",
142148 title,
143149 workspace_root AS "workspaceRoot",
144 default_model AS "defaultModel",
150 default_model_selection_json AS "defaultModelSelection",
145151 scripts_json AS "scripts",
146152 created_at AS "createdAt",
147153 updated_at AS "updatedAt",
......@@ -160,7 +166,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
160166 thread_id AS "threadId",
161167 project_id AS "projectId",
162168 title,
163 model,
169 model_selection_json AS "modelSelection",
164170 runtime_mode AS "runtimeMode",
165171 interaction_mode AS "interactionMode",
166172 branch,
......@@ -531,22 +537,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
531537 });
532538 }
533539
534 const projects: Array<OrchestrationProject> = projectRows.map((row) => ({
540 const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
535541 id: row.projectId,
536542 title: row.title,
537543 workspaceRoot: row.workspaceRoot,
538 defaultModel: row.defaultModel,
544 defaultModelSelection: row.defaultModelSelection,
539545 scripts: row.scripts,
540546 createdAt: row.createdAt,
541547 updatedAt: row.updatedAt,
542548 deletedAt: row.deletedAt,
543549 }));
544550
545 const threads: Array<OrchestrationThread> = threadRows.map((row) => ({
551 const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
546552 id: row.threadId,
547553 projectId: row.projectId,
548554 title: row.title,
549 model: row.model,
555 modelSelection: row.modelSelection,
550556 runtimeMode: row.runtimeMode,
551557 interactionMode: row.interactionMode,
552558 branch: row.branch,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+159-98
......@@ -2,7 +2,7 @@ import fs from "node:fs";
22import os from "node:os";
33import path from "node:path";
44
5import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
5import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
66import {
77 ApprovalRequestId,
88 CommandId,
......@@ -93,37 +93,27 @@ describe("ProviderCommandReactor", () => {
9393
9494 async function createHarness(input?: {
9595 readonly baseDir?: string;
96 readonly threadModel?: string;
96 readonly threadModelSelection?: ModelSelection;
97 readonly sessionModelSwitch?: "unsupported" | "in-session";
9798 }) {
9899 const now = new Date().toISOString();
99100 const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
100101 createdBaseDirs.add(baseDir);
101102 const { stateDir } = deriveServerPathsSync(baseDir, undefined);
102103 createdStateDirs.add(stateDir);
103 const threadModel = input?.threadModel ?? "gpt-5-codex";
104104 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
105105 let nextSessionIndex = 1;
106106 const runtimeSessions: Array<ProviderSession> = [];
107 const modelSelection = input?.threadModelSelection ?? {
108 provider: "codex",
109 model: "gpt-5-codex",
110 };
107111 const startSession = vi.fn((_: unknown, input: unknown) => {
108112 const sessionIndex = nextSessionIndex++;
109 const provider =
110 typeof input === "object" &&
111 input !== null &&
112 "provider" in input &&
113 (input.provider === "codex" || input.provider === "claudeAgent")
114 ? input.provider
115 : "codex";
116113 const resumeCursor =
117114 typeof input === "object" && input !== null && "resumeCursor" in input
118115 ? input.resumeCursor
119116 : undefined;
120 const model =
121 typeof input === "object" &&
122 input !== null &&
123 "model" in input &&
124 typeof input.model === "string"
125 ? input.model
126 : undefined;
127117 const threadId =
128118 typeof input === "object" &&
129119 input !== null &&
......@@ -132,7 +122,7 @@ describe("ProviderCommandReactor", () => {
132122 ? ThreadId.makeUnsafe(input.threadId)
133123 : ThreadId.makeUnsafe(`thread-${sessionIndex}`);
134124 const session: ProviderSession = {
135 provider,
125 provider: modelSelection.provider,
136126 status: "ready" as const,
137127 runtimeMode:
138128 typeof input === "object" &&
......@@ -141,7 +131,7 @@ describe("ProviderCommandReactor", () => {
141131 (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access")
142132 ? input.runtimeMode
143133 : "full-access",
144 ...(model !== undefined ? { model } : {}),
134 ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}),
145135 threadId,
146136 resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },
147137 createdAt: now,
......@@ -203,9 +193,9 @@ describe("ProviderCommandReactor", () => {
203193 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],
204194 stopSession: stopSession as ProviderServiceShape["stopSession"],
205195 listSessions: () => Effect.succeed(runtimeSessions),
206 getCapabilities: (provider) =>
196 getCapabilities: (_provider) =>
207197 Effect.succeed({
208 sessionModelSwitch: provider === "codex" ? "in-session" : "in-session",
198 sessionModelSwitch: input?.sessionModelSwitch ?? "in-session",
209199 }),
210200 rollbackConversation: () => unsupported(),
211201 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
......@@ -242,7 +232,7 @@ describe("ProviderCommandReactor", () => {
242232 projectId: asProjectId("project-1"),
243233 title: "Provider Project",
244234 workspaceRoot: "/tmp/provider-project",
245 defaultModel: threadModel,
235 defaultModelSelection: modelSelection,
246236 createdAt: now,
247237 }),
248238 );
......@@ -253,7 +243,7 @@ describe("ProviderCommandReactor", () => {
253243 threadId: ThreadId.makeUnsafe("thread-1"),
254244 projectId: asProjectId("project-1"),
255245 title: "Thread",
256 model: threadModel,
246 modelSelection: modelSelection,
257247 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
258248 runtimeMode: "approval-required",
259249 branch: null,
......@@ -303,7 +293,10 @@ describe("ProviderCommandReactor", () => {
303293 expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1"));
304294 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
305295 cwd: "/tmp/provider-project",
306 model: "gpt-5-codex",
296 modelSelection: {
297 provider: "codex",
298 model: "gpt-5-codex",
299 },
307300 runtimeMode: "approval-required",
308301 });
309302
......@@ -328,10 +321,10 @@ describe("ProviderCommandReactor", () => {
328321 text: "hello fast mode",
329322 attachments: [],
330323 },
331 provider: "codex",
332 model: "gpt-5.3-codex",
333 modelOptions: {
334 codex: {
324 modelSelection: {
325 provider: "codex",
326 model: "gpt-5.3-codex",
327 options: {
335328 reasoningEffort: "high",
336329 fastMode: true,
337330 },
......@@ -345,9 +338,10 @@ describe("ProviderCommandReactor", () => {
345338 await waitFor(() => harness.startSession.mock.calls.length === 1);
346339 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
347340 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
348 model: "gpt-5.3-codex",
349 modelOptions: {
350 codex: {
341 modelSelection: {
342 provider: "codex",
343 model: "gpt-5.3-codex",
344 options: {
351345 reasoningEffort: "high",
352346 fastMode: true,
353347 },
......@@ -355,9 +349,10 @@ describe("ProviderCommandReactor", () => {
355349 });
356350 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
357351 threadId: ThreadId.makeUnsafe("thread-1"),
358 model: "gpt-5.3-codex",
359 modelOptions: {
360 codex: {
352 modelSelection: {
353 provider: "codex",
354 model: "gpt-5.3-codex",
355 options: {
361356 reasoningEffort: "high",
362357 fastMode: true,
363358 },
......@@ -366,7 +361,9 @@ describe("ProviderCommandReactor", () => {
366361 });
367362
368363 it("forwards claude effort options through session start and turn send", async () => {
369 const harness = await createHarness({ threadModel: "claude-sonnet-4-6" });
364 const harness = await createHarness({
365 threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
366 });
370367 const now = new Date().toISOString();
371368
372369 await Effect.runPromise(
......@@ -380,10 +377,10 @@ describe("ProviderCommandReactor", () => {
380377 text: "hello with effort",
381378 attachments: [],
382379 },
383 provider: "claudeAgent",
384 model: "claude-sonnet-4-6",
385 modelOptions: {
386 claudeAgent: {
380 modelSelection: {
381 provider: "claudeAgent",
382 model: "claude-sonnet-4-6",
383 options: {
387384 effort: "max",
388385 },
389386 },
......@@ -396,19 +393,20 @@ describe("ProviderCommandReactor", () => {
396393 await waitFor(() => harness.startSession.mock.calls.length === 1);
397394 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
398395 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
399 provider: "claudeAgent",
400 model: "claude-sonnet-4-6",
401 modelOptions: {
402 claudeAgent: {
396 modelSelection: {
397 provider: "claudeAgent",
398 model: "claude-sonnet-4-6",
399 options: {
403400 effort: "max",
404401 },
405402 },
406403 });
407404 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
408405 threadId: ThreadId.makeUnsafe("thread-1"),
409 model: "claude-sonnet-4-6",
410 modelOptions: {
411 claudeAgent: {
406 modelSelection: {
407 provider: "claudeAgent",
408 model: "claude-sonnet-4-6",
409 options: {
412410 effort: "max",
413411 },
414412 },
......@@ -416,7 +414,9 @@ describe("ProviderCommandReactor", () => {
416414 });
417415
418416 it("forwards claude fast mode options through session start and turn send", async () => {
419 const harness = await createHarness({ threadModel: "claude-opus-4-6" });
417 const harness = await createHarness({
418 threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" },
419 });
420420 const now = new Date().toISOString();
421421
422422 await Effect.runPromise(
......@@ -430,10 +430,10 @@ describe("ProviderCommandReactor", () => {
430430 text: "hello with fast mode",
431431 attachments: [],
432432 },
433 provider: "claudeAgent",
434 model: "claude-opus-4-6",
435 modelOptions: {
436 claudeAgent: {
433 modelSelection: {
434 provider: "claudeAgent",
435 model: "claude-opus-4-6",
436 options: {
437437 fastMode: true,
438438 },
439439 },
......@@ -446,19 +446,20 @@ describe("ProviderCommandReactor", () => {
446446 await waitFor(() => harness.startSession.mock.calls.length === 1);
447447 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
448448 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
449 provider: "claudeAgent",
450 model: "claude-opus-4-6",
451 modelOptions: {
452 claudeAgent: {
449 modelSelection: {
450 provider: "claudeAgent",
451 model: "claude-opus-4-6",
452 options: {
453453 fastMode: true,
454454 },
455455 },
456456 });
457457 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
458458 threadId: ThreadId.makeUnsafe("thread-1"),
459 model: "claude-opus-4-6",
460 modelOptions: {
461 claudeAgent: {
459 modelSelection: {
460 provider: "claudeAgent",
461 model: "claude-opus-4-6",
462 options: {
462463 fastMode: true,
463464 },
464465 },
......@@ -504,7 +505,9 @@ describe("ProviderCommandReactor", () => {
504505 });
505506
506507 it("rejects a first turn when requested provider conflicts with the thread model", async () => {
507 const harness = await createHarness();
508 const harness = await createHarness({
509 threadModelSelection: { provider: "codex", model: "gpt-5-codex" },
510 });
508511 const now = new Date().toISOString();
509512
510513 await Effect.runPromise(
......@@ -518,7 +521,10 @@ describe("ProviderCommandReactor", () => {
518521 text: "hello claude",
519522 attachments: [],
520523 },
521 provider: "claudeAgent",
524 modelSelection: {
525 provider: "claudeAgent",
526 model: "claude-opus-4-6",
527 },
522528 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
523529 runtimeMode: "approval-required",
524530 createdAt: now,
......@@ -552,49 +558,53 @@ describe("ProviderCommandReactor", () => {
552558 });
553559 });
554560
555 it("rejects a turn when the requested model belongs to a different provider", async () => {
556 const harness = await createHarness();
561 it("preserves the active session model when in-session model switching is unsupported", async () => {
562 const harness = await createHarness({ sessionModelSwitch: "unsupported" });
557563 const now = new Date().toISOString();
558564
559565 await Effect.runPromise(
560566 harness.engine.dispatch({
561567 type: "thread.turn.start",
562 commandId: CommandId.makeUnsafe("cmd-turn-start-model-provider-mismatch"),
568 commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-1"),
563569 threadId: ThreadId.makeUnsafe("thread-1"),
564570 message: {
565 messageId: asMessageId("user-message-model-provider-mismatch"),
571 messageId: asMessageId("user-message-unsupported-1"),
566572 role: "user",
567 text: "hello",
573 text: "first",
568574 attachments: [],
569575 },
570 model: "claude-sonnet-4-6",
571576 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
572577 runtimeMode: "approval-required",
573578 createdAt: now,
574579 }),
575580 );
576581
577 await waitFor(async () => {
578 const readModel = await Effect.runPromise(harness.engine.getReadModel());
579 const thread = readModel.threads.find(
580 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
581 );
582 return (
583 thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ??
584 false
585 );
586 });
582 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
587583
588 expect(harness.startSession).not.toHaveBeenCalled();
589 expect(harness.sendTurn).not.toHaveBeenCalled();
584 await Effect.runPromise(
585 harness.engine.dispatch({
586 type: "thread.turn.start",
587 commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-2"),
588 threadId: ThreadId.makeUnsafe("thread-1"),
589 message: {
590 messageId: asMessageId("user-message-unsupported-2"),
591 role: "user",
592 text: "second",
593 attachments: [],
594 },
595 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
596 runtimeMode: "approval-required",
597 createdAt: now,
598 }),
599 );
590600
591 const readModel = await Effect.runPromise(harness.engine.getReadModel());
592 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));
593 expect(
594 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),
595 ).toMatchObject({
596 payload: {
597 detail: expect.stringContaining("does not belong to provider 'codex'"),
601 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
602
603 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
604 threadId: ThreadId.makeUnsafe("thread-1"),
605 modelSelection: {
606 provider: "codex",
607 model: "gpt-5-codex",
598608 },
599609 });
600610 });
......@@ -646,7 +656,9 @@ describe("ProviderCommandReactor", () => {
646656 });
647657
648658 it("restarts claude sessions when claude effort changes", async () => {
649 const harness = await createHarness({ threadModel: "claude-sonnet-4-6" });
659 const harness = await createHarness({
660 threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
661 });
650662 const now = new Date().toISOString();
651663
652664 await Effect.runPromise(
......@@ -660,10 +672,10 @@ describe("ProviderCommandReactor", () => {
660672 text: "first claude turn",
661673 attachments: [],
662674 },
663 provider: "claudeAgent",
664 model: "claude-sonnet-4-6",
665 modelOptions: {
666 claudeAgent: {
675 modelSelection: {
676 provider: "claudeAgent",
677 model: "claude-sonnet-4-6",
678 options: {
667679 effort: "medium",
668680 },
669681 },
......@@ -687,10 +699,10 @@ describe("ProviderCommandReactor", () => {
687699 text: "second claude turn",
688700 attachments: [],
689701 },
690 provider: "claudeAgent",
691 model: "claude-sonnet-4-6",
692 modelOptions: {
693 claudeAgent: {
702 modelSelection: {
703 provider: "claudeAgent",
704 model: "claude-sonnet-4-6",
705 options: {
694706 effort: "max",
695707 },
696708 },
......@@ -703,10 +715,11 @@ describe("ProviderCommandReactor", () => {
703715 await waitFor(() => harness.startSession.mock.calls.length === 2);
704716 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
705717 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
706 provider: "claudeAgent",
707718 resumeCursor: { opaque: "resume-1" },
708 modelOptions: {
709 claudeAgent: {
719 modelSelection: {
720 provider: "claudeAgent",
721 model: "claude-sonnet-4-6",
722 options: {
710723 effort: "max",
711724 },
712725 },
......@@ -800,6 +813,51 @@ describe("ProviderCommandReactor", () => {
800813 expect(thread?.session?.runtimeMode).toBe("approval-required");
801814 });
802815
816 it("does not inject derived model options when restarting claude on runtime mode changes", async () => {
817 const harness = await createHarness({
818 threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" },
819 });
820 const now = new Date().toISOString();
821
822 await Effect.runPromise(
823 harness.engine.dispatch({
824 type: "thread.session.set",
825 commandId: CommandId.makeUnsafe("cmd-session-set-runtime-mode-claude"),
826 threadId: ThreadId.makeUnsafe("thread-1"),
827 session: {
828 threadId: ThreadId.makeUnsafe("thread-1"),
829 status: "ready",
830 providerName: "claudeAgent",
831 runtimeMode: "full-access",
832 activeTurnId: null,
833 lastError: null,
834 updatedAt: now,
835 },
836 createdAt: now,
837 }),
838 );
839
840 await Effect.runPromise(
841 harness.engine.dispatch({
842 type: "thread.runtime-mode.set",
843 commandId: CommandId.makeUnsafe("cmd-runtime-mode-set-claude-no-options"),
844 threadId: ThreadId.makeUnsafe("thread-1"),
845 runtimeMode: "approval-required",
846 createdAt: now,
847 }),
848 );
849
850 await waitFor(() => harness.startSession.mock.calls.length === 1);
851
852 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
853 modelSelection: {
854 provider: "claudeAgent",
855 model: "claude-opus-4-6",
856 },
857 runtimeMode: "approval-required",
858 });
859 });
860
803861 it("rejects provider changes after a thread is already bound to a session provider", async () => {
804862 const harness = await createHarness();
805863 const now = new Date().toISOString();
......@@ -835,7 +893,10 @@ describe("ProviderCommandReactor", () => {
835893 text: "second",
836894 attachments: [],
837895 },
838 provider: "claudeAgent",
896 modelSelection: {
897 provider: "claudeAgent",
898 model: "claude-opus-4-6",
899 },
839900 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
840901 runtimeMode: "approval-required",
841902 createdAt: now,
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+48-62
......@@ -3,8 +3,8 @@ import {
33 CommandId,
44 DEFAULT_GIT_TEXT_GENERATION_MODEL,
55 EventId,
6 type ModelSelection,
67 type OrchestrationEvent,
7 type ProviderModelOptions,
88 ProviderKind,
99 type ProviderStartOptions,
1010 type OrchestrationSession,
......@@ -13,7 +13,7 @@ import {
1313 type RuntimeMode,
1414 type TurnId,
1515} from "@t3tools/contracts";
16import { Cache, Cause, Duration, Effect, Layer, Option, Schema, Stream } from "effect";
16import { Cache, Cause, Duration, Effect, Equal, Layer, Option, Schema, Stream } from "effect";
1717import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1818
1919import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
......@@ -26,7 +26,6 @@ import {
2626 ProviderCommandReactor,
2727 type ProviderCommandReactorShape,
2828} from "../Services/ProviderCommandReactor.ts";
29import { inferProviderForModel } from "@t3tools/shared/model";
3029
3130type ProviderIntentEvent = Extract<
3231 OrchestrationEvent,
......@@ -76,11 +75,6 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
7675const WORKTREE_BRANCH_PREFIX = "t3code";
7776const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
7877
79const sameModelOptions = (
80 left: ProviderModelOptions | undefined,
81 right: ProviderModelOptions | undefined,
82): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
83
8478function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
8579 const error = Cause.squash(cause);
8680 if (Schema.is(ProviderAdapterRequestError)(error)) {
......@@ -158,7 +152,7 @@ const make = Effect.gen(function* () {
158152 );
159153
160154 const threadProviderOptions = new Map<string, ProviderStartOptions>();
161 const threadModelOptions = new Map<string, ProviderModelOptions>();
155 const threadModelSelections = new Map<string, ModelSelection>();
162156
163157 const appendProviderFailureActivity = (input: {
164158 readonly threadId: ThreadId;
......@@ -215,9 +209,7 @@ const make = Effect.gen(function* () {
215209 threadId: ThreadId,
216210 createdAt: string,
217211 options?: {
218 readonly provider?: ProviderKind;
219 readonly model?: string;
220 readonly modelOptions?: ProviderModelOptions;
212 readonly modelSelection?: ModelSelection;
221213 readonly providerOptions?: ProviderStartOptions;
222214 },
223215 ) {
......@@ -233,26 +225,20 @@ const make = Effect.gen(function* () {
233225 )
234226 ? thread.session.providerName
235227 : undefined;
236 const threadProvider: ProviderKind = currentProvider ?? inferProviderForModel(thread.model);
237 if (options?.provider !== undefined && options.provider !== threadProvider) {
238 return yield* new ProviderAdapterRequestError({
239 provider: threadProvider,
240 method: "thread.turn.start",
241 detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${options.provider}'.`,
242 });
243 }
228 const requestedModelSelection = options?.modelSelection;
229 const threadProvider: ProviderKind = currentProvider ?? thread.modelSelection.provider;
244230 if (
245 options?.model !== undefined &&
246 inferProviderForModel(options.model, threadProvider) !== threadProvider
231 requestedModelSelection !== undefined &&
232 requestedModelSelection.provider !== threadProvider
247233 ) {
248234 return yield* new ProviderAdapterRequestError({
249235 provider: threadProvider,
250236 method: "thread.turn.start",
251 detail: `Model '${options.model}' does not belong to provider '${threadProvider}' for thread '${threadId}'.`,
237 detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${requestedModelSelection.provider}'.`,
252238 });
253239 }
254240 const preferredProvider: ProviderKind = currentProvider ?? threadProvider;
255 const desiredModel = options?.model ?? thread.model;
241 const desiredModelSelection = requestedModelSelection ?? thread.modelSelection;
256242 const effectiveCwd = resolveThreadWorkspaceCwd({
257243 thread,
258244 projects: readModel.projects,
......@@ -269,12 +255,9 @@ const make = Effect.gen(function* () {
269255 }) =>
270256 providerService.startSession(threadId, {
271257 threadId,
272 ...((input?.provider ?? preferredProvider)
273 ? { provider: input?.provider ?? preferredProvider }
274 : {}),
258 ...(preferredProvider ? { provider: preferredProvider } : {}),
275259 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
276 ...(desiredModel ? { model: desiredModel } : {}),
277 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),
260 modelSelection: desiredModelSelection,
278261 ...(options?.providerOptions !== undefined
279262 ? { providerOptions: options.providerOptions }
280263 : {}),
......@@ -303,25 +286,28 @@ const make = Effect.gen(function* () {
303286 if (existingSessionThreadId) {
304287 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
305288 const providerChanged =
306 options?.provider !== undefined && options.provider !== currentProvider;
289 requestedModelSelection !== undefined &&
290 requestedModelSelection.provider !== currentProvider;
307291 const activeSession = yield* resolveActiveSession(existingSessionThreadId);
308292 const sessionModelSwitch =
309293 currentProvider === undefined
310294 ? "in-session"
311295 : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch;
312 const modelChanged = options?.model !== undefined && options.model !== activeSession?.model;
296 const modelChanged =
297 requestedModelSelection !== undefined &&
298 requestedModelSelection.model !== activeSession?.model;
313299 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";
314 const previousModelOptions = threadModelOptions.get(threadId);
315 const shouldRestartForModelOptionsChange =
300 const previousModelSelection = threadModelSelections.get(threadId);
301 const shouldRestartForModelSelectionChange =
316302 currentProvider === "claudeAgent" &&
317 options?.modelOptions !== undefined &&
318 !sameModelOptions(previousModelOptions, options.modelOptions);
303 requestedModelSelection !== undefined &&
304 !Equal.equals(previousModelSelection, requestedModelSelection);
319305
320306 if (
321307 !runtimeModeChanged &&
322308 !providerChanged &&
323309 !shouldRestartForModelChange &&
324 !shouldRestartForModelOptionsChange
310 !shouldRestartForModelSelectionChange
325311 ) {
326312 return existingSessionThreadId;
327313 }
......@@ -334,20 +320,19 @@ const make = Effect.gen(function* () {
334320 threadId,
335321 existingSessionThreadId,
336322 currentProvider,
337 desiredProvider: options?.provider ?? currentProvider,
323 desiredProvider: desiredModelSelection.provider,
338324 currentRuntimeMode: thread.session?.runtimeMode,
339325 desiredRuntimeMode: thread.runtimeMode,
340326 runtimeModeChanged,
341327 providerChanged,
342328 modelChanged,
343329 shouldRestartForModelChange,
344 shouldRestartForModelOptionsChange,
330 shouldRestartForModelSelectionChange,
345331 hasResumeCursor: resumeCursor !== undefined,
346332 });
347 const restartedSession = yield* startProviderSession({
348 ...(resumeCursor !== undefined ? { resumeCursor } : {}),
349 ...(options?.provider !== undefined ? { provider: options.provider } : {}),
350 });
333 const restartedSession = yield* startProviderSession(
334 resumeCursor !== undefined ? { resumeCursor } : undefined,
335 );
351336 yield* Effect.logInfo("provider command reactor restarted provider session", {
352337 threadId,
353338 previousSessionId: existingSessionThreadId,
......@@ -359,9 +344,7 @@ const make = Effect.gen(function* () {
359344 return restartedSession.threadId;
360345 }
361346
362 const startedSession = yield* startProviderSession(
363 options?.provider !== undefined ? { provider: options.provider } : undefined,
364 );
347 const startedSession = yield* startProviderSession(undefined);
365348 yield* bindSessionToThread(startedSession);
366349 return startedSession.threadId;
367350 });
......@@ -370,9 +353,7 @@ const make = Effect.gen(function* () {
370353 readonly threadId: ThreadId;
371354 readonly messageText: string;
372355 readonly attachments?: ReadonlyArray<ChatAttachment>;
373 readonly provider?: ProviderKind;
374 readonly model?: string;
375 readonly modelOptions?: ProviderModelOptions;
356 readonly modelSelection?: ModelSelection;
376357 readonly providerOptions?: ProviderStartOptions;
377358 readonly interactionMode?: "default" | "plan";
378359 readonly createdAt: string;
......@@ -382,16 +363,14 @@ const make = Effect.gen(function* () {
382363 return;
383364 }
384365 yield* ensureSessionForThread(input.threadId, input.createdAt, {
385 ...(input.provider !== undefined ? { provider: input.provider } : {}),
386 ...(input.model !== undefined ? { model: input.model } : {}),
387 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
366 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
388367 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
389368 });
390369 if (input.providerOptions !== undefined) {
391370 threadProviderOptions.set(input.threadId, input.providerOptions);
392371 }
393 if (input.modelOptions !== undefined) {
394 threadModelOptions.set(input.threadId, input.modelOptions);
372 if (input.modelSelection !== undefined) {
373 threadModelSelections.set(input.threadId, input.modelSelection);
395374 }
396375 const normalizedInput = toNonEmptyProviderInput(input.messageText);
397376 const normalizedAttachments = input.attachments ?? [];
......@@ -404,14 +383,23 @@ const make = Effect.gen(function* () {
404383 activeSession === undefined
405384 ? "in-session"
406385 : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch;
407 const modelForTurn = sessionModelSwitch === "unsupported" ? activeSession?.model : input.model;
386 const requestedModelSelection =
387 input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection;
388 const modelForTurn =
389 sessionModelSwitch === "unsupported"
390 ? activeSession?.model !== undefined
391 ? {
392 ...requestedModelSelection,
393 model: activeSession.model,
394 }
395 : requestedModelSelection
396 : input.modelSelection;
408397
409398 yield* providerService.sendTurn({
410399 threadId: input.threadId,
411400 ...(normalizedInput ? { input: normalizedInput } : {}),
412401 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
413 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),
414 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
402 ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}),
415403 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
416404 });
417405 });
......@@ -524,10 +512,8 @@ const make = Effect.gen(function* () {
524512 threadId: event.payload.threadId,
525513 messageText: message.text,
526514 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
527 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),
528 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
529 ...(event.payload.modelOptions !== undefined
530 ? { modelOptions: event.payload.modelOptions }
515 ...(event.payload.modelSelection !== undefined
516 ? { modelSelection: event.payload.modelSelection }
531517 : {}),
532518 ...(event.payload.providerOptions !== undefined
533519 ? { providerOptions: event.payload.providerOptions }
......@@ -698,12 +684,12 @@ const make = Effect.gen(function* () {
698684 return;
699685 }
700686 const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);
701 const cachedModelOptions = threadModelOptions.get(event.payload.threadId);
687 const cachedModelSelection = threadModelSelections.get(event.payload.threadId);
702688 yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {
703689 ...(cachedProviderOptions !== undefined
704690 ? { providerOptions: cachedProviderOptions }
705691 : {}),
706 ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}),
692 ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}),
707693 });
708694 return;
709695 }
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+28-7
......@@ -187,7 +187,10 @@ describe("ProviderRuntimeIngestion", () => {
187187 projectId: asProjectId("project-1"),
188188 title: "Provider Project",
189189 workspaceRoot,
190 defaultModel: "gpt-5-codex",
190 defaultModelSelection: {
191 provider: "codex",
192 model: "gpt-5-codex",
193 },
191194 createdAt,
192195 }),
193196 );
......@@ -198,7 +201,10 @@ describe("ProviderRuntimeIngestion", () => {
198201 threadId: ThreadId.makeUnsafe("thread-1"),
199202 projectId: asProjectId("project-1"),
200203 title: "Thread",
201 model: "gpt-5-codex",
204 modelSelection: {
205 provider: "codex",
206 model: "gpt-5-codex",
207 },
202208 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
203209 runtimeMode: "approval-required",
204210 branch: null,
......@@ -724,7 +730,10 @@ describe("ProviderRuntimeIngestion", () => {
724730 threadId: sourceThreadId,
725731 projectId: asProjectId("project-1"),
726732 title: "Plan Source",
727 model: "gpt-5-codex",
733 modelSelection: {
734 provider: "codex",
735 model: "gpt-5-codex",
736 },
728737 interactionMode: "plan",
729738 runtimeMode: "approval-required",
730739 branch: null,
......@@ -756,7 +765,10 @@ describe("ProviderRuntimeIngestion", () => {
756765 threadId: targetThreadId,
757766 projectId: asProjectId("project-1"),
758767 title: "Plan Target",
759 model: "gpt-5-codex",
768 modelSelection: {
769 provider: "codex",
770 model: "gpt-5-codex",
771 },
760772 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
761773 runtimeMode: "approval-required",
762774 branch: null,
......@@ -905,7 +917,10 @@ describe("ProviderRuntimeIngestion", () => {
905917 threadId: sourceThreadId,
906918 projectId: asProjectId("project-1"),
907919 title: "Plan Source",
908 model: "gpt-5-codex",
920 modelSelection: {
921 provider: "codex",
922 model: "gpt-5-codex",
923 },
909924 interactionMode: "plan",
910925 runtimeMode: "approval-required",
911926 branch: null,
......@@ -1055,7 +1070,10 @@ describe("ProviderRuntimeIngestion", () => {
10551070 threadId: sourceThreadId,
10561071 projectId: asProjectId("project-1"),
10571072 title: "Plan Source",
1058 model: "gpt-5-codex",
1073 modelSelection: {
1074 provider: "codex",
1075 model: "gpt-5-codex",
1076 },
10591077 interactionMode: "plan",
10601078 runtimeMode: "approval-required",
10611079 branch: null,
......@@ -1087,7 +1105,10 @@ describe("ProviderRuntimeIngestion", () => {
10871105 threadId: targetThreadId,
10881106 projectId: asProjectId("project-1"),
10891107 title: "Plan Target",
1090 model: "gpt-5-codex",
1108 modelSelection: {
1109 provider: "codex",
1110 model: "gpt-5-codex",
1111 },
10911112 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
10921113 runtimeMode: "approval-required",
10931114 branch: null,
apps/server/src/orchestration/commandInvariants.test.ts+24-6
......@@ -28,7 +28,10 @@ const readModel: OrchestrationReadModel = {
2828 id: ProjectId.makeUnsafe("project-a"),
2929 title: "Project A",
3030 workspaceRoot: "/tmp/project-a",
31 defaultModel: "gpt-5-codex",
31 defaultModelSelection: {
32 provider: "codex",
33 model: "gpt-5-codex",
34 },
3235 scripts: [],
3336 createdAt: now,
3437 updatedAt: now,
......@@ -38,7 +41,10 @@ const readModel: OrchestrationReadModel = {
3841 id: ProjectId.makeUnsafe("project-b"),
3942 title: "Project B",
4043 workspaceRoot: "/tmp/project-b",
41 defaultModel: "gpt-5-codex",
44 defaultModelSelection: {
45 provider: "codex",
46 model: "gpt-5-codex",
47 },
4248 scripts: [],
4349 createdAt: now,
4450 updatedAt: now,
......@@ -50,7 +56,10 @@ const readModel: OrchestrationReadModel = {
5056 id: ThreadId.makeUnsafe("thread-1"),
5157 projectId: ProjectId.makeUnsafe("project-a"),
5258 title: "Thread A",
53 model: "gpt-5-codex",
59 modelSelection: {
60 provider: "codex",
61 model: "gpt-5-codex",
62 },
5463 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
5564 runtimeMode: "full-access",
5665 branch: null,
......@@ -69,7 +78,10 @@ const readModel: OrchestrationReadModel = {
6978 id: ThreadId.makeUnsafe("thread-2"),
7079 projectId: ProjectId.makeUnsafe("project-b"),
7180 title: "Thread B",
72 model: "gpt-5-codex",
81 modelSelection: {
82 provider: "codex",
83 model: "gpt-5-codex",
84 },
7385 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
7486 runtimeMode: "full-access",
7587 branch: null,
......@@ -144,7 +156,10 @@ describe("commandInvariants", () => {
144156 threadId: ThreadId.makeUnsafe("thread-3"),
145157 projectId: ProjectId.makeUnsafe("project-a"),
146158 title: "new",
147 model: "gpt-5-codex",
159 modelSelection: {
160 provider: "codex",
161 model: "gpt-5-codex",
162 },
148163 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
149164 runtimeMode: "full-access",
150165 branch: null,
......@@ -165,7 +180,10 @@ describe("commandInvariants", () => {
165180 threadId: ThreadId.makeUnsafe("thread-1"),
166181 projectId: ProjectId.makeUnsafe("project-a"),
167182 title: "dup",
168 model: "gpt-5-codex",
183 modelSelection: {
184 provider: "codex",
185 model: "gpt-5-codex",
186 },
169187 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
170188 runtimeMode: "full-access",
171189 branch: null,
apps/server/src/orchestration/decider.projectScripts.test.ts+24-15
......@@ -59,7 +59,7 @@ describe("decider project scripts", () => {
5959 projectId: asProjectId("project-scripts"),
6060 title: "Scripts",
6161 workspaceRoot: "/tmp/scripts",
62 defaultModel: null,
62 defaultModelSelection: null,
6363 scripts: [],
6464 createdAt: now,
6565 updatedAt: now,
......@@ -113,7 +113,7 @@ describe("decider project scripts", () => {
113113 projectId: asProjectId("project-1"),
114114 title: "Project",
115115 workspaceRoot: "/tmp/project",
116 defaultModel: null,
116 defaultModelSelection: null,
117117 scripts: [],
118118 createdAt: now,
119119 updatedAt: now,
......@@ -136,7 +136,10 @@ describe("decider project scripts", () => {
136136 threadId: ThreadId.makeUnsafe("thread-1"),
137137 projectId: asProjectId("project-1"),
138138 title: "Thread",
139 model: "gpt-5-codex",
139 modelSelection: {
140 provider: "codex",
141 model: "gpt-5-codex",
142 },
140143 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
141144 runtimeMode: "approval-required",
142145 branch: null,
......@@ -159,10 +162,10 @@ describe("decider project scripts", () => {
159162 text: "hello",
160163 attachments: [],
161164 },
162 provider: "codex",
163 model: "gpt-5.3-codex",
164 modelOptions: {
165 codex: {
165 modelSelection: {
166 provider: "codex",
167 model: "gpt-5.3-codex",
168 options: {
166169 reasoningEffort: "high",
167170 fastMode: true,
168171 },
......@@ -189,10 +192,10 @@ describe("decider project scripts", () => {
189192 expect(turnStartEvent.payload).toMatchObject({
190193 threadId: ThreadId.makeUnsafe("thread-1"),
191194 messageId: asMessageId("message-user-1"),
192 provider: "codex",
193 model: "gpt-5.3-codex",
194 modelOptions: {
195 codex: {
195 modelSelection: {
196 provider: "codex",
197 model: "gpt-5.3-codex",
198 options: {
196199 reasoningEffort: "high",
197200 fastMode: true,
198201 },
......@@ -220,7 +223,7 @@ describe("decider project scripts", () => {
220223 projectId: asProjectId("project-1"),
221224 title: "Project",
222225 workspaceRoot: "/tmp/project",
223 defaultModel: null,
226 defaultModelSelection: null,
224227 scripts: [],
225228 createdAt: now,
226229 updatedAt: now,
......@@ -243,7 +246,10 @@ describe("decider project scripts", () => {
243246 threadId: ThreadId.makeUnsafe("thread-1"),
244247 projectId: asProjectId("project-1"),
245248 title: "Thread",
246 model: "gpt-5-codex",
249 modelSelection: {
250 provider: "codex",
251 model: "gpt-5-codex",
252 },
247253 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
248254 runtimeMode: "full-access",
249255 branch: null,
......@@ -299,7 +305,7 @@ describe("decider project scripts", () => {
299305 projectId: asProjectId("project-1"),
300306 title: "Project",
301307 workspaceRoot: "/tmp/project",
302 defaultModel: null,
308 defaultModelSelection: null,
303309 scripts: [],
304310 createdAt: now,
305311 updatedAt: now,
......@@ -322,7 +328,10 @@ describe("decider project scripts", () => {
322328 threadId: ThreadId.makeUnsafe("thread-1"),
323329 projectId: asProjectId("project-1"),
324330 title: "Thread",
325 model: "gpt-5-codex",
331 modelSelection: {
332 provider: "codex",
333 model: "gpt-5-codex",
334 },
326335 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
327336 runtimeMode: "approval-required",
328337 branch: null,
apps/server/src/orchestration/decider.ts+11-7
......@@ -77,7 +77,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
7777 projectId: command.projectId,
7878 title: command.title,
7979 workspaceRoot: command.workspaceRoot,
80 defaultModel: command.defaultModel ?? null,
80 defaultModelSelection: command.defaultModelSelection ?? null,
8181 scripts: [],
8282 createdAt: command.createdAt,
8383 updatedAt: command.createdAt,
......@@ -104,7 +104,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
104104 projectId: command.projectId,
105105 ...(command.title !== undefined ? { title: command.title } : {}),
106106 ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}),
107 ...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}),
107 ...(command.defaultModelSelection !== undefined
108 ? { defaultModelSelection: command.defaultModelSelection }
109 : {}),
108110 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
109111 updatedAt: occurredAt,
110112 },
......@@ -156,7 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
156158 threadId: command.threadId,
157159 projectId: command.projectId,
158160 title: command.title,
159 model: command.model,
161 modelSelection: command.modelSelection,
160162 runtimeMode: command.runtimeMode,
161163 interactionMode: command.interactionMode,
162164 branch: command.branch,
......@@ -207,7 +209,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
207209 payload: {
208210 threadId: command.threadId,
209211 ...(command.title !== undefined ? { title: command.title } : {}),
210 ...(command.model !== undefined ? { model: command.model } : {}),
212 ...(command.modelSelection !== undefined
213 ? { modelSelection: command.modelSelection }
214 : {}),
211215 ...(command.branch !== undefined ? { branch: command.branch } : {}),
212216 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
213217 updatedAt: occurredAt,
......@@ -323,9 +327,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
323327 payload: {
324328 threadId: command.threadId,
325329 messageId: command.message.messageId,
326 ...(command.provider !== undefined ? { provider: command.provider } : {}),
327 ...(command.model !== undefined ? { model: command.model } : {}),
328 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),
330 ...(command.modelSelection !== undefined
331 ? { modelSelection: command.modelSelection }
332 : {}),
329333 ...(command.providerOptions !== undefined
330334 ? { providerOptions: command.providerOptions }
331335 : {}),
apps/server/src/orchestration/projector.test.ts+36-9
......@@ -56,7 +56,10 @@ describe("orchestration projector", () => {
5656 threadId: "thread-1",
5757 projectId: "project-1",
5858 title: "demo",
59 model: "gpt-5-codex",
59 modelSelection: {
60 provider: "codex",
61 model: "gpt-5-codex",
62 },
6063 runtimeMode: "full-access",
6164 branch: null,
6265 worktreePath: null,
......@@ -73,7 +76,10 @@ describe("orchestration projector", () => {
7376 id: "thread-1",
7477 projectId: "project-1",
7578 title: "demo",
76 model: "gpt-5-codex",
79 modelSelection: {
80 provider: "codex",
81 model: "gpt-5-codex",
82 },
7783 runtimeMode: "full-access",
7884 interactionMode: "default",
7985 branch: null,
......@@ -110,7 +116,10 @@ describe("orchestration projector", () => {
110116 // missing required threadId
111117 projectId: "project-1",
112118 title: "demo",
113 model: "gpt-5-codex",
119 modelSelection: {
120 provider: "codex",
121 model: "gpt-5-codex",
122 },
114123 branch: null,
115124 worktreePath: null,
116125 createdAt: now,
......@@ -170,7 +179,10 @@ describe("orchestration projector", () => {
170179 threadId: "thread-1",
171180 projectId: "project-1",
172181 title: "demo",
173 model: "gpt-5.3-codex",
182 modelSelection: {
183 provider: "codex",
184 model: "gpt-5.3-codex",
185 },
174186 runtimeMode: "full-access",
175187 branch: null,
176188 worktreePath: null,
......@@ -233,7 +245,10 @@ describe("orchestration projector", () => {
233245 threadId: "thread-1",
234246 projectId: "project-1",
235247 title: "demo",
236 model: "gpt-5.3-codex",
248 modelSelection: {
249 provider: "codex",
250 model: "gpt-5.3-codex",
251 },
237252 runtimeMode: "full-access",
238253 branch: null,
239254 worktreePath: null,
......@@ -287,7 +302,10 @@ describe("orchestration projector", () => {
287302 threadId: "thread-1",
288303 projectId: "project-1",
289304 title: "demo",
290 model: "gpt-5.3-codex",
305 modelSelection: {
306 provider: "codex",
307 model: "gpt-5.3-codex",
308 },
291309 runtimeMode: "full-access",
292310 branch: null,
293311 worktreePath: null,
......@@ -371,7 +389,10 @@ describe("orchestration projector", () => {
371389 threadId: "thread-1",
372390 projectId: "project-1",
373391 title: "demo",
374 model: "gpt-5.3-codex",
392 modelSelection: {
393 provider: "codex",
394 model: "gpt-5.3-codex",
395 },
375396 runtimeMode: "full-access",
376397 branch: null,
377398 worktreePath: null,
......@@ -583,7 +604,10 @@ describe("orchestration projector", () => {
583604 threadId: "thread-revert",
584605 projectId: "project-1",
585606 title: "demo",
586 model: "gpt-5.3-codex",
607 modelSelection: {
608 provider: "codex",
609 model: "gpt-5.3-codex",
610 },
587611 runtimeMode: "full-access",
588612 branch: null,
589613 worktreePath: null,
......@@ -733,7 +757,10 @@ describe("orchestration projector", () => {
733757 threadId: "thread-capped",
734758 projectId: "project-1",
735759 title: "capped",
736 model: "gpt-5-codex",
760 modelSelection: {
761 provider: "codex",
762 model: "gpt-5-codex",
763 },
737764 runtimeMode: "full-access",
738765 branch: null,
739766 worktreePath: null,
apps/server/src/orchestration/projector.ts+7-5
......@@ -181,7 +181,7 @@ export function projectEvent(
181181 id: payload.projectId,
182182 title: payload.title,
183183 workspaceRoot: payload.workspaceRoot,
184 defaultModel: payload.defaultModel,
184 defaultModelSelection: payload.defaultModelSelection,
185185 scripts: payload.scripts,
186186 createdAt: payload.createdAt,
187187 updatedAt: payload.updatedAt,
......@@ -211,8 +211,8 @@ export function projectEvent(
211211 ...(payload.workspaceRoot !== undefined
212212 ? { workspaceRoot: payload.workspaceRoot }
213213 : {}),
214 ...(payload.defaultModel !== undefined
215 ? { defaultModel: payload.defaultModel }
214 ...(payload.defaultModelSelection !== undefined
215 ? { defaultModelSelection: payload.defaultModelSelection }
216216 : {}),
217217 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
218218 updatedAt: payload.updatedAt,
......@@ -252,7 +252,7 @@ export function projectEvent(
252252 id: payload.threadId,
253253 projectId: payload.projectId,
254254 title: payload.title,
255 model: payload.model,
255 modelSelection: payload.modelSelection,
256256 runtimeMode: payload.runtimeMode,
257257 interactionMode: payload.interactionMode,
258258 branch: payload.branch,
......@@ -295,7 +295,9 @@ export function projectEvent(
295295 ...nextBase,
296296 threads: updateThread(nextBase.threads, payload.threadId, {
297297 ...(payload.title !== undefined ? { title: payload.title } : {}),
298 ...(payload.model !== undefined ? { model: payload.model } : {}),
298 ...(payload.modelSelection !== undefined
299 ? { modelSelection: payload.modelSelection }
300 : {}),
299301 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
300302 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
301303 updatedAt: payload.updatedAt,
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+1-1
......@@ -35,7 +35,7 @@ layer("OrchestrationEventStore", (it) => {
3535 projectId: ProjectId.makeUnsafe("project-roundtrip"),
3636 title: "Roundtrip Project",
3737 workspaceRoot: "/tmp/project-roundtrip",
38 defaultModel: null,
38 defaultModelSelection: null,
3939 scripts: [],
4040 createdAt: now,
4141 updatedAt: now,
apps/server/src/persistence/Layers/ProjectionProjects.ts+47-75
......@@ -1,9 +1,9 @@
11import * as SqlClient from "effect/unstable/sql/SqlClient";
22import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Option, Schema, Struct } from "effect";
4
5import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
3import { Effect, Layer, Schema, Struct } from "effect";
64
5import { ModelSelection, ProjectScript } from "@t3tools/contracts";
6import { toPersistenceSqlError } from "../Errors.ts";
77import {
88 DeleteProjectionProjectInput,
99 GetProjectionProjectInput,
......@@ -11,69 +11,64 @@ import {
1111 ProjectionProjectRepository,
1212 type ProjectionProjectRepositoryShape,
1313} from "../Services/ProjectionProjects.ts";
14import { ProjectScript } from "@t3tools/contracts";
1514
16// Makes sure that the scripts are parsed from the JSON string the DB returns
17const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
18 Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }),
15const ProjectionProjectDbRow = ProjectionProject.mapFields(
16 Struct.assign({
17 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
18 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
19 }),
1920);
20
21function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
22 return (cause: unknown) =>
23 Schema.isSchemaError(cause)
24 ? toPersistenceDecodeError(decodeOperation)(cause)
25 : toPersistenceSqlError(sqlOperation)(cause);
26}
21type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type;
2722
2823const makeProjectionProjectRepository = Effect.gen(function* () {
2924 const sql = yield* SqlClient.SqlClient;
3025
3126 const upsertProjectionProjectRow = SqlSchema.void({
32 Request: ProjectionProjectDbRowSchema,
27 Request: ProjectionProject,
3328 execute: (row) =>
3429 sql`
35 INSERT INTO projection_projects (
36 project_id,
37 title,
38 workspace_root,
39 default_model,
40 scripts_json,
41 created_at,
42 updated_at,
43 deleted_at
44 )
45 VALUES (
46 ${row.projectId},
47 ${row.title},
48 ${row.workspaceRoot},
49 ${row.defaultModel},
50 ${row.scripts},
51 ${row.createdAt},
52 ${row.updatedAt},
53 ${row.deletedAt}
54 )
55 ON CONFLICT (project_id)
56 DO UPDATE SET
57 title = excluded.title,
58 workspace_root = excluded.workspace_root,
59 default_model = excluded.default_model,
60 scripts_json = excluded.scripts_json,
61 created_at = excluded.created_at,
62 updated_at = excluded.updated_at,
63 deleted_at = excluded.deleted_at
64 `,
30 INSERT INTO projection_projects (
31 project_id,
32 title,
33 workspace_root,
34 default_model_selection_json,
35 scripts_json,
36 created_at,
37 updated_at,
38 deleted_at
39 )
40 VALUES (
41 ${row.projectId},
42 ${row.title},
43 ${row.workspaceRoot},
44 ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null},
45 ${JSON.stringify(row.scripts)},
46 ${row.createdAt},
47 ${row.updatedAt},
48 ${row.deletedAt}
49 )
50 ON CONFLICT (project_id)
51 DO UPDATE SET
52 title = excluded.title,
53 workspace_root = excluded.workspace_root,
54 default_model_selection_json = excluded.default_model_selection_json,
55 scripts_json = excluded.scripts_json,
56 created_at = excluded.created_at,
57 updated_at = excluded.updated_at,
58 deleted_at = excluded.deleted_at
59 `,
6560 });
6661
6762 const getProjectionProjectRow = SqlSchema.findOneOption({
6863 Request: GetProjectionProjectInput,
69 Result: ProjectionProjectDbRowSchema,
64 Result: ProjectionProjectDbRow,
7065 execute: ({ projectId }) =>
7166 sql`
7267 SELECT
7368 project_id AS "projectId",
7469 title,
7570 workspace_root AS "workspaceRoot",
76 default_model AS "defaultModel",
71 default_model_selection_json AS "defaultModelSelection",
7772 scripts_json AS "scripts",
7873 created_at AS "createdAt",
7974 updated_at AS "updatedAt",
......@@ -85,14 +80,14 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
8580
8681 const listProjectionProjectRows = SqlSchema.findAll({
8782 Request: Schema.Void,
88 Result: ProjectionProjectDbRowSchema,
83 Result: ProjectionProjectDbRow,
8984 execute: () =>
9085 sql`
9186 SELECT
9287 project_id AS "projectId",
9388 title,
9489 workspace_root AS "workspaceRoot",
95 default_model AS "defaultModel",
90 default_model_selection_json AS "defaultModelSelection",
9691 scripts_json AS "scripts",
9792 created_at AS "createdAt",
9893 updated_at AS "updatedAt",
......@@ -113,40 +108,17 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
113108
114109 const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) =>
115110 upsertProjectionProjectRow(row).pipe(
116 Effect.mapError(
117 toPersistenceSqlOrDecodeError(
118 "ProjectionProjectRepository.upsert:query",
119 "ProjectionProjectRepository.upsert:encodeRequest",
120 ),
121 ),
111 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")),
122112 );
123113
124114 const getById: ProjectionProjectRepositoryShape["getById"] = (input) =>
125115 getProjectionProjectRow(input).pipe(
126 Effect.mapError(
127 toPersistenceSqlOrDecodeError(
128 "ProjectionProjectRepository.getById:query",
129 "ProjectionProjectRepository.getById:decodeRow",
130 ),
131 ),
132 Effect.flatMap((rowOption) =>
133 Option.match(rowOption, {
134 onNone: () => Effect.succeed(Option.none()),
135 onSome: (row) =>
136 Effect.succeed(Option.some(row as Schema.Schema.Type<typeof ProjectionProject>)),
137 }),
138 ),
116 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")),
139117 );
140118
141119 const listAll: ProjectionProjectRepositoryShape["listAll"] = () =>
142120 listProjectionProjectRows().pipe(
143 Effect.mapError(
144 toPersistenceSqlOrDecodeError(
145 "ProjectionProjectRepository.listAll:query",
146 "ProjectionProjectRepository.listAll:decodeRows",
147 ),
148 ),
149 Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionProject>>),
121 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")),
150122 );
151123
152124 const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) =>
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts created+122
......@@ -0,0 +1,122 @@
1import { ProjectId, ThreadId } from "@t3tools/contracts";
2import { assert, it } from "@effect/vitest";
3import { Effect, Layer, Option } from "effect";
4import * as SqlClient from "effect/unstable/sql/SqlClient";
5
6import { SqlitePersistenceMemory } from "./Sqlite.ts";
7import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts";
8import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts";
9import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts";
10import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts";
11
12const projectionRepositoriesLayer = it.layer(
13 Layer.mergeAll(
14 ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
15 ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
16 SqlitePersistenceMemory,
17 ),
18);
19
20projectionRepositoriesLayer("Projection repositories", (it) => {
21 it.effect("stores SQL NULL for missing project model options", () =>
22 Effect.gen(function* () {
23 const projects = yield* ProjectionProjectRepository;
24 const sql = yield* SqlClient.SqlClient;
25
26 yield* projects.upsert({
27 projectId: ProjectId.makeUnsafe("project-null-options"),
28 title: "Null options project",
29 workspaceRoot: "/tmp/project-null-options",
30 defaultModelSelection: {
31 provider: "codex",
32 model: "gpt-5.4",
33 },
34 scripts: [],
35 createdAt: "2026-03-24T00:00:00.000Z",
36 updatedAt: "2026-03-24T00:00:00.000Z",
37 deletedAt: null,
38 });
39
40 const rows = yield* sql<{
41 readonly defaultModelSelection: string | null;
42 }>`
43 SELECT default_model_selection_json AS "defaultModelSelection"
44 FROM projection_projects
45 WHERE project_id = 'project-null-options'
46 `;
47 const row = rows[0];
48 if (!row) {
49 return yield* Effect.fail(new Error("Expected projection_projects row to exist."));
50 }
51
52 assert.strictEqual(
53 row.defaultModelSelection,
54 JSON.stringify({
55 provider: "codex",
56 model: "gpt-5.4",
57 }),
58 );
59
60 const persisted = yield* projects.getById({
61 projectId: ProjectId.makeUnsafe("project-null-options"),
62 });
63 assert.deepStrictEqual(Option.getOrNull(persisted)?.defaultModelSelection, {
64 provider: "codex",
65 model: "gpt-5.4",
66 });
67 }),
68 );
69
70 it.effect("stores JSON for thread model options", () =>
71 Effect.gen(function* () {
72 const threads = yield* ProjectionThreadRepository;
73 const sql = yield* SqlClient.SqlClient;
74
75 yield* threads.upsert({
76 threadId: ThreadId.makeUnsafe("thread-null-options"),
77 projectId: ProjectId.makeUnsafe("project-null-options"),
78 title: "Null options thread",
79 modelSelection: {
80 provider: "claudeAgent",
81 model: "claude-opus-4-6",
82 },
83 runtimeMode: "full-access",
84 interactionMode: "default",
85 branch: null,
86 worktreePath: null,
87 latestTurnId: null,
88 createdAt: "2026-03-24T00:00:00.000Z",
89 updatedAt: "2026-03-24T00:00:00.000Z",
90 deletedAt: null,
91 });
92
93 const rows = yield* sql<{
94 readonly modelSelection: string | null;
95 }>`
96 SELECT model_selection_json AS "modelSelection"
97 FROM projection_threads
98 WHERE thread_id = 'thread-null-options'
99 `;
100 const row = rows[0];
101 if (!row) {
102 return yield* Effect.fail(new Error("Expected projection_threads row to exist."));
103 }
104
105 assert.strictEqual(
106 row.modelSelection,
107 JSON.stringify({
108 provider: "claudeAgent",
109 model: "claude-opus-4-6",
110 }),
111 );
112
113 const persisted = yield* threads.getById({
114 threadId: ThreadId.makeUnsafe("thread-null-options"),
115 });
116 assert.deepStrictEqual(Option.getOrNull(persisted)?.modelSelection, {
117 provider: "claudeAgent",
118 model: "claude-opus-4-6",
119 });
120 }),
121 );
122});
apps/server/src/persistence/Layers/ProjectionThreads.ts+16-8
......@@ -1,6 +1,6 @@
11import * as SqlClient from "effect/unstable/sql/SqlClient";
22import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";
3import { Effect, Layer, Schema, Struct } from "effect";
44
55import { toPersistenceSqlError } from "../Errors.ts";
66import {
......@@ -11,6 +11,14 @@ import {
1111 ProjectionThreadRepository,
1212 type ProjectionThreadRepositoryShape,
1313} from "../Services/ProjectionThreads.ts";
14import { ModelSelection } from "@t3tools/contracts";
15
16const ProjectionThreadDbRow = ProjectionThread.mapFields(
17 Struct.assign({
18 modelSelection: Schema.fromJsonString(ModelSelection),
19 }),
20);
21type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type;
1422
1523const makeProjectionThreadRepository = Effect.gen(function* () {
1624 const sql = yield* SqlClient.SqlClient;
......@@ -23,7 +31,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
2331 thread_id,
2432 project_id,
2533 title,
26 model,
34 model_selection_json,
2735 runtime_mode,
2836 interaction_mode,
2937 branch,
......@@ -37,7 +45,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
3745 ${row.threadId},
3846 ${row.projectId},
3947 ${row.title},
40 ${row.model},
48 ${JSON.stringify(row.modelSelection)},
4149 ${row.runtimeMode},
4250 ${row.interactionMode},
4351 ${row.branch},
......@@ -51,7 +59,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
5159 DO UPDATE SET
5260 project_id = excluded.project_id,
5361 title = excluded.title,
54 model = excluded.model,
62 model_selection_json = excluded.model_selection_json,
5563 runtime_mode = excluded.runtime_mode,
5664 interaction_mode = excluded.interaction_mode,
5765 branch = excluded.branch,
......@@ -65,14 +73,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
6573
6674 const getProjectionThreadRow = SqlSchema.findOneOption({
6775 Request: GetProjectionThreadInput,
68 Result: ProjectionThread,
76 Result: ProjectionThreadDbRow,
6977 execute: ({ threadId }) =>
7078 sql`
7179 SELECT
7280 thread_id AS "threadId",
7381 project_id AS "projectId",
7482 title,
75 model,
83 model_selection_json AS "modelSelection",
7684 runtime_mode AS "runtimeMode",
7785 interaction_mode AS "interactionMode",
7886 branch,
......@@ -88,14 +96,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
8896
8997 const listProjectionThreadRows = SqlSchema.findAll({
9098 Request: ListProjectionThreadsByProjectInput,
91 Result: ProjectionThread,
99 Result: ProjectionThreadDbRow,
92100 execute: ({ projectId }) =>
93101 sql`
94102 SELECT
95103 thread_id AS "threadId",
96104 project_id AS "projectId",
97105 title,
98 model,
106 model_selection_json AS "modelSelection",
99107 runtime_mode AS "runtimeMode",
100108 interaction_mode AS "interactionMode",
101109 branch,
apps/server/src/persistence/Layers/Sqlite.ts+1-1
......@@ -31,7 +31,7 @@ const setup = Layer.effectDiscard(
3131 const sql = yield* SqlClient.SqlClient;
3232 yield* sql`PRAGMA journal_mode = WAL;`;
3333 yield* sql`PRAGMA foreign_keys = ON;`;
34 yield* runMigrations;
34 yield* runMigrations();
3535 }),
3636);
3737
apps/server/src/persistence/Migrations.ts+47-24
......@@ -10,6 +10,7 @@
1010
1111import * as Migrator from "effect/unstable/sql/Migrator";
1212import * as Layer from "effect/Layer";
13import * as Effect from "effect/Effect";
1314
1415// Import all migrations statically
1516import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
......@@ -27,7 +28,7 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"
2728import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";
2829import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts";
2930import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts";
30import { Effect } from "effect";
31import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts";
3132
3233/**
3334 * Migration loader with all migrations defined inline.
......@@ -39,23 +40,33 @@ import { Effect } from "effect";
3940 * Uses Migrator.fromRecord which parses the key format and
4041 * returns migrations sorted by ID.
4142 */
42const loader = Migrator.fromRecord({
43 "1_OrchestrationEvents": Migration0001,
44 "2_OrchestrationCommandReceipts": Migration0002,
45 "3_CheckpointDiffBlobs": Migration0003,
46 "4_ProviderSessionRuntime": Migration0004,
47 "5_Projections": Migration0005,
48 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,
49 "7_ProjectionThreadMessageAttachments": Migration0007,
50 "8_ProjectionThreadActivitySequence": Migration0008,
51 "9_ProviderSessionRuntimeMode": Migration0009,
52 "10_ProjectionThreadsRuntimeMode": Migration0010,
53 "11_OrchestrationThreadCreatedRuntimeMode": Migration0011,
54 "12_ProjectionThreadsInteractionMode": Migration0012,
55 "13_ProjectionThreadProposedPlans": Migration0013,
56 "14_ProjectionThreadProposedPlanImplementation": Migration0014,
57 "15_ProjectionTurnsSourceProposedPlan": Migration0015,
58});
43export const migrationEntries = [
44 [1, "OrchestrationEvents", Migration0001],
45 [2, "OrchestrationCommandReceipts", Migration0002],
46 [3, "CheckpointDiffBlobs", Migration0003],
47 [4, "ProviderSessionRuntime", Migration0004],
48 [5, "Projections", Migration0005],
49 [6, "ProjectionThreadSessionRuntimeModeColumns", Migration0006],
50 [7, "ProjectionThreadMessageAttachments", Migration0007],
51 [8, "ProjectionThreadActivitySequence", Migration0008],
52 [9, "ProviderSessionRuntimeMode", Migration0009],
53 [10, "ProjectionThreadsRuntimeMode", Migration0010],
54 [11, "OrchestrationThreadCreatedRuntimeMode", Migration0011],
55 [12, "ProjectionThreadsInteractionMode", Migration0012],
56 [13, "ProjectionThreadProposedPlans", Migration0013],
57 [14, "ProjectionThreadProposedPlanImplementation", Migration0014],
58 [15, "ProjectionTurnsSourceProposedPlan", Migration0015],
59 [16, "CanonicalizeModelSelections", Migration0016],
60] as const;
61
62export const makeMigrationLoader = (throughId?: number) =>
63 Migrator.fromRecord(
64 Object.fromEntries(
65 migrationEntries
66 .filter(([id]) => throughId === undefined || id <= throughId)
67 .map(([id, name, migration]) => [`${id}_${name}`, migration]),
68 ),
69 );
5970
6071/**
6172 * Migrator run function - no schema dumping needed
......@@ -63,6 +74,10 @@ const loader = Migrator.fromRecord({
6374 */
6475const run = Migrator.make({});
6576
77export interface RunMigrationsOptions {
78 readonly toMigrationInclusive?: number | undefined;
79}
80
6681/**
6782 * Run all pending migrations.
6883 *
......@@ -73,11 +88,19 @@ const run = Migrator.make({});
7388 *
7489 * @returns Effect containing array of executed migrations
7590 */
76export const runMigrations = Effect.gen(function* () {
77 yield* Effect.log("Running migrations...");
78 yield* run({ loader });
79 yield* Effect.log("Migrations ran successfully");
80});
91export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) =>
92 Effect.gen(function* () {
93 yield* Effect.log(
94 toMigrationInclusive === undefined
95 ? "Running all migrations..."
96 : `Running migrations 1 through ${toMigrationInclusive}...`,
97 );
98 const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) });
99 yield* Effect.log("Migrations ran successfully").pipe(
100 Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }),
101 );
102 return executedMigrations;
103 });
81104
82105/**
83106 * Layer that runs migrations when the layer is built.
......@@ -96,4 +119,4 @@ export const runMigrations = Effect.gen(function* () {
96119 * )
97120 * ```
98121 */
99export const MigrationsLive = Layer.effectDiscard(runMigrations);
122export const MigrationsLive = Layer.effectDiscard(runMigrations());
apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts created+357
......@@ -0,0 +1,357 @@
1import { assert, it } from "@effect/vitest";
2import { Effect, Layer } from "effect";
3import * as SqlClient from "effect/unstable/sql/SqlClient";
4
5import { runMigrations } from "../Migrations.ts";
6import * as NodeSqliteClient from "../NodeSqliteClient.ts";
7
8const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory()));
9
10layer("016_CanonicalizeModelSelections", (it) => {
11 it.effect(
12 "migrates legacy projection rows and event payloads to the canonical model-selection shape",
13 () =>
14 Effect.gen(function* () {
15 const sql = yield* SqlClient.SqlClient;
16
17 // Setup base state
18 {
19 yield* runMigrations({ toMigrationInclusive: 15 });
20
21 yield* sql`
22 INSERT INTO projection_projects (
23 project_id,
24 title,
25 workspace_root,
26 default_model,
27 scripts_json,
28 created_at,
29 updated_at,
30 deleted_at
31 )
32 VALUES
33 ('project-codex', 'Codex project', '/tmp/project-codex', 'gpt-5.4', '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL),
34 ('project-claude', 'Claude project', '/tmp/project-claude', 'claude-sonnet-4-6', '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL),
35 ('project-null', 'Null project', '/tmp/project-null', NULL, '[]', '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL)
36 `;
37 yield* sql`
38 UPDATE projection_projects
39 SET default_model = 'claude-opus-4-6'
40 WHERE project_id = 'project-claude'
41 `;
42 yield* sql`
43 INSERT INTO projection_threads (
44 thread_id,
45 project_id,
46 title,
47 model,
48 branch,
49 worktree_path,
50 latest_turn_id,
51 created_at,
52 updated_at,
53 deleted_at,
54 runtime_mode,
55 interaction_mode
56 )
57 VALUES
58 ('thread-session', 'project-codex', 'Session thread', 'gpt-5.4', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'),
59 ('thread-claude', 'project-claude', 'Claude thread', 'claude-opus-4-6', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'),
60 ('thread-codex', 'project-codex', 'Codex thread', 'gpt-5.4', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'),
61 ('thread-legacy-options', 'project-claude', 'Legacy options thread', 'claude-opus-4-6', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default')
62 `;
63 yield* sql`
64 INSERT INTO projection_thread_sessions (
65 thread_id,
66 status,
67 provider_name,
68 provider_session_id,
69 provider_thread_id,
70 active_turn_id,
71 last_error,
72 updated_at,
73 runtime_mode
74 )
75 VALUES (
76 'thread-session',
77 'running',
78 'claudeAgent',
79 'provider-session-1',
80 'provider-thread-1',
81 NULL,
82 NULL,
83 '2026-01-01T00:00:00.000Z',
84 'full-access'
85 )
86 `;
87 yield* sql`
88 INSERT INTO orchestration_events (
89 event_id,
90 aggregate_kind,
91 stream_id,
92 stream_version,
93 event_type,
94 occurred_at,
95 command_id,
96 causation_event_id,
97 correlation_id,
98 actor_kind,
99 payload_json,
100 metadata_json
101 )
102 VALUES
103 (
104 'event-project-created',
105 'project',
106 'project-1',
107 1,
108 'project.created',
109 '2026-01-01T00:00:00.000Z',
110 'command-project-created',
111 NULL,
112 'correlation-project-created',
113 'user',
114 '{"projectId":"project-1","title":"Project","workspaceRoot":"/tmp/project","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
115 '{}'
116 ),
117 (
118 'event-project-created-fallback',
119 'project',
120 'project-2',
121 1,
122 'project.created',
123 '2026-01-01T00:00:00.000Z',
124 'command-project-created-fallback',
125 NULL,
126 'correlation-project-created-fallback',
127 'user',
128 '{"projectId":"project-2","title":"Fallback Project","workspaceRoot":"/tmp/project-2","defaultModel":"claude-opus-4-6","defaultModelOptions":{"codex":{"reasoningEffort":"low"}},"scripts":[],"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
129 '{}'
130 ),
131 (
132 'event-thread-created',
133 'thread',
134 'thread-1',
135 1,
136 'thread.created',
137 '2026-01-01T00:00:00.000Z',
138 'command-thread-created',
139 NULL,
140 'correlation-thread-created',
141 'user',
142 '{"threadId":"thread-1","projectId":"project-1","title":"Thread","model":"claude-opus-4-6","modelOptions":{"codex":{"reasoningEffort":"high"},"claudeAgent":{"effort":"max","thinking":false}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
143 '{}'
144 ),
145 (
146 'event-thread-created-fallback',
147 'thread',
148 'thread-2',
149 1,
150 'thread.created',
151 '2026-01-01T00:00:00.000Z',
152 'command-thread-created-fallback',
153 NULL,
154 'correlation-thread-created-fallback',
155 'user',
156 '{"threadId":"thread-2","projectId":"project-1","title":"Fallback Thread","model":"gpt-5.4","modelOptions":{"claudeAgent":{"effort":"max"}},"runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
157 '{}'
158 ),
159 (
160 'event-turn-start-requested',
161 'thread',
162 'thread-1',
163 2,
164 'thread.turn-start-requested',
165 '2026-01-01T00:00:00.000Z',
166 'command-turn-start-requested',
167 NULL,
168 'correlation-turn-start-requested',
169 'user',
170 '{"threadId":"thread-1","turnId":"turn-1","input":"hi","model":"gpt-5.4","modelOptions":{"codex":{"fastMode":true},"claudeAgent":{"effort":"max"}},"deliveryMode":"buffered"}',
171 '{}'
172 ),
173 (
174 'event-thread-created-no-model',
175 'thread',
176 'thread-3',
177 1,
178 'thread.created',
179 '2026-01-01T00:00:00.000Z',
180 'command-thread-created-no-model',
181 NULL,
182 'correlation-thread-created-no-model',
183 'user',
184 '{"threadId":"thread-3","projectId":"project-1","title":"Ancient Thread","runtimeMode":"full-access","interactionMode":"default","branch":null,"worktreePath":null,"createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z"}',
185 '{}'
186 )
187 `;
188 }
189
190 // Execute migration under test
191 yield* runMigrations({ toMigrationInclusive: 16 });
192
193 // Assert expected state
194 {
195 const projectRows = yield* sql<{
196 readonly projectId: string;
197 readonly defaultModelSelection: string | null;
198 }>`
199 SELECT
200 project_id AS "projectId",
201 default_model_selection_json AS "defaultModelSelection"
202 FROM projection_projects
203 ORDER BY project_id
204 `;
205 assert.deepStrictEqual(projectRows, [
206 {
207 projectId: "project-claude",
208 defaultModelSelection: '{"provider":"claudeAgent","model":"claude-opus-4-6"}',
209 },
210 {
211 projectId: "project-codex",
212 defaultModelSelection: '{"provider":"codex","model":"gpt-5.4"}',
213 },
214 { projectId: "project-null", defaultModelSelection: null },
215 ]);
216
217 const threadRows = yield* sql<{
218 readonly threadId: string;
219 readonly modelSelection: string | null;
220 }>`
221 SELECT
222 thread_id AS "threadId",
223 model_selection_json AS "modelSelection"
224 FROM projection_threads
225 ORDER BY thread_id
226 `;
227 assert.deepStrictEqual(threadRows, [
228 {
229 threadId: "thread-claude",
230 modelSelection: '{"provider":"claudeAgent","model":"claude-opus-4-6"}',
231 },
232 {
233 threadId: "thread-codex",
234 modelSelection: '{"provider":"codex","model":"gpt-5.4"}',
235 },
236 {
237 threadId: "thread-legacy-options",
238 modelSelection: '{"provider":"claudeAgent","model":"claude-opus-4-6"}',
239 },
240 {
241 threadId: "thread-session",
242 modelSelection: '{"provider":"claudeAgent","model":"gpt-5.4"}',
243 },
244 ]);
245
246 const eventRows = yield* sql<{
247 readonly payloadJson: string;
248 }>`
249 SELECT payload_json AS "payloadJson"
250 FROM orchestration_events
251 ORDER BY rowid ASC
252 `;
253
254 assert.deepStrictEqual(JSON.parse(eventRows[0]!.payloadJson), {
255 projectId: "project-1",
256 title: "Project",
257 workspaceRoot: "/tmp/project",
258 defaultModelSelection: {
259 provider: "claudeAgent",
260 model: "claude-opus-4-6",
261 options: {
262 effort: "max",
263 },
264 },
265 scripts: [],
266 createdAt: "2026-01-01T00:00:00.000Z",
267 updatedAt: "2026-01-01T00:00:00.000Z",
268 });
269
270 assert.deepStrictEqual(JSON.parse(eventRows[1]!.payloadJson), {
271 projectId: "project-2",
272 title: "Fallback Project",
273 workspaceRoot: "/tmp/project-2",
274 defaultModelSelection: {
275 provider: "claudeAgent",
276 model: "claude-opus-4-6",
277 options: {
278 reasoningEffort: "low",
279 },
280 },
281 scripts: [],
282 createdAt: "2026-01-01T00:00:00.000Z",
283 updatedAt: "2026-01-01T00:00:00.000Z",
284 });
285
286 assert.deepStrictEqual(JSON.parse(eventRows[2]!.payloadJson), {
287 threadId: "thread-1",
288 projectId: "project-1",
289 title: "Thread",
290 modelSelection: {
291 provider: "claudeAgent",
292 model: "claude-opus-4-6",
293 options: {
294 effort: "max",
295 thinking: false,
296 },
297 },
298 runtimeMode: "full-access",
299 interactionMode: "default",
300 branch: null,
301 worktreePath: null,
302 createdAt: "2026-01-01T00:00:00.000Z",
303 updatedAt: "2026-01-01T00:00:00.000Z",
304 });
305
306 assert.deepStrictEqual(JSON.parse(eventRows[3]!.payloadJson), {
307 threadId: "thread-2",
308 projectId: "project-1",
309 title: "Fallback Thread",
310 modelSelection: {
311 provider: "codex",
312 model: "gpt-5.4",
313 options: {
314 effort: "max",
315 },
316 },
317 runtimeMode: "full-access",
318 interactionMode: "default",
319 branch: null,
320 worktreePath: null,
321 createdAt: "2026-01-01T00:00:00.000Z",
322 updatedAt: "2026-01-01T00:00:00.000Z",
323 });
324
325 assert.deepStrictEqual(JSON.parse(eventRows[4]!.payloadJson), {
326 threadId: "thread-1",
327 turnId: "turn-1",
328 input: "hi",
329 modelSelection: {
330 provider: "codex",
331 model: "gpt-5.4",
332 options: {
333 fastMode: true,
334 },
335 },
336 deliveryMode: "buffered",
337 });
338
339 assert.deepStrictEqual(JSON.parse(eventRows[5]!.payloadJson), {
340 threadId: "thread-3",
341 projectId: "project-1",
342 title: "Ancient Thread",
343 modelSelection: {
344 provider: "codex",
345 model: "gpt-5.4",
346 },
347 runtimeMode: "full-access",
348 interactionMode: "default",
349 branch: null,
350 worktreePath: null,
351 createdAt: "2026-01-01T00:00:00.000Z",
352 updatedAt: "2026-01-01T00:00:00.000Z",
353 });
354 }
355 }),
356 );
357});
apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.ts created+235
......@@ -0,0 +1,235 @@
1import * as Effect from "effect/Effect";
2import * as SqlClient from "effect/unstable/sql/SqlClient";
3
4export default Effect.gen(function* () {
5 const sql = yield* SqlClient.SqlClient;
6
7 yield* sql`
8 ALTER TABLE projection_projects
9 ADD COLUMN default_model_selection_json TEXT
10 `;
11
12 yield* sql`
13 UPDATE projection_projects
14 SET default_model_selection_json = CASE
15 WHEN default_model IS NULL THEN NULL
16 ELSE json_object(
17 'provider',
18 CASE
19 WHEN lower(default_model) LIKE '%claude%' THEN 'claudeAgent'
20 ELSE 'codex'
21 END,
22 'model',
23 default_model
24 )
25 END
26 WHERE default_model_selection_json IS NULL
27 `;
28
29 yield* sql`
30 ALTER TABLE projection_threads
31 ADD COLUMN model_selection_json TEXT
32 `;
33
34 yield* sql`
35 UPDATE projection_threads
36 SET model_selection_json = json_object(
37 'provider',
38 COALESCE(
39 (
40 SELECT provider_name
41 FROM projection_thread_sessions
42 WHERE projection_thread_sessions.thread_id = projection_threads.thread_id
43 ),
44 CASE
45 WHEN lower(model) LIKE '%claude%' THEN 'claudeAgent'
46 ELSE 'codex'
47 END,
48 'codex'
49 ),
50 'model',
51 model
52 )
53 WHERE model_selection_json IS NULL
54 `;
55
56 yield* sql`
57 ALTER TABLE projection_projects
58 DROP COLUMN default_model
59 `;
60
61 yield* sql`
62 ALTER TABLE projection_threads
63 DROP COLUMN model
64 `;
65
66 yield* sql`
67 UPDATE orchestration_events
68 SET payload_json = CASE
69 WHEN json_type(payload_json, '$.defaultModel') = 'null' THEN json_remove(
70 json_set(payload_json, '$.defaultModelSelection', json('null')),
71 '$.defaultProvider',
72 '$.defaultModel',
73 '$.defaultModelOptions'
74 )
75 ELSE json_remove(
76 json_set(
77 payload_json,
78 '$.defaultModelSelection',
79 json_patch(
80 json_object(
81 'provider',
82 CASE
83 WHEN json_extract(payload_json, '$.defaultProvider') IS NOT NULL
84 THEN json_extract(payload_json, '$.defaultProvider')
85 WHEN lower(json_extract(payload_json, '$.defaultModel')) LIKE '%claude%'
86 THEN 'claudeAgent'
87 ELSE 'codex'
88 END,
89 'model',
90 json_extract(payload_json, '$.defaultModel')
91 ),
92 CASE
93 WHEN json_type(payload_json, '$.defaultModelOptions') IS NULL THEN '{}'
94 WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL
95 OR json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL
96 THEN CASE
97 WHEN (
98 CASE
99 WHEN json_extract(payload_json, '$.defaultProvider') IS NOT NULL
100 THEN json_extract(payload_json, '$.defaultProvider')
101 WHEN lower(json_extract(payload_json, '$.defaultModel')) LIKE '%claude%'
102 THEN 'claudeAgent'
103 ELSE 'codex'
104 END
105 ) = 'claudeAgent'
106 THEN CASE
107 WHEN json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL
108 THEN json_object(
109 'options',
110 json(json_extract(payload_json, '$.defaultModelOptions.claudeAgent'))
111 )
112 WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL
113 THEN json_object(
114 'options',
115 json(json_extract(payload_json, '$.defaultModelOptions.codex'))
116 )
117 ELSE '{}'
118 END
119 ELSE CASE
120 WHEN json_type(payload_json, '$.defaultModelOptions.codex') IS NOT NULL
121 THEN json_object(
122 'options',
123 json(json_extract(payload_json, '$.defaultModelOptions.codex'))
124 )
125 WHEN json_type(payload_json, '$.defaultModelOptions.claudeAgent') IS NOT NULL
126 THEN json_object(
127 'options',
128 json(json_extract(payload_json, '$.defaultModelOptions.claudeAgent'))
129 )
130 ELSE '{}'
131 END
132 END
133 ELSE json_object(
134 'options',
135 json(json_extract(payload_json, '$.defaultModelOptions'))
136 )
137 END
138 )
139 ),
140 '$.defaultProvider',
141 '$.defaultModel',
142 '$.defaultModelOptions'
143 )
144 END
145 WHERE event_type IN ('project.created', 'project.meta-updated')
146 AND json_type(payload_json, '$.defaultModelSelection') IS NULL
147 AND json_type(payload_json, '$.defaultModel') IS NOT NULL
148 `;
149
150 yield* sql`
151 UPDATE orchestration_events
152 SET payload_json = json_remove(
153 json_set(
154 payload_json,
155 '$.modelSelection',
156 json_patch(
157 json_object(
158 'provider',
159 CASE
160 WHEN json_extract(payload_json, '$.provider') IS NOT NULL
161 THEN json_extract(payload_json, '$.provider')
162 WHEN lower(json_extract(payload_json, '$.model')) LIKE '%claude%'
163 THEN 'claudeAgent'
164 ELSE 'codex'
165 END,
166 'model',
167 json_extract(payload_json, '$.model')
168 ),
169 CASE
170 WHEN json_type(payload_json, '$.modelOptions') IS NULL THEN '{}'
171 WHEN json_type(payload_json, '$.modelOptions.codex') IS NOT NULL
172 OR json_type(payload_json, '$.modelOptions.claudeAgent') IS NOT NULL
173 THEN CASE
174 WHEN (
175 CASE
176 WHEN json_extract(payload_json, '$.provider') IS NOT NULL
177 THEN json_extract(payload_json, '$.provider')
178 WHEN lower(json_extract(payload_json, '$.model')) LIKE '%claude%'
179 THEN 'claudeAgent'
180 ELSE 'codex'
181 END
182 ) = 'claudeAgent'
183 THEN CASE
184 WHEN json_type(payload_json, '$.modelOptions.claudeAgent') IS NOT NULL
185 THEN json_object(
186 'options',
187 json(json_extract(payload_json, '$.modelOptions.claudeAgent'))
188 )
189 WHEN json_type(payload_json, '$.modelOptions.codex') IS NOT NULL
190 THEN json_object(
191 'options',
192 json(json_extract(payload_json, '$.modelOptions.codex'))
193 )
194 ELSE '{}'
195 END
196 ELSE CASE
197 WHEN json_type(payload_json, '$.modelOptions.codex') IS NOT NULL
198 THEN json_object(
199 'options',
200 json(json_extract(payload_json, '$.modelOptions.codex'))
201 )
202 WHEN json_type(payload_json, '$.modelOptions.claudeAgent') IS NOT NULL
203 THEN json_object(
204 'options',
205 json(json_extract(payload_json, '$.modelOptions.claudeAgent'))
206 )
207 ELSE '{}'
208 END
209 END
210 ELSE json_object('options', json(json_extract(payload_json, '$.modelOptions')))
211 END
212 )
213 ),
214 '$.provider',
215 '$.model',
216 '$.modelOptions'
217 )
218 WHERE event_type IN ('thread.created', 'thread.meta-updated', 'thread.turn-start-requested')
219 AND json_type(payload_json, '$.modelSelection') IS NULL
220 AND json_type(payload_json, '$.model') IS NOT NULL
221 `;
222
223 // Backfill thread.created events that predate the model field entirely
224 yield* sql`
225 UPDATE orchestration_events
226 SET payload_json = json_set(
227 payload_json,
228 '$.modelSelection',
229 json(json_object('provider', 'codex', 'model', 'gpt-5.4'))
230 )
231 WHERE event_type = 'thread.created'
232 AND json_type(payload_json, '$.modelSelection') IS NULL
233 AND json_type(payload_json, '$.model') IS NULL
234 `;
235});
apps/server/src/persistence/Services/ProjectionProjects.ts+2-2
......@@ -6,7 +6,7 @@
66 *
77 * @module ProjectionProjectRepository
88 */
9import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts";
9import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts";
1010import { Option, Schema, ServiceMap } from "effect";
1111import type { Effect } from "effect";
1212
......@@ -16,7 +16,7 @@ export const ProjectionProject = Schema.Struct({
1616 projectId: ProjectId,
1717 title: Schema.String,
1818 workspaceRoot: Schema.String,
19 defaultModel: Schema.NullOr(Schema.String),
19 defaultModelSelection: Schema.NullOr(ModelSelection),
2020 scripts: Schema.Array(ProjectScript),
2121 createdAt: IsoDateTime,
2222 updatedAt: IsoDateTime,
apps/server/src/persistence/Services/ProjectionThreads.ts+2-1
......@@ -8,6 +8,7 @@
88 */
99import {
1010 IsoDateTime,
11 ModelSelection,
1112 ProjectId,
1213 ProviderInteractionMode,
1314 RuntimeMode,
......@@ -23,7 +24,7 @@ export const ProjectionThread = Schema.Struct({
2324 threadId: ThreadId,
2425 projectId: ProjectId,
2526 title: Schema.String,
26 model: Schema.String,
27 modelSelection: ModelSelection,
2728 runtimeMode: RuntimeMode,
2829 interactionMode: ProviderInteractionMode,
2930 branch: Schema.NullOr(Schema.String),
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+52-37
......@@ -332,13 +332,14 @@ describe("ClaudeAdapterLive", () => {
332332 yield* adapter.startSession({
333333 threadId: THREAD_ID,
334334 provider: "claudeAgent",
335 model: "claude-opus-4-6",
336 runtimeMode: "full-access",
337 modelOptions: {
338 claudeAgent: {
335 modelSelection: {
336 provider: "claudeAgent",
337 model: "claude-opus-4-6",
338 options: {
339339 effort: "max",
340340 },
341341 },
342 runtimeMode: "full-access",
342343 });
343344
344345 const createInput = harness.getLastCreateQueryInput();
......@@ -356,13 +357,14 @@ describe("ClaudeAdapterLive", () => {
356357 yield* adapter.startSession({
357358 threadId: THREAD_ID,
358359 provider: "claudeAgent",
359 model: "claude-sonnet-4-6",
360 runtimeMode: "full-access",
361 modelOptions: {
362 claudeAgent: {
360 modelSelection: {
361 provider: "claudeAgent",
362 model: "claude-sonnet-4-6",
363 options: {
363364 effort: "max",
364365 },
365366 },
367 runtimeMode: "full-access",
366368 });
367369
368370 const createInput = harness.getLastCreateQueryInput();
......@@ -380,13 +382,14 @@ describe("ClaudeAdapterLive", () => {
380382 yield* adapter.startSession({
381383 threadId: THREAD_ID,
382384 provider: "claudeAgent",
383 model: "claude-haiku-4-5",
384 runtimeMode: "full-access",
385 modelOptions: {
386 claudeAgent: {
385 modelSelection: {
386 provider: "claudeAgent",
387 model: "claude-haiku-4-5",
388 options: {
387389 effort: "high",
388390 },
389391 },
392 runtimeMode: "full-access",
390393 });
391394
392395 const createInput = harness.getLastCreateQueryInput();
......@@ -404,13 +407,14 @@ describe("ClaudeAdapterLive", () => {
404407 yield* adapter.startSession({
405408 threadId: THREAD_ID,
406409 provider: "claudeAgent",
407 model: "claude-haiku-4-5",
408 runtimeMode: "full-access",
409 modelOptions: {
410 claudeAgent: {
410 modelSelection: {
411 provider: "claudeAgent",
412 model: "claude-haiku-4-5",
413 options: {
411414 thinking: false,
412415 },
413416 },
417 runtimeMode: "full-access",
414418 });
415419
416420 const createInput = harness.getLastCreateQueryInput();
......@@ -430,13 +434,14 @@ describe("ClaudeAdapterLive", () => {
430434 yield* adapter.startSession({
431435 threadId: THREAD_ID,
432436 provider: "claudeAgent",
433 model: "claude-sonnet-4-6",
434 runtimeMode: "full-access",
435 modelOptions: {
436 claudeAgent: {
437 modelSelection: {
438 provider: "claudeAgent",
439 model: "claude-sonnet-4-6",
440 options: {
437441 thinking: false,
438442 },
439443 },
444 runtimeMode: "full-access",
440445 });
441446
442447 const createInput = harness.getLastCreateQueryInput();
......@@ -454,13 +459,14 @@ describe("ClaudeAdapterLive", () => {
454459 yield* adapter.startSession({
455460 threadId: THREAD_ID,
456461 provider: "claudeAgent",
457 model: "claude-opus-4-6",
458 runtimeMode: "full-access",
459 modelOptions: {
460 claudeAgent: {
462 modelSelection: {
463 provider: "claudeAgent",
464 model: "claude-opus-4-6",
465 options: {
461466 fastMode: true,
462467 },
463468 },
469 runtimeMode: "full-access",
464470 });
465471
466472 const createInput = harness.getLastCreateQueryInput();
......@@ -480,13 +486,14 @@ describe("ClaudeAdapterLive", () => {
480486 yield* adapter.startSession({
481487 threadId: THREAD_ID,
482488 provider: "claudeAgent",
483 model: "claude-sonnet-4-6",
484 runtimeMode: "full-access",
485 modelOptions: {
486 claudeAgent: {
489 modelSelection: {
490 provider: "claudeAgent",
491 model: "claude-sonnet-4-6",
492 options: {
487493 fastMode: true,
488494 },
489495 },
496 runtimeMode: "full-access",
490497 });
491498
492499 const createInput = harness.getLastCreateQueryInput();
......@@ -504,22 +511,24 @@ describe("ClaudeAdapterLive", () => {
504511 const session = yield* adapter.startSession({
505512 threadId: THREAD_ID,
506513 provider: "claudeAgent",
507 model: "claude-sonnet-4-6",
508 runtimeMode: "full-access",
509 modelOptions: {
510 claudeAgent: {
514 modelSelection: {
515 provider: "claudeAgent",
516 model: "claude-sonnet-4-6",
517 options: {
511518 effort: "ultrathink",
512519 },
513520 },
521 runtimeMode: "full-access",
514522 });
515523
516524 yield* adapter.sendTurn({
517525 threadId: session.threadId,
518526 input: "Investigate the edge cases",
519527 attachments: [],
520 model: "claude-sonnet-4-6",
521 modelOptions: {
522 claudeAgent: {
528 modelSelection: {
529 provider: "claudeAgent",
530 model: "claude-sonnet-4-6",
531 options: {
523532 effort: "ultrathink",
524533 },
525534 },
......@@ -613,7 +622,10 @@ describe("ClaudeAdapterLive", () => {
613622 const session = yield* adapter.startSession({
614623 threadId: THREAD_ID,
615624 provider: "claudeAgent",
616 model: "claude-sonnet-4-5",
625 modelSelection: {
626 provider: "claudeAgent",
627 model: "claude-sonnet-4-5",
628 },
617629 runtimeMode: "full-access",
618630 });
619631
......@@ -2360,7 +2372,10 @@ describe("ClaudeAdapterLive", () => {
23602372 yield* adapter.sendTurn({
23612373 threadId: session.threadId,
23622374 input: "hello",
2363 model: "claude-opus-4-6",
2375 modelSelection: {
2376 provider: "claudeAgent",
2377 model: "claude-opus-4-6",
2378 },
23642379 attachments: [],
23652380 });
23662381
apps/server/src/provider/Layers/ClaudeAdapter.ts+64-36
......@@ -38,15 +38,13 @@ import {
3838 ThreadId,
3939 TurnId,
4040 type UserInputQuestion,
41 ClaudeCodeEffort,
4142} from "@t3tools/contracts";
4243import {
44 hasEffortLevel,
4345 applyClaudePromptEffortPrefix,
44 getEffectiveClaudeCodeEffort,
45 getReasoningEffortOptions,
46 resolveReasoningEffortForProvider,
47 supportsClaudeFastMode,
48 supportsClaudeThinkingToggle,
49 supportsClaudeUltrathinkKeyword,
46 getModelCapabilities,
47 trimOrNull,
5048} from "@t3tools/shared/model";
5149import {
5250 Cause,
......@@ -158,6 +156,7 @@ interface ClaudeSessionContext {
158156 readonly inFlightTools: Map<number, ToolInFlight>;
159157 turnState: ClaudeTurnState | undefined;
160158 lastKnownContextWindow: number | undefined;
159 lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined;
161160 lastAssistantUuid: string | undefined;
162161 lastThreadStartedId: string | undefined;
163162 stopped: boolean;
......@@ -211,6 +210,15 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray
211210 return squashed.length > 0 ? [squashed] : [];
212211}
213212
213function getEffectiveClaudeCodeEffort(
214 effort: ClaudeCodeEffort | null | undefined,
215): Exclude<ClaudeCodeEffort, "ultrathink"> | null {
216 if (!effort) {
217 return null;
218 }
219 return effort === "ultrathink" ? null : effort;
220}
221
214222function isClaudeInterruptedMessage(message: string): boolean {
215223 const normalized = message.toLowerCase();
216224 return (
......@@ -512,15 +520,16 @@ const CLAUDE_SETTING_SOURCES = [
512520] as const satisfies ReadonlyArray<SettingSource>;
513521
514522function buildPromptText(input: ProviderSendTurnInput): string {
515 const requestedEffort = resolveReasoningEffortForProvider(
516 "claudeAgent",
517 input.modelOptions?.claudeAgent?.effort ?? null,
518 );
519 const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model);
523 const rawEffort =
524 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null;
525 const requestedEffort = trimOrNull(rawEffort);
526 const claudeModel =
527 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined;
528 const caps = getModelCapabilities("claudeAgent", claudeModel);
520529 const promptEffort =
521 requestedEffort === "ultrathink" && supportsClaudeUltrathinkKeyword(input.model)
530 requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0
522531 ? "ultrathink"
523 : requestedEffort && supportedEffortOptions.includes(requestedEffort)
532 : requestedEffort && hasEffortLevel(caps, requestedEffort)
524533 ? requestedEffort
525534 : null;
526535 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
......@@ -1368,14 +1377,33 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
13681377 const resultUsage =
13691378 result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined;
13701379 const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage);
1371 const usageSnapshot = normalizeClaudeTokenUsage(
1372 resultUsage,
1373 resultContextWindow ?? context.lastKnownContextWindow,
1374 );
13751380 if (resultContextWindow !== undefined) {
13761381 context.lastKnownContextWindow = resultContextWindow;
13771382 }
13781383
1384 // The SDK result.usage contains *accumulated* totals across all API calls
1385 // (input_tokens, cache_read_input_tokens, etc. summed over every request).
1386 // This does NOT represent the current context window size.
1387 // Instead, use the last known context-window-accurate usage from task_progress
1388 // events and treat the accumulated total as totalProcessedTokens.
1389 const accumulatedSnapshot = normalizeClaudeTokenUsage(
1390 resultUsage,
1391 resultContextWindow ?? context.lastKnownContextWindow,
1392 );
1393 const lastGoodUsage = context.lastKnownTokenUsage;
1394 const maxTokens = resultContextWindow ?? context.lastKnownContextWindow;
1395 const usageSnapshot: ThreadTokenUsageSnapshot | undefined = lastGoodUsage
1396 ? {
1397 ...lastGoodUsage,
1398 ...(typeof maxTokens === "number" && Number.isFinite(maxTokens) && maxTokens > 0
1399 ? { maxTokens }
1400 : {}),
1401 ...(accumulatedSnapshot && accumulatedSnapshot.usedTokens > lastGoodUsage.usedTokens
1402 ? { totalProcessedTokens: accumulatedSnapshot.usedTokens }
1403 : {}),
1404 }
1405 : accumulatedSnapshot;
1406
13791407 const turnState = context.turnState;
13801408 if (!turnState) {
13811409 if (usageSnapshot) {
......@@ -2051,6 +2079,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
20512079 context.lastKnownContextWindow,
20522080 );
20532081 if (normalizedUsage) {
2082 context.lastKnownTokenUsage = normalizedUsage;
20542083 const usageStamp = yield* makeEventStamp();
20552084 yield* offerRuntimeEvent({
20562085 ...base,
......@@ -2082,6 +2111,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
20822111 context.lastKnownContextWindow,
20832112 );
20842113 if (normalizedUsage) {
2114 context.lastKnownTokenUsage = normalizedUsage;
20852115 const usageStamp = yield* makeEventStamp();
20862116 yield* offerRuntimeEvent({
20872117 ...base,
......@@ -2698,21 +2728,16 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
26982728 );
26992729
27002730 const providerOptions = input.providerOptions?.claudeAgent;
2701 const requestedEffort = resolveReasoningEffortForProvider(
2702 "claudeAgent",
2703 input.modelOptions?.claudeAgent?.effort ?? null,
2704 );
2705 const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model);
2731 const modelSelection =
2732 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
2733 const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null);
2734 const caps = getModelCapabilities("claudeAgent", modelSelection?.model);
27062735 const effort =
2707 requestedEffort && supportedEffortOptions.includes(requestedEffort)
2708 ? requestedEffort
2709 : null;
2710 const fastMode =
2711 input.modelOptions?.claudeAgent?.fastMode === true && supportsClaudeFastMode(input.model);
2736 requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null;
2737 const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode;
27122738 const thinking =
2713 typeof input.modelOptions?.claudeAgent?.thinking === "boolean" &&
2714 supportsClaudeThinkingToggle(input.model)
2715 ? input.modelOptions.claudeAgent.thinking
2739 typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle
2740 ? modelSelection.options.thinking
27162741 : undefined;
27172742 const effectiveEffort = getEffectiveClaudeCodeEffort(effort);
27182743 const permissionMode =
......@@ -2725,7 +2750,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27252750
27262751 const queryOptions: ClaudeQueryOptions = {
27272752 ...(input.cwd ? { cwd: input.cwd } : {}),
2728 ...(input.model ? { model: input.model } : {}),
2753 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
27292754 pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude",
27302755 settingSources: [...CLAUDE_SETTING_SOURCES],
27312756 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
......@@ -2766,7 +2791,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27662791 status: "ready",
27672792 runtimeMode: input.runtimeMode,
27682793 ...(input.cwd ? { cwd: input.cwd } : {}),
2769 ...(input.model ? { model: input.model } : {}),
2794 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
27702795 ...(threadId ? { threadId } : {}),
27712796 resumeCursor: {
27722797 ...(threadId ? { threadId } : {}),
......@@ -2794,6 +2819,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27942819 inFlightTools,
27952820 turnState: undefined,
27962821 lastKnownContextWindow: undefined,
2822 lastKnownTokenUsage: undefined,
27972823 lastAssistantUuid: resumeState?.resumeSessionAt,
27982824 lastThreadStartedId: undefined,
27992825 stopped: false,
......@@ -2821,7 +2847,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
28212847 threadId,
28222848 payload: {
28232849 config: {
2824 ...(input.model ? { model: input.model } : {}),
2850 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
28252851 ...(input.cwd ? { cwd: input.cwd } : {}),
28262852 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
28272853 ...(permissionMode ? { permissionMode } : {}),
......@@ -2867,6 +2893,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
28672893 const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) =>
28682894 Effect.gen(function* () {
28692895 const context = yield* requireSession(input.threadId);
2896 const modelSelection =
2897 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
28702898
28712899 if (context.turnState) {
28722900 // Auto-close a stale synthetic turn (from background agent responses
......@@ -2874,9 +2902,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
28742902 yield* completeTurn(context, "completed");
28752903 }
28762904
2877 if (input.model) {
2905 if (modelSelection?.model) {
28782906 yield* Effect.tryPromise({
2879 try: () => context.query.setModel(input.model),
2907 try: () => context.query.setModel(modelSelection.model),
28802908 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),
28812909 });
28822910 }
......@@ -2926,7 +2954,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
29262954 createdAt: turnStartedStamp.createdAt,
29272955 threadId: context.session.threadId,
29282956 turnId,
2929 payload: input.model ? { model: input.model } : {},
2957 payload: modelSelection?.model ? { model: modelSelection.model } : {},
29302958 providerRefs: {},
29312959 });
29322960
apps/server/src/provider/Layers/CodexAdapter.test.ts+8-6
......@@ -188,9 +188,10 @@ validationLayer("CodexAdapterLive validation", (it) => {
188188 yield* adapter.startSession({
189189 provider: "codex",
190190 threadId: asThreadId("thread-1"),
191 model: "gpt-5.3-codex",
192 modelOptions: {
193 codex: {
191 modelSelection: {
192 provider: "codex",
193 model: "gpt-5.3-codex",
194 options: {
194195 fastMode: true,
195196 },
196197 },
......@@ -256,9 +257,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
256257 adapter.sendTurn({
257258 threadId: asThreadId("sess-missing"),
258259 input: "hello",
259 model: "gpt-5.3-codex",
260 modelOptions: {
261 codex: {
260 modelSelection: {
261 provider: "codex",
262 model: "gpt-5.3-codex",
263 options: {
262264 reasoningEffort: "high",
263265 fastMode: true,
264266 },
apps/server/src/provider/Layers/CodexAdapter.ts+16-6
......@@ -1361,8 +1361,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
13611361 ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
13621362 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
13631363 runtimeMode: input.runtimeMode,
1364 ...(input.model !== undefined ? { model: input.model } : {}),
1365 ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}),
1364 ...(input.modelSelection?.provider === "codex"
1365 ? { model: input.modelSelection.model }
1366 : {}),
1367 ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode
1368 ? { serviceTier: "fast" }
1369 : {}),
13661370 };
13671371
13681372 return Effect.tryPromise({
......@@ -1418,11 +1422,17 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
14181422 const managerInput = {
14191423 threadId: input.threadId,
14201424 ...(input.input !== undefined ? { input: input.input } : {}),
1421 ...(input.model !== undefined ? { model: input.model } : {}),
1422 ...(input.modelOptions?.codex?.reasoningEffort !== undefined
1423 ? { effort: input.modelOptions.codex.reasoningEffort }
1425 ...(input.modelSelection?.provider === "codex"
1426 ? { model: input.modelSelection.model }
1427 : {}),
1428 ...(input.modelSelection?.provider === "codex" &&
1429 input.modelSelection.options?.reasoningEffort !== undefined
1430 ? { effort: input.modelSelection.options.reasoningEffort }
1431 : {}),
1432 ...(input.modelSelection?.provider === "codex" &&
1433 input.modelSelection.options?.fastMode
1434 ? { serviceTier: "fast" }
14241435 : {}),
1425 ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}),
14261436 ...(input.interactionMode !== undefined
14271437 ? { interactionMode: input.interactionMode }
14281438 : {}),
apps/server/src/provider/Layers/ProviderService.test.ts+9-5
......@@ -633,8 +633,10 @@ routing.layer("ProviderServiceLive routing", (it) => {
633633 provider: "claudeAgent",
634634 threadId: asThreadId("thread-claude-send-turn"),
635635 cwd: "/tmp/project-claude-send-turn",
636 modelOptions: {
637 claudeAgent: {
636 modelSelection: {
637 provider: "claudeAgent",
638 model: "claude-opus-4-6",
639 options: {
638640 effort: "max",
639641 },
640642 },
......@@ -658,14 +660,16 @@ routing.layer("ProviderServiceLive routing", (it) => {
658660 const startPayload = resumedStartInput as {
659661 provider?: string;
660662 cwd?: string;
661 modelOptions?: unknown;
663 modelSelection?: unknown;
662664 resumeCursor?: unknown;
663665 threadId?: string;
664666 };
665667 assert.equal(startPayload.provider, "claudeAgent");
666668 assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn");
667 assert.deepEqual(startPayload.modelOptions, {
668 claudeAgent: {
669 assert.deepEqual(startPayload.modelSelection, {
670 provider: "claudeAgent",
671 model: "claude-opus-4-6",
672 options: {
669673 effort: "max",
670674 },
671675 });
apps/server/src/provider/Layers/ProviderService.ts+16-13
......@@ -10,6 +10,7 @@
1010 * @module ProviderServiceLive
1111 */
1212import {
13 ModelSelection,
1314 NonNegativeInt,
1415 ThreadId,
1516 ProviderInterruptTurnInput,
......@@ -89,7 +90,7 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st
8990function toRuntimePayloadFromSession(
9091 session: ProviderSession,
9192 extra?: {
92 readonly modelOptions?: unknown;
93 readonly modelSelection?: unknown;
9394 readonly providerOptions?: unknown;
9495 readonly lastRuntimeEvent?: string;
9596 readonly lastRuntimeEventAt?: string;
......@@ -100,7 +101,7 @@ function toRuntimePayloadFromSession(
100101 model: session.model ?? null,
101102 activeTurnId: session.activeTurnId ?? null,
102103 lastError: session.lastError ?? null,
103 ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}),
104 ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}),
104105 ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}),
105106 ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),
106107 ...(extra?.lastRuntimeEventAt !== undefined
......@@ -109,15 +110,14 @@ function toRuntimePayloadFromSession(
109110 };
110111}
111112
112function readPersistedModelOptions(
113function readPersistedModelSelection(
113114 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
114): Record<string, unknown> | undefined {
115): ModelSelection | undefined {
115116 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
116117 return undefined;
117118 }
118 const raw = "modelOptions" in runtimePayload ? runtimePayload.modelOptions : undefined;
119 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
120 return raw as Record<string, unknown>;
119 const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined;
120 return Schema.is(ModelSelection)(raw) ? raw : undefined;
121121}
122122
123123function readPersistedProviderOptions(
......@@ -172,7 +172,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
172172 session: ProviderSession,
173173 threadId: ThreadId,
174174 extra?: {
175 readonly modelOptions?: unknown;
175 readonly modelSelection?: unknown;
176176 readonly providerOptions?: unknown;
177177 readonly lastRuntimeEvent?: string;
178178 readonly lastRuntimeEventAt?: string;
......@@ -238,14 +238,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
238238 }
239239
240240 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
241 const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload);
241 const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload);
242242 const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload);
243243
244244 const resumed = yield* adapter.startSession({
245245 threadId: input.binding.threadId,
246246 provider: input.binding.provider,
247247 ...(persistedCwd ? { cwd: persistedCwd } : {}),
248 ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}),
248 ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}),
249249 ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}),
250250 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
251251 runtimeMode: input.binding.runtimeMode ?? "full-access",
......@@ -328,7 +328,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
328328 }
329329
330330 yield* upsertSessionBinding(session, threadId, {
331 modelOptions: input.modelOptions,
331 modelSelection: input.modelSelection,
332332 providerOptions: input.providerOptions,
333333 });
334334 yield* analytics.record("provider.session.started", {
......@@ -336,7 +336,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
336336 runtimeMode: input.runtimeMode,
337337 hasResumeCursor: session.resumeCursor !== undefined,
338338 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,
340342 });
341343
342344 return session;
......@@ -372,6 +374,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
372374 status: "running",
373375 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
374376 runtimePayload: {
377 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
375378 activeTurnId: turn.turnId,
376379 lastRuntimeEvent: "provider.sendTurn",
377380 lastRuntimeEventAt: new Date().toISOString(),
......@@ -379,7 +382,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
379382 });
380383 yield* analytics.record("provider.turn.sent", {
381384 provider: routed.adapter.provider,
382 model: input.model,
385 model: input.modelSelection?.model,
383386 interactionMode: input.interactionMode,
384387 attachmentCount: input.attachments.length,
385388 hasInput: typeof input.input === "string" && input.input.trim().length > 0,
apps/server/src/wsServer.test.ts+32-8
......@@ -703,13 +703,19 @@ describe("WebSocket Server", () => {
703703 id: string;
704704 workspaceRoot: string;
705705 title: string;
706 defaultModel: string | null;
706 defaultModelSelection: {
707 provider: string;
708 model: string;
709 } | null;
707710 }>;
708711 threads: Array<{
709712 id: string;
710713 projectId: string;
711714 title: string;
712 model: string;
715 modelSelection: {
716 provider: string;
717 model: string;
718 };
713719 branch: string | null;
714720 worktreePath: string | null;
715721 }>;
......@@ -725,7 +731,10 @@ describe("WebSocket Server", () => {
725731 id: bootstrapProjectId,
726732 workspaceRoot: "/test/bootstrap-workspace",
727733 title: "bootstrap-workspace",
728 defaultModel: "gpt-5-codex",
734 defaultModelSelection: {
735 provider: "codex",
736 model: "gpt-5-codex",
737 },
729738 }),
730739 ]),
731740 );
......@@ -735,7 +744,10 @@ describe("WebSocket Server", () => {
735744 id: bootstrapThreadId,
736745 projectId: bootstrapProjectId,
737746 title: "New thread",
738 model: "gpt-5-codex",
747 modelSelection: {
748 provider: "codex",
749 model: "gpt-5-codex",
750 },
739751 branch: null,
740752 worktreePath: null,
741753 }),
......@@ -1192,7 +1204,10 @@ describe("WebSocket Server", () => {
11921204 projectId: "project-diff",
11931205 title: "Diff Project",
11941206 workspaceRoot,
1195 defaultModel: "gpt-5-codex",
1207 defaultModelSelection: {
1208 provider: "codex",
1209 model: "gpt-5-codex",
1210 },
11961211 createdAt,
11971212 });
11981213 expect(createProjectResponse.error).toBeUndefined();
......@@ -1202,7 +1217,10 @@ describe("WebSocket Server", () => {
12021217 threadId: "thread-diff",
12031218 projectId: "project-diff",
12041219 title: "Diff Thread",
1205 model: "gpt-5-codex",
1220 modelSelection: {
1221 provider: "codex",
1222 model: "gpt-5-codex",
1223 },
12061224 runtimeMode: "full-access",
12071225 interactionMode: "default",
12081226 branch: null,
......@@ -1270,7 +1288,10 @@ describe("WebSocket Server", () => {
12701288 projectId: "project-1",
12711289 title: "WS Project",
12721290 workspaceRoot,
1273 defaultModel: "gpt-5-codex",
1291 defaultModelSelection: {
1292 provider: "codex",
1293 model: "gpt-5-codex",
1294 },
12741295 createdAt,
12751296 });
12761297 expect(createProjectResponse.error).toBeUndefined();
......@@ -1280,7 +1301,10 @@ describe("WebSocket Server", () => {
12801301 threadId: "thread-1",
12811302 projectId: "project-1",
12821303 title: "Thread 1",
1283 model: "gpt-5-codex",
1304 modelSelection: {
1305 provider: "codex",
1306 model: "gpt-5-codex",
1307 },
12841308 runtimeMode: "full-access",
12851309 interactionMode: "default",
12861310 branch: null,
apps/server/src/wsServer.ts+11-5
......@@ -631,25 +631,31 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
631631 (project) => project.workspaceRoot === cwd && project.deletedAt === null,
632632 );
633633 let bootstrapProjectId: ProjectId;
634 let bootstrapProjectDefaultModel: string;
634 let bootstrapProjectDefaultModelSelection;
635635
636636 if (!existingProject) {
637637 const createdAt = new Date().toISOString();
638638 bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID());
639639 const bootstrapProjectTitle = path.basename(cwd) || "project";
640 bootstrapProjectDefaultModel = "gpt-5-codex";
640 bootstrapProjectDefaultModelSelection = {
641 provider: "codex" as const,
642 model: "gpt-5-codex",
643 };
641644 yield* orchestrationEngine.dispatch({
642645 type: "project.create",
643646 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
644647 projectId: bootstrapProjectId,
645648 title: bootstrapProjectTitle,
646649 workspaceRoot: cwd,
647 defaultModel: bootstrapProjectDefaultModel,
650 defaultModelSelection: bootstrapProjectDefaultModelSelection,
648651 createdAt,
649652 });
650653 } else {
651654 bootstrapProjectId = existingProject.id;
652 bootstrapProjectDefaultModel = existingProject.defaultModel ?? "gpt-5-codex";
655 bootstrapProjectDefaultModelSelection = existingProject.defaultModelSelection ?? {
656 provider: "codex" as const,
657 model: "gpt-5-codex",
658 };
653659 }
654660
655661 const existingThread = snapshot.threads.find(
......@@ -664,7 +670,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
664670 threadId,
665671 projectId: bootstrapProjectId,
666672 title: "New thread",
667 model: bootstrapProjectDefaultModel,
673 modelSelection: bootstrapProjectDefaultModelSelection,
668674 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
669675 runtimeMode: "full-access",
670676 branch: null,
apps/web/src/components/ChatView.browser.tsx+80-41
......@@ -90,6 +90,7 @@ interface UserRowMeasurement {
9090}
9191
9292interface MountedChatView {
93 [Symbol.asyncDispose]: () => Promise<void>;
9394 cleanup: () => Promise<void>;
9495 measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>;
9596 setViewport: (viewport: ViewportSpec) => Promise<void>;
......@@ -221,7 +222,10 @@ function createSnapshotForTargetUser(options: {
221222 id: PROJECT_ID,
222223 title: "Project",
223224 workspaceRoot: "/repo/project",
224 defaultModel: "gpt-5",
225 defaultModelSelection: {
226 provider: "codex",
227 model: "gpt-5",
228 },
225229 scripts: [],
226230 createdAt: NOW_ISO,
227231 updatedAt: NOW_ISO,
......@@ -233,7 +237,10 @@ function createSnapshotForTargetUser(options: {
233237 id: THREAD_ID,
234238 projectId: PROJECT_ID,
235239 title: "Browser test thread",
236 model: "gpt-5",
240 modelSelection: {
241 provider: "codex",
242 model: "gpt-5",
243 },
237244 interactionMode: "default",
238245 runtimeMode: "full-access",
239246 branch: "main",
......@@ -287,7 +294,10 @@ function addThreadToSnapshot(
287294 id: threadId,
288295 projectId: PROJECT_ID,
289296 title: "New thread",
290 model: "gpt-5",
297 modelSelection: {
298 provider: "codex",
299 model: "gpt-5",
300 },
291301 interactionMode: "default",
292302 runtimeMode: "full-access",
293303 branch: "main",
......@@ -756,11 +766,14 @@ async function mountChatView(options: {
756766
757767 await waitForLayout();
758768
769 const cleanup = async () => {
770 await screen.unmount();
771 host.remove();
772 };
773
759774 return {
760 cleanup: async () => {
761 await screen.unmount();
762 host.remove();
763 },
775 [Symbol.asyncDispose]: cleanup,
776 cleanup,
764777 measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }),
765778 setViewport: async (viewport: ViewportSpec) => {
766779 await setViewport(viewport);
......@@ -817,8 +830,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
817830 draftsByThreadId: {},
818831 draftThreadsByThreadId: {},
819832 projectDraftThreadIdByProjectId: {},
820 stickyModel: null,
821 stickyModelOptions: {},
833 stickyModelSelectionByProvider: {},
834 stickyActiveProvider: null,
822835 });
823836 useStore.setState({
824837 projects: [],
......@@ -1483,13 +1496,17 @@ describe("ChatView timeline estimator parity (full app)", () => {
14831496
14841497 it("snapshots sticky codex settings into a new draft thread", async () => {
14851498 useComposerDraftStore.setState({
1486 stickyModel: "gpt-5.3-codex",
1487 stickyModelOptions: {
1499 stickyModelSelectionByProvider: {
14881500 codex: {
1489 reasoningEffort: "medium",
1490 fastMode: true,
1501 provider: "codex",
1502 model: "gpt-5.3-codex",
1503 options: {
1504 reasoningEffort: "medium",
1505 fastMode: true,
1506 },
14911507 },
14921508 },
1509 stickyActiveProvider: "codex",
14931510 });
14941511
14951512 const mounted = await mountChatView({
......@@ -1514,13 +1531,16 @@ describe("ChatView timeline estimator parity (full app)", () => {
15141531 const newThreadId = newThreadPath.slice(1) as ThreadId;
15151532
15161533 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
1517 model: "gpt-5.3-codex",
1518 provider: "codex",
1519 modelOptions: {
1534 modelSelectionByProvider: {
15201535 codex: {
1521 fastMode: true,
1536 provider: "codex",
1537 model: "gpt-5.3-codex",
1538 options: {
1539 fastMode: true,
1540 },
15221541 },
15231542 },
1543 activeProvider: "codex",
15241544 });
15251545 } finally {
15261546 await mounted.cleanup();
......@@ -1529,13 +1549,17 @@ describe("ChatView timeline estimator parity (full app)", () => {
15291549
15301550 it("hydrates the provider alongside a sticky claude model", async () => {
15311551 useComposerDraftStore.setState({
1532 stickyModel: "claude-opus-4-6",
1533 stickyModelOptions: {
1552 stickyModelSelectionByProvider: {
15341553 claudeAgent: {
1535 effort: "max",
1536 fastMode: true,
1554 provider: "claudeAgent",
1555 model: "claude-opus-4-6",
1556 options: {
1557 effort: "max",
1558 fastMode: true,
1559 },
15371560 },
15381561 },
1562 stickyActiveProvider: "claudeAgent",
15391563 });
15401564
15411565 const mounted = await mountChatView({
......@@ -1560,16 +1584,18 @@ describe("ChatView timeline estimator parity (full app)", () => {
15601584 const newThreadId = newThreadPath.slice(1) as ThreadId;
15611585
15621586 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
1563 provider: "claudeAgent",
1564 model: "claude-opus-4-6",
1565 modelOptions: {
1587 modelSelectionByProvider: {
15661588 claudeAgent: {
1567 effort: "max",
1568 fastMode: true,
1589 provider: "claudeAgent",
1590 model: "claude-opus-4-6",
1591 options: {
1592 effort: "max",
1593 fastMode: true,
1594 },
15691595 },
15701596 },
1597 activeProvider: "claudeAgent",
15711598 });
1572 await expect.element(page.getByText("Claude Opus 4.6")).toBeInTheDocument();
15731599 } finally {
15741600 await mounted.cleanup();
15751601 }
......@@ -1605,13 +1631,17 @@ describe("ChatView timeline estimator parity (full app)", () => {
16051631
16061632 it("prefers draft state over sticky composer settings and defaults", async () => {
16071633 useComposerDraftStore.setState({
1608 stickyModel: "gpt-5.3-codex",
1609 stickyModelOptions: {
1634 stickyModelSelectionByProvider: {
16101635 codex: {
1611 reasoningEffort: "medium",
1612 fastMode: true,
1636 provider: "codex",
1637 model: "gpt-5.3-codex",
1638 options: {
1639 reasoningEffort: "medium",
1640 fastMode: true,
1641 },
16131642 },
16141643 },
1644 stickyActiveProvider: "codex",
16151645 });
16161646
16171647 const mounted = await mountChatView({
......@@ -1636,17 +1666,22 @@ describe("ChatView timeline estimator parity (full app)", () => {
16361666 const threadId = threadPath.slice(1) as ThreadId;
16371667
16381668 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
1639 model: "gpt-5.3-codex",
1640 modelOptions: {
1669 modelSelectionByProvider: {
16411670 codex: {
1642 fastMode: true,
1671 provider: "codex",
1672 model: "gpt-5.3-codex",
1673 options: {
1674 fastMode: true,
1675 },
16431676 },
16441677 },
1678 activeProvider: "codex",
16451679 });
16461680
1647 useComposerDraftStore.getState().setModel(threadId, "gpt-5.4");
1648 useComposerDraftStore.getState().setModelOptions(threadId, {
1649 codex: {
1681 useComposerDraftStore.getState().setModelSelection(threadId, {
1682 provider: "codex",
1683 model: "gpt-5.4",
1684 options: {
16501685 reasoningEffort: "low",
16511686 fastMode: true,
16521687 },
......@@ -1660,13 +1695,17 @@ describe("ChatView timeline estimator parity (full app)", () => {
16601695 "New-thread should reuse the existing project draft thread.",
16611696 );
16621697 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
1663 model: "gpt-5.4",
1664 modelOptions: {
1698 modelSelectionByProvider: {
16651699 codex: {
1666 reasoningEffort: "low",
1667 fastMode: true,
1700 provider: "codex",
1701 model: "gpt-5.4",
1702 options: {
1703 reasoningEffort: "low",
1704 fastMode: true,
1705 },
16681706 },
16691707 },
1708 activeProvider: "codex",
16701709 });
16711710 } finally {
16721711 await mounted.cleanup();
apps/web/src/components/ChatView.logic.ts+3-3
......@@ -1,4 +1,4 @@
1import { ProjectId, type ThreadId } from "@t3tools/contracts";
1import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts";
22import { type ChatMessage, type Thread } from "../types";
33import { randomUUID } from "~/lib/utils";
44import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
......@@ -17,7 +17,7 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.
1717export function buildLocalDraftThread(
1818 threadId: ThreadId,
1919 draftThread: DraftThreadState,
20 fallbackModel: string,
20 fallbackModelSelection: ModelSelection,
2121 error: string | null,
2222): Thread {
2323 return {
......@@ -25,7 +25,7 @@ export function buildLocalDraftThread(
2525 codexThreadId: null,
2626 projectId: draftThread.projectId,
2727 title: "New thread",
28 model: fallbackModel,
28 modelSelection: fallbackModelSelection,
2929 runtimeMode: draftThread.runtimeMode,
3030 interactionMode: draftThread.interactionMode,
3131 session: null,
apps/web/src/components/ChatView.tsx+82-64
......@@ -3,6 +3,7 @@ import {
33 DEFAULT_MODEL_BY_PROVIDER,
44 type ClaudeCodeEffort,
55 type MessageId,
6 type ModelSelection,
67 type ProjectScript,
78 type ModelSlug,
89 type ProviderKind,
......@@ -23,9 +24,8 @@ import {
2324} from "@t3tools/contracts";
2425import {
2526 applyClaudePromptEffortPrefix,
26 getDefaultModel,
27 getModelCapabilities,
2728 normalizeModelSlug,
28 resolveModelSlugForProvider,
2929} from "@t3tools/shared/model";
3030import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3131import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
......@@ -133,6 +133,7 @@ import {
133133 type DraftThreadEnvMode,
134134 type PersistedComposerImageAttachment,
135135 useComposerDraftStore,
136 useEffectiveComposerModelState,
136137 useComposerThreadDraft,
137138} from "../composerDraftStore";
138139import {
......@@ -196,10 +197,12 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw
196197
197198function formatOutgoingPrompt(params: {
198199 provider: ProviderKind;
200 model: string | null;
199201 effort: string | null;
200202 text: string;
201203}): string {
202 if (params.provider === "claudeAgent" && params.effort === "ultrathink") {
204 const caps = getModelCapabilities(params.provider, params.model);
205 if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) {
203206 return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null);
204207 }
205208 return params.text;
......@@ -248,7 +251,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
248251 const setStoreThreadError = useStore((store) => store.setError);
249252 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
250253 const { settings } = useAppSettings();
251 const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel);
254 const setStickyComposerModelSelection = useComposerDraftStore(
255 (store) => store.setStickyModelSelection,
256 );
252257 const timestampFormat = settings.timestampFormat;
253258 const navigate = useNavigate();
254259 const rawSearch = useSearch({
......@@ -273,8 +278,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
273278 );
274279 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
275280 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
276 const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);
277 const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);
281 const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
278282 const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);
279283 const setComposerDraftInteractionMode = useComposerDraftStore(
280284 (store) => store.setInteractionMode,
......@@ -467,11 +471,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
467471 ? buildLocalDraftThread(
468472 threadId,
469473 draftThread,
470 fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex,
474 fallbackDraftProject?.defaultModelSelection ?? {
475 provider: "codex",
476 model: DEFAULT_MODEL_BY_PROVIDER.codex,
477 },
471478 localDraftError,
472479 )
473480 : undefined,
474 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],
481 [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId],
475482 );
476483 const activeThread = serverThread ?? localDraftThread;
477484 const runtimeMode =
......@@ -590,7 +597,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
590597 ]);
591598
592599 const sessionProvider = activeThread?.session?.provider ?? null;
593 const selectedProviderByThreadId = composerDraft.provider;
600 const selectedProviderByThreadId = composerDraft.activeProvider ?? null;
601 const threadProvider =
602 activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null;
594603 const hasThreadStarted = Boolean(
595604 activeThread &&
596605 (activeThread.latestTurn !== null ||
......@@ -598,34 +607,38 @@ export default function ChatView({ threadId }: ChatViewProps) {
598607 activeThread.session !== null),
599608 );
600609 const lockedProvider: ProviderKind | null = hasThreadStarted
601 ? (sessionProvider ?? selectedProviderByThreadId ?? null)
610 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
602611 : null;
603 const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex";
604 const baseThreadModel = resolveModelSlugForProvider(
605 selectedProvider,
606 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
607 );
612 const selectedProvider: ProviderKind =
613 lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex";
608614 const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]);
609 const selectedModel = useMemo(() => {
610 const draftModel = composerDraft.model;
611 if (!draftModel) {
612 return baseThreadModel;
613 }
614 return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel);
615 }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]);
616 const draftModelOptions = composerDraft.modelOptions;
615 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
616 threadId,
617 selectedProvider,
618 threadModelSelection: activeThread?.modelSelection,
619 projectModelSelection: activeProject?.defaultModelSelection,
620 customModelsByProvider,
621 });
617622 const composerProviderState = useMemo(
618623 () =>
619624 getComposerProviderState({
620625 provider: selectedProvider,
621626 model: selectedModel,
622627 prompt,
623 modelOptions: draftModelOptions,
628 modelOptions: composerModelOptions,
624629 }),
625 [draftModelOptions, prompt, selectedModel, selectedProvider],
630 [composerModelOptions, prompt, selectedModel, selectedProvider],
626631 );
627632 const selectedPromptEffort = composerProviderState.promptEffort;
628633 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;
634 const selectedModelSelection = useMemo<ModelSelection>(
635 () => ({
636 provider: selectedProvider,
637 model: selectedModel,
638 ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}),
639 }),
640 [selectedModel, selectedModelOptionsForDispatch, selectedProvider],
641 );
629642 const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);
630643 const selectedModelForPicker = selectedModel;
631644 const modelOptionsByProvider = useMemo(
......@@ -1594,7 +1607,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
15941607 async (input: {
15951608 threadId: ThreadId;
15961609 createdAt: string;
1597 model?: string;
1610 modelSelection?: ModelSelection;
15981611 runtimeMode: RuntimeMode;
15991612 interactionMode: ProviderInteractionMode;
16001613 }) => {
......@@ -1606,12 +1619,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
16061619 return;
16071620 }
16081621
1609 if (input.model !== undefined && input.model !== serverThread.model) {
1622 if (
1623 input.modelSelection !== undefined &&
1624 (input.modelSelection.model !== serverThread.modelSelection.model ||
1625 input.modelSelection.provider !== serverThread.modelSelection.provider ||
1626 JSON.stringify(input.modelSelection.options ?? null) !==
1627 JSON.stringify(serverThread.modelSelection.options ?? null))
1628 ) {
16101629 await api.orchestration.dispatchCommand({
16111630 type: "thread.meta.update",
16121631 commandId: newCommandId(),
16131632 threadId: input.threadId,
1614 model: input.model,
1633 modelSelection: input.modelSelection,
16151634 });
16161635 }
16171636
......@@ -2441,6 +2460,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
24412460 const messageCreatedAt = new Date().toISOString();
24422461 const outgoingMessageText = formatOutgoingPrompt({
24432462 provider: selectedProvider,
2463 model: selectedModel,
24442464 effort: selectedPromptEffort,
24452465 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
24462466 });
......@@ -2542,8 +2562,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
25422562 }
25432563 }
25442564 const title = truncateTitle(titleSeed);
2545 let threadCreateModel: ModelSlug =
2546 selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex;
2565 const threadCreateModelSelection: ModelSelection = {
2566 provider: selectedProvider,
2567 model:
2568 selectedModel ||
2569 activeProject.defaultModelSelection?.model ||
2570 DEFAULT_MODEL_BY_PROVIDER.codex,
2571 ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}),
2572 };
25472573
25482574 if (isLocalDraftThread) {
25492575 await api.orchestration.dispatchCommand({
......@@ -2552,7 +2578,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
25522578 threadId: threadIdForSend,
25532579 projectId: activeProject.id,
25542580 title,
2555 model: threadCreateModel,
2581 modelSelection: threadCreateModelSelection,
25562582 runtimeMode,
25572583 interactionMode,
25582584 branch: nextThreadBranch,
......@@ -2601,7 +2627,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
26012627 await persistThreadSettingsForNextTurn({
26022628 threadId: threadIdForSend,
26032629 createdAt: messageCreatedAt,
2604 ...(selectedModel ? { model: selectedModel } : {}),
2630 ...(selectedModel ? { modelSelection: selectedModelSelection } : {}),
26052631 runtimeMode,
26062632 interactionMode,
26072633 });
......@@ -2619,12 +2645,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
26192645 text: outgoingMessageText,
26202646 attachments: turnAttachments,
26212647 },
2622 model: selectedModel || undefined,
2623 ...(selectedModelOptionsForDispatch
2624 ? { modelOptions: selectedModelOptionsForDispatch }
2625 : {}),
2648 modelSelection: selectedModelSelection,
26262649 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
2627 provider: selectedProvider,
26282650 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
26292651 runtimeMode,
26302652 interactionMode,
......@@ -2861,6 +2883,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
28612883 const messageCreatedAt = new Date().toISOString();
28622884 const outgoingMessageText = formatOutgoingPrompt({
28632885 provider: selectedProvider,
2886 model: selectedModel,
28642887 effort: selectedPromptEffort,
28652888 text: trimmed,
28662889 });
......@@ -2885,7 +2908,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
28852908 await persistThreadSettingsForNextTurn({
28862909 threadId: threadIdForSend,
28872910 createdAt: messageCreatedAt,
2888 ...(selectedModel ? { model: selectedModel } : {}),
2911 modelSelection: selectedModelSelection,
28892912 runtimeMode,
28902913 interactionMode: nextInteractionMode,
28912914 });
......@@ -2904,11 +2927,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
29042927 text: outgoingMessageText,
29052928 attachments: [],
29062929 },
2907 provider: selectedProvider,
2908 model: selectedModel || undefined,
2909 ...(selectedModelOptionsForDispatch
2910 ? { modelOptions: selectedModelOptionsForDispatch }
2911 : {}),
2930 modelSelection: selectedModelSelection,
29122931 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
29132932 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
29142933 runtimeMode,
......@@ -2955,13 +2974,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
29552974 resetSendPhase,
29562975 runtimeMode,
29572976 selectedPromptEffort,
2958 selectedModel,
2959 selectedModelOptionsForDispatch,
2977 selectedModelSelection,
29602978 providerOptionsForDispatch,
29612979 selectedProvider,
29622980 setComposerDraftInteractionMode,
29632981 setThreadError,
29642982 settings.enableAssistantStreaming,
2983 selectedModel,
29652984 ],
29662985 );
29672986
......@@ -2986,15 +3005,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
29863005 const implementationPrompt = buildPlanImplementationPrompt(planMarkdown);
29873006 const outgoingImplementationPrompt = formatOutgoingPrompt({
29883007 provider: selectedProvider,
3008 model: selectedModel,
29893009 effort: selectedPromptEffort,
29903010 text: implementationPrompt,
29913011 });
29923012 const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown));
2993 const nextThreadModel: ModelSlug =
2994 selectedModel ||
2995 (activeThread.model as ModelSlug) ||
2996 (activeProject.model as ModelSlug) ||
2997 DEFAULT_MODEL_BY_PROVIDER.codex;
3013 const nextThreadModelSelection: ModelSelection = selectedModelSelection;
29983014
29993015 sendInFlightRef.current = true;
30003016 beginSendPhase("sending-turn");
......@@ -3010,7 +3026,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30103026 threadId: nextThreadId,
30113027 projectId: activeProject.id,
30123028 title: nextThreadTitle,
3013 model: nextThreadModel,
3029 modelSelection: nextThreadModelSelection,
30143030 runtimeMode,
30153031 interactionMode: "default",
30163032 branch: activeThread.branch,
......@@ -3028,11 +3044,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
30283044 text: outgoingImplementationPrompt,
30293045 attachments: [],
30303046 },
3031 provider: selectedProvider,
3032 model: selectedModel || undefined,
3033 ...(selectedModelOptionsForDispatch
3034 ? { modelOptions: selectedModelOptionsForDispatch }
3035 : {}),
3047 modelSelection: selectedModelSelection,
30363048 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
30373049 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
30383050 runtimeMode,
......@@ -3084,12 +3096,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
30843096 resetSendPhase,
30853097 runtimeMode,
30863098 selectedPromptEffort,
3087 selectedModel,
3088 selectedModelOptionsForDispatch,
3099 selectedModelSelection,
30893100 providerOptionsForDispatch,
30903101 selectedProvider,
30913102 settings.enableAssistantStreaming,
30923103 syncServerReadModel,
3104 selectedModel,
30933105 ]);
30943106
30953107 const onProviderModelSelect = useCallback(
......@@ -3100,18 +3112,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
31003112 return;
31013113 }
31023114 const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model);
3103 setComposerDraftProvider(activeThread.id, provider);
3104 setComposerDraftModel(activeThread.id, resolvedModel);
3105 setStickyComposerModel(resolvedModel);
3115 const nextModelSelection: ModelSelection = {
3116 provider,
3117 model: resolvedModel,
3118 };
3119 setComposerDraftModelSelection(activeThread.id, nextModelSelection);
3120 setStickyComposerModelSelection(nextModelSelection);
31063121 scheduleComposerFocus();
31073122 },
31083123 [
31093124 activeThread,
31103125 lockedProvider,
31113126 scheduleComposerFocus,
3112 setComposerDraftModel,
3113 setComposerDraftProvider,
3114 setStickyComposerModel,
3127 setComposerDraftModelSelection,
3128 setStickyComposerModelSelection,
31153129 customModelsByProvider,
31163130 ],
31173131 );
......@@ -3135,12 +3149,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
31353149 provider: selectedProvider,
31363150 threadId,
31373151 model: selectedModel,
3152 modelOptions: composerModelOptions?.[selectedProvider],
3153 prompt,
31383154 onPromptChange: setPromptFromTraits,
31393155 });
31403156 const providerTraitsPicker = renderProviderTraitsPicker({
31413157 provider: selectedProvider,
31423158 threadId,
31433159 model: selectedModel,
3160 modelOptions: composerModelOptions?.[selectedProvider],
3161 prompt,
31443162 onPromptChange: setPromptFromTraits,
31453163 });
31463164 const onEnvModeChange = useCallback(
apps/web/src/components/KeybindingsToast.browser.tsx+8-2
......@@ -64,7 +64,10 @@ function createMinimalSnapshot(): OrchestrationReadModel {
6464 id: PROJECT_ID,
6565 title: "Project",
6666 workspaceRoot: "/repo/project",
67 defaultModel: "gpt-5",
67 defaultModelSelection: {
68 provider: "codex",
69 model: "gpt-5",
70 },
6871 scripts: [],
6972 createdAt: NOW_ISO,
7073 updatedAt: NOW_ISO,
......@@ -76,7 +79,10 @@ function createMinimalSnapshot(): OrchestrationReadModel {
7679 id: THREAD_ID,
7780 projectId: PROJECT_ID,
7881 title: "Test thread",
79 model: "gpt-5",
82 modelSelection: {
83 provider: "codex",
84 model: "gpt-5",
85 },
8086 interactionMode: "default",
8187 runtimeMode: "full-access",
8288 branch: "main",
apps/web/src/components/Sidebar.logic.test.ts+12-3
......@@ -343,16 +343,21 @@ describe("getVisibleThreadsForProject", () => {
343343});
344344
345345function makeProject(overrides: Partial<Project> = {}): Project {
346 const { defaultModelSelection, ...rest } = overrides;
346347 return {
347348 id: ProjectId.makeUnsafe("project-1"),
348349 name: "Project",
349350 cwd: "/tmp/project",
350 model: "gpt-5.4",
351 defaultModelSelection: {
352 provider: "codex",
353 model: "gpt-5.4",
354 ...defaultModelSelection,
355 },
351356 expanded: true,
352357 createdAt: "2026-03-09T10:00:00.000Z",
353358 updatedAt: "2026-03-09T10:00:00.000Z",
354359 scripts: [],
355 ...overrides,
360 ...rest,
356361 };
357362}
358363
......@@ -362,7 +367,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
362367 codexThreadId: null,
363368 projectId: ProjectId.makeUnsafe("project-1"),
364369 title: "Thread",
365 model: "gpt-5.4",
370 modelSelection: {
371 provider: "codex",
372 model: "gpt-5.4",
373 ...overrides?.modelSelection,
374 },
366375 runtimeMode: DEFAULT_RUNTIME_MODE,
367376 interactionMode: DEFAULT_INTERACTION_MODE,
368377 session: null,
apps/web/src/components/Sidebar.tsx+4-1
......@@ -539,7 +539,10 @@ export default function Sidebar() {
539539 projectId,
540540 title,
541541 workspaceRoot: cwd,
542 defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,
542 defaultModelSelection: {
543 provider: "codex",
544 model: DEFAULT_MODEL_BY_PROVIDER.codex,
545 },
543546 createdAt,
544547 });
545548 await handleNewThread(projectId, {
apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx deleted-197
......@@ -1,197 +0,0 @@
1import "../../index.css";
2
3import { ThreadId } from "@t3tools/contracts";
4import { page } from "vitest/browser";
5import { afterEach, describe, expect, it, vi } from "vitest";
6import { render } from "vitest-browser-react";
7
8import { ClaudeTraitsPicker } from "./ClaudeTraitsPicker";
9import { useComposerDraftStore } from "../../composerDraftStore";
10
11async function mountPicker(props?: {
12 model?: string;
13 prompt?: string;
14 effort?: "low" | "medium" | "high" | "max" | "ultrathink" | null;
15 thinkingEnabled?: boolean | null;
16 fastModeEnabled?: boolean;
17}) {
18 const threadId = ThreadId.makeUnsafe("thread-claude-traits");
19 const draftsByThreadId = {} as ReturnType<
20 typeof useComposerDraftStore.getState
21 >["draftsByThreadId"];
22 draftsByThreadId[threadId] = {
23 prompt: props?.prompt ?? "",
24 images: [],
25 nonPersistedImageIds: [],
26 persistedAttachments: [],
27 terminalContexts: [],
28 provider: "claudeAgent",
29 model: props?.model ?? "claude-opus-4-6",
30 modelOptions: {
31 claudeAgent: {
32 ...(props?.effort ? { effort: props.effort } : {}),
33 ...(props?.thinkingEnabled === false ? { thinking: false } : {}),
34 ...(props?.fastModeEnabled ? { fastMode: true } : {}),
35 },
36 },
37 runtimeMode: null,
38 interactionMode: null,
39 };
40 useComposerDraftStore.setState({
41 draftsByThreadId,
42 draftThreadsByThreadId: {},
43 projectDraftThreadIdByProjectId: {},
44 });
45 const host = document.createElement("div");
46 document.body.append(host);
47 const onPromptChange = vi.fn();
48 const screen = await render(
49 <ClaudeTraitsPicker
50 threadId={threadId}
51 model={props?.model ?? "claude-opus-4-6"}
52 onPromptChange={onPromptChange}
53 />,
54 { container: host },
55 );
56
57 return {
58 onPromptChange,
59 cleanup: async () => {
60 await screen.unmount();
61 host.remove();
62 },
63 };
64}
65
66describe("ClaudeTraitsPicker", () => {
67 afterEach(() => {
68 document.body.innerHTML = "";
69 useComposerDraftStore.setState({
70 draftsByThreadId: {},
71 draftThreadsByThreadId: {},
72 projectDraftThreadIdByProjectId: {},
73 });
74 });
75
76 it("shows fast mode controls for Opus", async () => {
77 const mounted = await mountPicker();
78
79 try {
80 await page.getByRole("button").click();
81
82 await vi.waitFor(() => {
83 const text = document.body.textContent ?? "";
84 expect(text).toContain("Fast Mode");
85 expect(text).toContain("off");
86 expect(text).toContain("on");
87 });
88 } finally {
89 await mounted.cleanup();
90 }
91 });
92
93 it("hides fast mode controls for non-Opus models", async () => {
94 const mounted = await mountPicker({ model: "claude-sonnet-4-6" });
95
96 try {
97 await page.getByRole("button").click();
98
99 await vi.waitFor(() => {
100 expect(document.body.textContent ?? "").not.toContain("Fast Mode");
101 });
102 } finally {
103 await mounted.cleanup();
104 }
105 });
106
107 it("shows only the provided effort options", async () => {
108 const mounted = await mountPicker({
109 model: "claude-sonnet-4-6",
110 });
111
112 try {
113 await page.getByRole("button").click();
114
115 await vi.waitFor(() => {
116 const text = document.body.textContent ?? "";
117 expect(text).toContain("Low");
118 expect(text).toContain("Medium");
119 expect(text).toContain("High");
120 expect(text).not.toContain("Max");
121 expect(text).toContain("Ultrathink");
122 });
123 } finally {
124 await mounted.cleanup();
125 }
126 });
127
128 it("shows a thinking on/off dropdown for Haiku", async () => {
129 const mounted = await mountPicker({
130 model: "claude-haiku-4-5",
131 thinkingEnabled: true,
132 });
133
134 try {
135 await vi.waitFor(() => {
136 expect(document.body.textContent ?? "").toContain("Thinking On");
137 });
138 await page.getByRole("button").click();
139
140 await vi.waitFor(() => {
141 const text = document.body.textContent ?? "";
142 expect(text).toContain("Thinking");
143 expect(text).toContain("On (default)");
144 expect(text).toContain("Off");
145 });
146 } finally {
147 await mounted.cleanup();
148 }
149 });
150
151 it("shows prompt-controlled Ultrathink state with disabled effort controls", async () => {
152 const mounted = await mountPicker({
153 effort: "high",
154 model: "claude-opus-4-6",
155 prompt: "Ultrathink:\nInvestigate this",
156 fastModeEnabled: false,
157 });
158
159 try {
160 await vi.waitFor(() => {
161 expect(document.body.textContent ?? "").toContain("Ultrathink");
162 expect(document.body.textContent ?? "").not.toContain("Ultrathink · Prompt");
163 });
164 await page.getByRole("button").click();
165
166 await vi.waitFor(() => {
167 const text = document.body.textContent ?? "";
168 expect(text).toContain("Effort");
169 expect(text).toContain("Remove Ultrathink from the prompt to change effort.");
170 expect(text).not.toContain("Fallback Effort");
171 });
172 } finally {
173 await mounted.cleanup();
174 }
175 });
176
177 it("persists sticky claude model options when traits change", async () => {
178 const mounted = await mountPicker({
179 model: "claude-opus-4-6",
180 effort: "medium",
181 fastModeEnabled: false,
182 });
183
184 try {
185 await page.getByRole("button").click();
186 await page.getByRole("menuitemradio", { name: "Max" }).click();
187
188 expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({
189 claudeAgent: {
190 effort: "max",
191 },
192 });
193 } finally {
194 await mounted.cleanup();
195 }
196 });
197});
apps/web/src/components/chat/ClaudeTraitsPicker.tsx deleted-272
......@@ -1,272 +0,0 @@
1import {
2 ProviderKind,
3 type ClaudeCodeEffort,
4 type ClaudeModelOptions,
5 type ThreadId,
6} from "@t3tools/contracts";
7import {
8 applyClaudePromptEffortPrefix,
9 getDefaultReasoningEffort,
10 getReasoningEffortOptions,
11 normalizeClaudeModelOptions,
12 resolveReasoningEffortForProvider,
13 supportsClaudeFastMode,
14 supportsClaudeThinkingToggle,
15 supportsClaudeUltrathinkKeyword,
16 isClaudeUltrathinkPrompt,
17} from "@t3tools/shared/model";
18import { memo, useCallback, useState } from "react";
19import { ChevronDownIcon } from "lucide-react";
20import { Button } from "../ui/button";
21import {
22 Menu,
23 MenuGroup,
24 MenuPopup,
25 MenuRadioGroup,
26 MenuRadioItem,
27 MenuSeparator as MenuDivider,
28 MenuTrigger,
29} from "../ui/menu";
30import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore";
31
32const PROVIDER = "claudeAgent" as const satisfies ProviderKind;
33
34const CLAUDE_EFFORT_LABELS: Record<ClaudeCodeEffort, string> = {
35 low: "Low",
36 medium: "Medium",
37 high: "High",
38 max: "Max",
39 ultrathink: "Ultrathink",
40};
41
42const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";
43
44function getSelectedClaudeTraits(
45 model: string | null | undefined,
46 prompt: string,
47 modelOptions: ClaudeModelOptions | null | undefined,
48): {
49 effort: Exclude<ClaudeCodeEffort, "ultrathink"> | null;
50 thinkingEnabled: boolean | null;
51 fastModeEnabled: boolean;
52 options: ReadonlyArray<ClaudeCodeEffort>;
53 ultrathinkPromptControlled: boolean;
54 supportsFastMode: boolean;
55} {
56 const options = getReasoningEffortOptions(PROVIDER, model);
57 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER) as Exclude<
58 ClaudeCodeEffort,
59 "ultrathink"
60 >;
61 const resolvedEffort = resolveReasoningEffortForProvider(PROVIDER, modelOptions?.effort);
62 const effort =
63 resolvedEffort && resolvedEffort !== "ultrathink" && options.includes(resolvedEffort)
64 ? resolvedEffort
65 : options.includes(defaultReasoningEffort)
66 ? defaultReasoningEffort
67 : null;
68 const thinkingEnabled = supportsClaudeThinkingToggle(model)
69 ? (modelOptions?.thinking ?? true)
70 : null;
71 const supportsFastMode = supportsClaudeFastMode(model);
72 return {
73 effort,
74 thinkingEnabled,
75 fastModeEnabled: supportsFastMode && modelOptions?.fastMode === true,
76 options,
77 ultrathinkPromptControlled:
78 supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt),
79 supportsFastMode,
80 };
81}
82
83interface ClaudeTraitsMenuContentProps {
84 threadId: ThreadId;
85 model: string | null | undefined;
86 onPromptChange: (prompt: string) => void;
87}
88
89export const ClaudeTraitsMenuContent = memo(function ClaudeTraitsMenuContentImpl({
90 threadId,
91 model,
92 onPromptChange,
93}: ClaudeTraitsMenuContentProps) {
94 const draft = useComposerThreadDraft(threadId);
95 const prompt = draft.prompt;
96 const modelOptions = draft.modelOptions?.[PROVIDER];
97 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
98 const {
99 effort,
100 thinkingEnabled,
101 fastModeEnabled,
102 options,
103 ultrathinkPromptControlled,
104 supportsFastMode,
105 } = getSelectedClaudeTraits(model, prompt, modelOptions);
106 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER);
107
108 const handleEffortChange = useCallback(
109 (value: ClaudeCodeEffort) => {
110 if (ultrathinkPromptControlled) return;
111 if (!value) return;
112 const nextEffort = options.find((option) => option === value);
113 if (!nextEffort) return;
114 if (nextEffort === "ultrathink") {
115 const nextPrompt =
116 prompt.trim().length === 0
117 ? ULTRATHINK_PROMPT_PREFIX
118 : applyClaudePromptEffortPrefix(prompt, "ultrathink");
119 onPromptChange(nextPrompt);
120 return;
121 }
122 setProviderModelOptions(
123 threadId,
124 PROVIDER,
125 normalizeClaudeModelOptions(model, {
126 ...modelOptions,
127 effort: nextEffort,
128 }),
129 { persistSticky: true },
130 );
131 },
132 [
133 ultrathinkPromptControlled,
134 model,
135 modelOptions,
136 onPromptChange,
137 threadId,
138 setProviderModelOptions,
139 options,
140 prompt,
141 ],
142 );
143
144 if (effort === null && thinkingEnabled === null) {
145 return null;
146 }
147
148 return (
149 <>
150 {effort ? (
151 <>
152 <MenuGroup>
153 <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">Effort</div>
154 {ultrathinkPromptControlled ? (
155 <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs">
156 Remove Ultrathink from the prompt to change effort.
157 </div>
158 ) : null}
159 <MenuRadioGroup value={effort} onValueChange={handleEffortChange}>
160 {options.map((option) => (
161 <MenuRadioItem key={option} value={option} disabled={ultrathinkPromptControlled}>
162 {CLAUDE_EFFORT_LABELS[option]}
163 {option === defaultReasoningEffort ? " (default)" : ""}
164 </MenuRadioItem>
165 ))}
166 </MenuRadioGroup>
167 </MenuGroup>
168 </>
169 ) : thinkingEnabled !== null ? (
170 <MenuGroup>
171 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>
172 <MenuRadioGroup
173 value={thinkingEnabled ? "on" : "off"}
174 onValueChange={(value) => {
175 setProviderModelOptions(
176 threadId,
177 PROVIDER,
178 normalizeClaudeModelOptions(model, {
179 ...modelOptions,
180 thinking: value === "on",
181 }),
182 { persistSticky: true },
183 );
184 }}
185 >
186 <MenuRadioItem value="on">On (default)</MenuRadioItem>
187 <MenuRadioItem value="off">Off</MenuRadioItem>
188 </MenuRadioGroup>
189 </MenuGroup>
190 ) : null}
191 {supportsFastMode ? (
192 <>
193 <MenuDivider />
194 <MenuGroup>
195 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
196 <MenuRadioGroup
197 value={fastModeEnabled ? "on" : "off"}
198 onValueChange={(value) => {
199 setProviderModelOptions(
200 threadId,
201 PROVIDER,
202 normalizeClaudeModelOptions(model, {
203 ...modelOptions,
204 fastMode: value === "on",
205 }),
206 { persistSticky: true },
207 );
208 }}
209 >
210 <MenuRadioItem value="off">off</MenuRadioItem>
211 <MenuRadioItem value="on">on</MenuRadioItem>
212 </MenuRadioGroup>
213 </MenuGroup>
214 </>
215 ) : null}
216 </>
217 );
218});
219
220export const ClaudeTraitsPicker = memo(function ClaudeTraitsPicker({
221 threadId,
222 model,
223 onPromptChange,
224}: ClaudeTraitsMenuContentProps) {
225 const [isMenuOpen, setIsMenuOpen] = useState(false);
226 const draft = useComposerThreadDraft(threadId);
227 const prompt = draft.prompt;
228 const modelOptions = draft.modelOptions?.[PROVIDER];
229 const { effort, thinkingEnabled, fastModeEnabled, ultrathinkPromptControlled, supportsFastMode } =
230 getSelectedClaudeTraits(model, prompt, modelOptions);
231 const triggerLabel = [
232 ultrathinkPromptControlled
233 ? "Ultrathink"
234 : effort
235 ? CLAUDE_EFFORT_LABELS[effort]
236 : thinkingEnabled === null
237 ? null
238 : `Thinking ${thinkingEnabled ? "On" : "Off"}`,
239 ...(supportsFastMode && fastModeEnabled ? ["Fast"] : []),
240 ]
241 .filter(Boolean)
242 .join(" · ");
243
244 return (
245 <Menu
246 open={isMenuOpen}
247 onOpenChange={(open) => {
248 setIsMenuOpen(open);
249 }}
250 >
251 <MenuTrigger
252 render={
253 <Button
254 size="sm"
255 variant="ghost"
256 className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
257 />
258 }
259 >
260 <span>{triggerLabel}</span>
261 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
262 </MenuTrigger>
263 <MenuPopup align="start">
264 <ClaudeTraitsMenuContent
265 threadId={threadId}
266 model={model}
267 onPromptChange={onPromptChange}
268 />
269 </MenuPopup>
270 </Menu>
271 );
272});
apps/web/src/components/chat/CodexTraitsPicker.browser.tsx deleted-188
......@@ -1,188 +0,0 @@
1import "../../index.css";
2
3import { ProjectId, ThreadId } from "@t3tools/contracts";
4import { page } from "vitest/browser";
5import { afterEach, describe, expect, it, vi } from "vitest";
6import { render } from "vitest-browser-react";
7
8import { CodexTraitsPicker } from "./CodexTraitsPicker";
9import { COMPOSER_DRAFT_STORAGE_KEY, useComposerDraftStore } from "../../composerDraftStore";
10
11async function mountPicker(props: {
12 reasoningEffort?: "low" | "medium" | "high" | "xhigh";
13 fastModeEnabled: boolean;
14}) {
15 const threadId = ThreadId.makeUnsafe("thread-codex-traits");
16 const draftsByThreadId = {} as ReturnType<
17 typeof useComposerDraftStore.getState
18 >["draftsByThreadId"];
19 draftsByThreadId[threadId] = {
20 prompt: "",
21 images: [],
22 nonPersistedImageIds: [],
23 persistedAttachments: [],
24 terminalContexts: [],
25 provider: "codex",
26 model: null,
27 modelOptions: {
28 codex: {
29 ...(props.reasoningEffort ? { reasoningEffort: props.reasoningEffort } : {}),
30 ...(props.fastModeEnabled ? { fastMode: true } : {}),
31 },
32 },
33 runtimeMode: null,
34 interactionMode: null,
35 };
36 useComposerDraftStore.setState({
37 draftsByThreadId,
38 draftThreadsByThreadId: {},
39 projectDraftThreadIdByProjectId: {
40 [ProjectId.makeUnsafe("project-codex-traits")]: threadId,
41 },
42 });
43 const host = document.createElement("div");
44 document.body.append(host);
45 const screen = await render(<CodexTraitsPicker threadId={threadId} />, { container: host });
46
47 return {
48 cleanup: async () => {
49 await screen.unmount();
50 host.remove();
51 },
52 };
53}
54
55describe("CodexTraitsPicker", () => {
56 afterEach(() => {
57 document.body.innerHTML = "";
58 localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY);
59 useComposerDraftStore.setState({
60 draftsByThreadId: {},
61 draftThreadsByThreadId: {},
62 projectDraftThreadIdByProjectId: {},
63 });
64 });
65
66 it("shows fast mode controls", async () => {
67 const mounted = await mountPicker({
68 fastModeEnabled: false,
69 });
70
71 try {
72 await page.getByRole("button").click();
73
74 await vi.waitFor(() => {
75 const text = document.body.textContent ?? "";
76 expect(text).toContain("Fast Mode");
77 expect(text).toContain("off");
78 expect(text).toContain("on");
79 });
80 } finally {
81 await mounted.cleanup();
82 }
83 });
84
85 it("shows Fast in the trigger label when fast mode is active", async () => {
86 const mounted = await mountPicker({
87 fastModeEnabled: true,
88 });
89
90 try {
91 await vi.waitFor(() => {
92 expect(document.body.textContent ?? "").toContain("High · Fast");
93 });
94 } finally {
95 await mounted.cleanup();
96 }
97 });
98
99 it("shows only the provided effort options", async () => {
100 const mounted = await mountPicker({
101 fastModeEnabled: false,
102 });
103
104 try {
105 await page.getByRole("button").click();
106
107 await vi.waitFor(() => {
108 const text = document.body.textContent ?? "";
109 expect(text).toContain("Low");
110 expect(text).toContain("Medium");
111 expect(text).toContain("High");
112 expect(text).toContain("Extra High");
113 });
114 } finally {
115 await mounted.cleanup();
116 }
117 });
118
119 it("persists sticky codex model options when traits change", async () => {
120 const mounted = await mountPicker({
121 fastModeEnabled: false,
122 });
123
124 try {
125 await page.getByRole("button").click();
126 await page.getByRole("menuitemradio", { name: "on" }).click();
127
128 expect(useComposerDraftStore.getState().stickyModelOptions).toMatchObject({
129 codex: {
130 fastMode: true,
131 },
132 });
133 } finally {
134 await mounted.cleanup();
135 }
136 });
137
138 it("hydrates legacy codex persisted state into modelOptions through the picker", async () => {
139 const threadId = ThreadId.makeUnsafe("thread-codex-legacy");
140 localStorage.setItem(
141 COMPOSER_DRAFT_STORAGE_KEY,
142 JSON.stringify({
143 state: {
144 draftsByThreadId: {
145 [threadId]: {
146 prompt: "",
147 attachments: [],
148 provider: "codex",
149 model: "gpt-5.3-codex",
150 effort: "xhigh",
151 codexFastMode: true,
152 serviceTier: "fast",
153 },
154 },
155 draftThreadsByThreadId: {},
156 projectDraftThreadIdByProjectId: {},
157 },
158 version: 1,
159 }),
160 );
161 useComposerDraftStore.setState({
162 draftsByThreadId: {},
163 draftThreadsByThreadId: {},
164 projectDraftThreadIdByProjectId: {},
165 });
166
167 const host = document.createElement("div");
168 document.body.append(host);
169 const screen = await render(<CodexTraitsPicker threadId={threadId} />, { container: host });
170
171 try {
172 await useComposerDraftStore.persist.rehydrate();
173
174 await vi.waitFor(() => {
175 expect(document.body.textContent ?? "").toContain("Extra High · Fast");
176 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
177 codex: {
178 reasoningEffort: "xhigh",
179 fastMode: true,
180 },
181 });
182 });
183 } finally {
184 await screen.unmount();
185 host.remove();
186 }
187 });
188});
apps/web/src/components/chat/CodexTraitsPicker.tsx deleted-147
......@@ -1,147 +0,0 @@
1import type {
2 CodexModelOptions,
3 CodexReasoningEffort,
4 ProviderKind,
5 ThreadId,
6} from "@t3tools/contracts";
7import {
8 getDefaultReasoningEffort,
9 getReasoningEffortOptions,
10 normalizeCodexModelOptions,
11 resolveReasoningEffortForProvider,
12} from "@t3tools/shared/model";
13import { memo, useState } from "react";
14import { ChevronDownIcon } from "lucide-react";
15import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore";
16import { Button } from "../ui/button";
17import {
18 Menu,
19 MenuGroup,
20 MenuPopup,
21 MenuRadioGroup,
22 MenuRadioItem,
23 MenuSeparator as MenuDivider,
24 MenuTrigger,
25} from "../ui/menu";
26
27const PROVIDER = "codex" as const satisfies ProviderKind;
28
29const CODEX_REASONING_LABELS: Record<CodexReasoningEffort, string> = {
30 low: "Low",
31 medium: "Medium",
32 high: "High",
33 xhigh: "Extra High",
34};
35
36function getSelectedCodexTraits(modelOptions: CodexModelOptions | null | undefined): {
37 effort: CodexReasoningEffort;
38 fastModeEnabled: boolean;
39} {
40 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER);
41 return {
42 effort:
43 resolveReasoningEffortForProvider(PROVIDER, modelOptions?.reasoningEffort) ??
44 defaultReasoningEffort,
45 fastModeEnabled: modelOptions?.fastMode === true,
46 };
47}
48
49function CodexTraitsMenuContentImpl(props: { threadId: ThreadId }) {
50 const draft = useComposerThreadDraft(props.threadId);
51 const modelOptions = draft.modelOptions?.[PROVIDER];
52 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
53 const options = getReasoningEffortOptions(PROVIDER);
54 const defaultReasoningEffort = getDefaultReasoningEffort(PROVIDER);
55 const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions);
56
57 return (
58 <>
59 <MenuGroup>
60 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Reasoning</div>
61 <MenuRadioGroup
62 value={effort}
63 onValueChange={(value) => {
64 if (!value) return;
65 const nextEffort = options.find((option) => option === value);
66 if (!nextEffort) return;
67 setProviderModelOptions(
68 props.threadId,
69 PROVIDER,
70 normalizeCodexModelOptions({
71 ...modelOptions,
72 reasoningEffort: nextEffort,
73 }),
74 { persistSticky: true },
75 );
76 }}
77 >
78 {options.map((option) => (
79 <MenuRadioItem key={option} value={option}>
80 {CODEX_REASONING_LABELS[option]}
81 {option === defaultReasoningEffort ? " (default)" : ""}
82 </MenuRadioItem>
83 ))}
84 </MenuRadioGroup>
85 </MenuGroup>
86 <MenuDivider />
87 <MenuGroup>
88 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
89 <MenuRadioGroup
90 value={fastModeEnabled ? "on" : "off"}
91 onValueChange={(value) => {
92 setProviderModelOptions(
93 props.threadId,
94 PROVIDER,
95 normalizeCodexModelOptions({
96 ...modelOptions,
97 fastMode: value === "on",
98 }),
99 { persistSticky: true },
100 );
101 }}
102 >
103 <MenuRadioItem value="off">off</MenuRadioItem>
104 <MenuRadioItem value="on">on</MenuRadioItem>
105 </MenuRadioGroup>
106 </MenuGroup>
107 </>
108 );
109}
110
111export const CodexTraitsMenuContent = memo(CodexTraitsMenuContentImpl);
112
113export const CodexTraitsPicker = memo(function CodexTraitsPicker(props: { threadId: ThreadId }) {
114 const [isMenuOpen, setIsMenuOpen] = useState(false);
115 const modelOptions = useComposerThreadDraft(props.threadId).modelOptions?.codex;
116 const { effort, fastModeEnabled } = getSelectedCodexTraits(modelOptions);
117 const triggerLabel = [CODEX_REASONING_LABELS[effort], ...(fastModeEnabled ? ["Fast"] : [])]
118 .filter(Boolean)
119 .join(" · ");
120
121 return (
122 <Menu
123 open={isMenuOpen}
124 onOpenChange={(open) => {
125 setIsMenuOpen(open);
126 }}
127 >
128 <MenuTrigger
129 render={
130 <Button
131 size="sm"
132 variant="ghost"
133 className="min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:max-w-48 sm:px-3 [&_svg]:mx-0"
134 />
135 }
136 >
137 <span className="flex min-w-0 w-full items-center gap-2 overflow-hidden">
138 {triggerLabel}
139 <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
140 </span>
141 </MenuTrigger>
142 <MenuPopup align="start">
143 <CodexTraitsMenuContent threadId={props.threadId} />
144 </MenuPopup>
145 </Menu>
146 );
147});
apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+89-102
......@@ -1,4 +1,4 @@
1import { type ProviderModelOptions, ThreadId } from "@t3tools/contracts";
1import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts";
22import "../../index.css";
33
44import { page } from "vitest/browser";
......@@ -6,30 +6,31 @@ import { afterEach, describe, expect, it, vi } from "vitest";
66import { render } from "vitest-browser-react";
77
88import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
9import { ClaudeTraitsMenuContent } from "./ClaudeTraitsPicker";
10import { CodexTraitsMenuContent } from "./CodexTraitsPicker";
9import { TraitsMenuContent } from "./TraitsPicker";
1110import { useComposerDraftStore } from "../../composerDraftStore";
1211
13async function mountMenu(props?: {
14 model?: string;
15 prompt?: string;
16 provider?: "codex" | "claudeAgent";
17 modelOptions?: ProviderModelOptions | null;
18}) {
12async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) {
1913 const threadId = ThreadId.makeUnsafe("thread-compact-menu");
20 const provider = props?.provider ?? "claudeAgent";
14 const provider = props?.modelSelection?.provider ?? "claudeAgent";
2115 const draftsByThreadId = {} as ReturnType<
2216 typeof useComposerDraftStore.getState
2317 >["draftsByThreadId"];
18 const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider];
19
2420 draftsByThreadId[threadId] = {
2521 prompt: props?.prompt ?? "",
2622 images: [],
2723 nonPersistedImageIds: [],
2824 persistedAttachments: [],
2925 terminalContexts: [],
30 provider,
31 model: props?.model ?? "claude-opus-4-6",
32 modelOptions: props?.modelOptions ?? null,
26 modelSelectionByProvider: {
27 [provider]: {
28 provider,
29 model,
30 ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}),
31 },
32 },
33 activeProvider: provider,
3334 runtimeMode: null,
3435 interactionMode: null,
3536 };
......@@ -41,6 +42,7 @@ async function mountMenu(props?: {
4142 const host = document.createElement("div");
4243 document.body.append(host);
4344 const onPromptChange = vi.fn();
45 const providerOptions = props?.modelSelection?.options;
4446 const screen = await render(
4547 <CompactComposerControlsMenu
4648 activePlan={false}
......@@ -48,15 +50,14 @@ async function mountMenu(props?: {
4850 planSidebarOpen={false}
4951 runtimeMode="approval-required"
5052 traitsMenuContent={
51 provider === "codex" ? (
52 <CodexTraitsMenuContent threadId={threadId} />
53 ) : (
54 <ClaudeTraitsMenuContent
55 threadId={threadId}
56 model={props?.model ?? "claude-opus-4-6"}
57 onPromptChange={onPromptChange}
58 />
59 )
53 <TraitsMenuContent
54 provider={provider}
55 threadId={threadId}
56 model={model}
57 prompt={props?.prompt ?? ""}
58 modelOptions={providerOptions}
59 onPromptChange={onPromptChange}
60 />
6061 }
6162 onToggleInteractionMode={vi.fn()}
6263 onTogglePlanSidebar={vi.fn()}
......@@ -65,11 +66,14 @@ async function mountMenu(props?: {
6566 { container: host },
6667 );
6768
69 const cleanup = async () => {
70 await screen.unmount();
71 host.remove();
72 };
73
6874 return {
69 cleanup: async () => {
70 await screen.unmount();
71 host.remove();
72 },
75 [Symbol.asyncDispose]: cleanup,
76 cleanup,
7377 };
7478}
7579
......@@ -80,107 +84,90 @@ describe("CompactComposerControlsMenu", () => {
8084 draftsByThreadId: {},
8185 draftThreadsByThreadId: {},
8286 projectDraftThreadIdByProjectId: {},
87 stickyModelSelectionByProvider: {},
8388 });
8489 });
8590
8691 it("shows fast mode controls for Opus", async () => {
87 const mounted = await mountMenu();
88
89 try {
90 await page.getByLabelText("More composer controls").click();
91
92 await vi.waitFor(() => {
93 const text = document.body.textContent ?? "";
94 expect(text).toContain("Fast Mode");
95 expect(text).toContain("off");
96 expect(text).toContain("on");
97 });
98 } finally {
99 await mounted.cleanup();
100 }
92 await using _ = await mountMenu({
93 modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" },
94 });
95
96 await page.getByLabelText("More composer controls").click();
97
98 await vi.waitFor(() => {
99 const text = document.body.textContent ?? "";
100 expect(text).toContain("Fast Mode");
101 expect(text).toContain("off");
102 expect(text).toContain("on");
103 });
101104 });
102105
103106 it("hides fast mode controls for non-Opus Claude models", async () => {
104 const mounted = await mountMenu({ model: "claude-sonnet-4-6" });
107 await using _ = await mountMenu({
108 modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
109 });
105110
106 try {
107 await page.getByLabelText("More composer controls").click();
111 await page.getByLabelText("More composer controls").click();
108112
109 await vi.waitFor(() => {
110 expect(document.body.textContent ?? "").not.toContain("Fast Mode");
111 });
112 } finally {
113 await mounted.cleanup();
114 }
113 await vi.waitFor(() => {
114 expect(document.body.textContent ?? "").not.toContain("Fast Mode");
115 });
115116 });
116117
117118 it("shows only the provided effort options", async () => {
118 const mounted = await mountMenu({
119 model: "claude-sonnet-4-6",
119 await using _ = await mountMenu({
120 modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
120121 });
121122
122 try {
123 await page.getByLabelText("More composer controls").click();
124
125 await vi.waitFor(() => {
126 const text = document.body.textContent ?? "";
127 expect(text).toContain("Low");
128 expect(text).toContain("Medium");
129 expect(text).toContain("High");
130 expect(text).not.toContain("Max");
131 expect(text).toContain("Ultrathink");
132 });
133 } finally {
134 await mounted.cleanup();
135 }
123 await page.getByLabelText("More composer controls").click();
124
125 await vi.waitFor(() => {
126 const text = document.body.textContent ?? "";
127 expect(text).toContain("Low");
128 expect(text).toContain("Medium");
129 expect(text).toContain("High");
130 expect(text).not.toContain("Max");
131 expect(text).toContain("Ultrathink");
132 });
136133 });
137134
138135 it("shows a Claude thinking on/off section for Haiku", async () => {
139 const mounted = await mountMenu({
140 model: "claude-haiku-4-5",
141 modelOptions: {
142 claudeAgent: {
143 thinking: true,
144 },
136 await using _ = await mountMenu({
137 modelSelection: {
138 provider: "claudeAgent",
139 model: "claude-haiku-4-5",
140 options: { thinking: true },
145141 },
146142 });
147143
148 try {
149 await page.getByLabelText("More composer controls").click();
150
151 await vi.waitFor(() => {
152 const text = document.body.textContent ?? "";
153 expect(text).toContain("Thinking");
154 expect(text).toContain("On (default)");
155 expect(text).toContain("Off");
156 });
157 } finally {
158 await mounted.cleanup();
159 }
144 await page.getByLabelText("More composer controls").click();
145
146 await vi.waitFor(() => {
147 const text = document.body.textContent ?? "";
148 expect(text).toContain("Thinking");
149 expect(text).toContain("On (default)");
150 expect(text).toContain("Off");
151 });
160152 });
161153
162154 it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => {
163 const mounted = await mountMenu({
164 model: "claude-opus-4-6",
165 prompt: "Ultrathink:\nInvestigate this",
166 modelOptions: {
167 claudeAgent: {
168 effort: "high",
169 },
155 await using _ = await mountMenu({
156 modelSelection: {
157 provider: "claudeAgent",
158 model: "claude-opus-4-6",
159 options: { effort: "high" },
170160 },
161 prompt: "Ultrathink:\nInvestigate this",
171162 });
172163
173 try {
174 await page.getByLabelText("More composer controls").click();
175
176 await vi.waitFor(() => {
177 const text = document.body.textContent ?? "";
178 expect(text).toContain("Effort");
179 expect(text).toContain("Remove Ultrathink from the prompt to change effort.");
180 expect(text).not.toContain("Fallback Effort");
181 });
182 } finally {
183 await mounted.cleanup();
184 }
164 await page.getByLabelText("More composer controls").click();
165
166 await vi.waitFor(() => {
167 const text = document.body.textContent ?? "";
168 expect(text).toContain("Effort");
169 expect(text).toContain("Remove Ultrathink from the prompt to change effort.");
170 expect(text).not.toContain("Fallback Effort");
171 });
185172 });
186173});
apps/web/src/components/chat/ProviderHealthBanner.tsx+2-7
......@@ -1,4 +1,4 @@
1import { type ServerProviderStatus } from "@t3tools/contracts";
1import { PROVIDER_DISPLAY_NAMES, type ServerProviderStatus } from "@t3tools/contracts";
22import { memo } from "react";
33import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
44import { CircleAlertIcon } from "lucide-react";
......@@ -12,12 +12,7 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({
1212 return null;
1313 }
1414
15 const providerLabel =
16 status.provider === "codex"
17 ? "Codex"
18 : status.provider === "claudeAgent"
19 ? "Claude"
20 : status.provider;
15 const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider;
2116 const defaultMessage =
2217 status.status === "error"
2318 ? `${providerLabel} provider is unavailable.`
apps/web/src/components/chat/TraitsPicker.browser.tsx created+362
......@@ -0,0 +1,362 @@
1import "../../index.css";
2
3import {
4 type ModelSelection,
5 ClaudeModelOptions,
6 CodexModelOptions,
7 DEFAULT_MODEL_BY_PROVIDER,
8 ProjectId,
9 ThreadId,
10} from "@t3tools/contracts";
11import { page } from "vitest/browser";
12import { useCallback } from "react";
13import { afterEach, describe, expect, it, vi } from "vitest";
14import { render } from "vitest-browser-react";
15
16import { TraitsPicker } from "./TraitsPicker";
17import {
18 COMPOSER_DRAFT_STORAGE_KEY,
19 ComposerThreadDraftState,
20 useComposerDraftStore,
21 useComposerThreadDraft,
22 useEffectiveComposerModelState,
23} from "../../composerDraftStore";
24
25// ── Claude TraitsPicker tests ─────────────────────────────────────────
26
27const CLAUDE_THREAD_ID = ThreadId.makeUnsafe("thread-claude-traits");
28
29function ClaudeTraitsPickerHarness(props: {
30 model: string;
31 fallbackModelSelection: ModelSelection | null;
32}) {
33 const prompt = useComposerThreadDraft(CLAUDE_THREAD_ID).prompt;
34 const setPrompt = useComposerDraftStore((store) => store.setPrompt);
35 const { modelOptions, selectedModel } = useEffectiveComposerModelState({
36 threadId: CLAUDE_THREAD_ID,
37 selectedProvider: "claudeAgent",
38 threadModelSelection: props.fallbackModelSelection,
39 projectModelSelection: null,
40 customModelsByProvider: { codex: [], claudeAgent: [] },
41 });
42 const handlePromptChange = useCallback(
43 (nextPrompt: string) => {
44 setPrompt(CLAUDE_THREAD_ID, nextPrompt);
45 },
46 [setPrompt],
47 );
48
49 return (
50 <TraitsPicker
51 provider="claudeAgent"
52 threadId={CLAUDE_THREAD_ID}
53 model={selectedModel ?? props.model}
54 prompt={prompt}
55 modelOptions={modelOptions?.claudeAgent}
56 onPromptChange={handlePromptChange}
57 />
58 );
59}
60
61async function mountClaudePicker(props?: {
62 model?: string;
63 prompt?: string;
64 options?: ClaudeModelOptions;
65 fallbackModelOptions?: {
66 effort?: "low" | "medium" | "high" | "max" | "ultrathink";
67 thinking?: boolean;
68 fastMode?: boolean;
69 } | null;
70 skipDraftModelOptions?: boolean;
71}) {
72 const model = props?.model ?? "claude-opus-4-6";
73 const claudeOptions = !props?.skipDraftModelOptions ? props?.options : undefined;
74 const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = {
75 [CLAUDE_THREAD_ID]: {
76 prompt: props?.prompt ?? "",
77 images: [],
78 nonPersistedImageIds: [],
79 persistedAttachments: [],
80 terminalContexts: [],
81 modelSelectionByProvider: props?.skipDraftModelOptions
82 ? {}
83 : {
84 claudeAgent: {
85 provider: "claudeAgent",
86 model,
87 ...(claudeOptions && Object.keys(claudeOptions).length > 0
88 ? { options: claudeOptions }
89 : {}),
90 },
91 },
92 activeProvider: "claudeAgent",
93 runtimeMode: null,
94 interactionMode: null,
95 },
96 };
97 useComposerDraftStore.setState({
98 draftsByThreadId,
99 draftThreadsByThreadId: {},
100 projectDraftThreadIdByProjectId: {},
101 });
102 const host = document.createElement("div");
103 document.body.append(host);
104 const fallbackModelSelection =
105 props?.fallbackModelOptions !== undefined
106 ? ({
107 provider: "claudeAgent",
108 model,
109 options: props.fallbackModelOptions ?? undefined,
110 } satisfies ModelSelection)
111 : null;
112 const screen = await render(
113 <ClaudeTraitsPickerHarness model={model} fallbackModelSelection={fallbackModelSelection} />,
114 { container: host },
115 );
116
117 const cleanup = async () => {
118 await screen.unmount();
119 host.remove();
120 };
121
122 return {
123 [Symbol.asyncDispose]: cleanup,
124 cleanup,
125 };
126}
127
128describe("TraitsPicker (Claude)", () => {
129 afterEach(() => {
130 document.body.innerHTML = "";
131 useComposerDraftStore.setState({
132 draftsByThreadId: {},
133 draftThreadsByThreadId: {},
134 projectDraftThreadIdByProjectId: {},
135 stickyModelSelectionByProvider: {},
136 });
137 });
138
139 it("shows fast mode controls for Opus", async () => {
140 await using _ = await mountClaudePicker();
141
142 await page.getByRole("button").click();
143
144 await vi.waitFor(() => {
145 const text = document.body.textContent ?? "";
146 expect(text).toContain("Fast Mode");
147 expect(text).toContain("off");
148 expect(text).toContain("on");
149 });
150 });
151
152 it("hides fast mode controls for non-Opus models", async () => {
153 await using _ = await mountClaudePicker({ model: "claude-sonnet-4-6" });
154
155 await page.getByRole("button").click();
156
157 await vi.waitFor(() => {
158 expect(document.body.textContent ?? "").not.toContain("Fast Mode");
159 });
160 });
161
162 it("shows only the provided effort options", async () => {
163 await using _ = await mountClaudePicker({
164 model: "claude-sonnet-4-6",
165 });
166
167 await page.getByRole("button").click();
168
169 await vi.waitFor(() => {
170 const text = document.body.textContent ?? "";
171 expect(text).toContain("Low");
172 expect(text).toContain("Medium");
173 expect(text).toContain("High");
174 expect(text).not.toContain("Max");
175 expect(text).toContain("Ultrathink");
176 });
177 });
178
179 it("shows a th inking on/off dropdown for Haiku", async () => {
180 await using _ = await mountClaudePicker({
181 model: "claude-haiku-4-5",
182 options: { thinking: true },
183 });
184
185 await vi.waitFor(() => {
186 expect(document.body.textContent ?? "").toContain("Thinking On");
187 });
188 await page.getByRole("button").click();
189
190 await vi.waitFor(() => {
191 const text = document.body.textContent ?? "";
192 expect(text).toContain("Thinking");
193 expect(text).toContain("On (default)");
194 expect(text).toContain("Off");
195 });
196 });
197
198 it("shows prompt-controlled Ultrathink state with disabled effort controls", async () => {
199 await using _ = await mountClaudePicker({
200 model: "claude-opus-4-6",
201 options: { effort: "high" },
202 prompt: "Ultrathink:\nInvestigate this",
203 });
204
205 await vi.waitFor(() => {
206 expect(document.body.textContent ?? "").toContain("Ultrathink");
207 expect(document.body.textContent ?? "").not.toContain("Ultrathink · Prompt");
208 });
209 await page.getByRole("button").click();
210
211 await vi.waitFor(() => {
212 const text = document.body.textContent ?? "";
213 expect(text).toContain("Effort");
214 expect(text).toContain("Remove Ultrathink from the prompt to change effort.");
215 expect(text).not.toContain("Fallback Effort");
216 });
217 });
218
219 it("persists sticky claude model options when traits change", async () => {
220 await using _ = await mountClaudePicker({
221 model: "claude-opus-4-6",
222 options: { effort: "medium", fastMode: false },
223 });
224
225 await page.getByRole("button").click();
226 await page.getByRole("menuitemradio", { name: "Max" }).click();
227
228 expect(
229 useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent,
230 ).toMatchObject({
231 provider: "claudeAgent",
232 options: {
233 effort: "max",
234 },
235 });
236 });
237});
238
239// ── Codex TraitsPicker tests ──────────────────────────────────────────
240
241async function mountCodexPicker(props: { model?: string; options?: CodexModelOptions }) {
242 const threadId = ThreadId.makeUnsafe("thread-codex-traits");
243 const model = props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex;
244 const draftsByThreadId: Record<ThreadId, ComposerThreadDraftState> = {
245 [threadId]: {
246 prompt: "",
247 images: [],
248 nonPersistedImageIds: [],
249 persistedAttachments: [],
250 terminalContexts: [],
251 modelSelectionByProvider: {
252 codex: {
253 provider: "codex",
254 model,
255 ...(props.options ? { options: props.options } : {}),
256 },
257 },
258 activeProvider: "codex",
259 runtimeMode: null,
260 interactionMode: null,
261 },
262 };
263
264 useComposerDraftStore.setState({
265 draftsByThreadId,
266 draftThreadsByThreadId: {},
267 projectDraftThreadIdByProjectId: {
268 [ProjectId.makeUnsafe("project-codex-traits")]: threadId,
269 },
270 });
271 const host = document.createElement("div");
272 document.body.append(host);
273 const screen = await render(
274 <TraitsPicker
275 provider="codex"
276 threadId={threadId}
277 model={props.model ?? DEFAULT_MODEL_BY_PROVIDER.codex}
278 prompt=""
279 modelOptions={props.options}
280 onPromptChange={() => {}}
281 />,
282 { container: host },
283 );
284
285 const cleanup = async () => {
286 await screen.unmount();
287 host.remove();
288 };
289
290 return {
291 [Symbol.asyncDispose]: cleanup,
292 cleanup,
293 };
294}
295
296describe("TraitsPicker (Codex)", () => {
297 afterEach(() => {
298 document.body.innerHTML = "";
299 localStorage.removeItem(COMPOSER_DRAFT_STORAGE_KEY);
300 useComposerDraftStore.setState({
301 draftsByThreadId: {},
302 draftThreadsByThreadId: {},
303 projectDraftThreadIdByProjectId: {},
304 stickyModelSelectionByProvider: {},
305 });
306 });
307
308 it("shows fast mode controls", async () => {
309 await using _ = await mountCodexPicker({
310 options: { fastMode: false },
311 });
312
313 await page.getByRole("button").click();
314
315 await vi.waitFor(() => {
316 const text = document.body.textContent ?? "";
317 expect(text).toContain("Fast Mode");
318 expect(text).toContain("off");
319 expect(text).toContain("on");
320 });
321 });
322
323 it("shows Fast in the trigger label when fast mode is active", async () => {
324 await using _ = await mountCodexPicker({
325 options: { fastMode: true },
326 });
327
328 await vi.waitFor(() => {
329 expect(document.body.textContent ?? "").toContain("High · Fast");
330 });
331 });
332
333 it("shows only the provided effort options", async () => {
334 await using _ = await mountCodexPicker({
335 options: { fastMode: false },
336 });
337
338 await page.getByRole("button").click();
339
340 await vi.waitFor(() => {
341 const text = document.body.textContent ?? "";
342 expect(text).toContain("Low");
343 expect(text).toContain("Medium");
344 expect(text).toContain("High");
345 expect(text).toContain("Extra High");
346 });
347 });
348
349 it("persists sticky codex model options when traits change", async () => {
350 await using _ = await mountCodexPicker({
351 options: { fastMode: false },
352 });
353
354 await page.getByRole("button").click();
355 await page.getByRole("menuitemradio", { name: "on" }).click();
356
357 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toMatchObject({
358 provider: "codex",
359 options: { fastMode: true },
360 });
361 });
362});
apps/web/src/components/chat/TraitsPicker.tsx created+320
......@@ -0,0 +1,320 @@
1import {
2 type ClaudeModelOptions,
3 type CodexModelOptions,
4 type ProviderKind,
5 type ProviderModelOptions,
6 type ThreadId,
7} from "@t3tools/contracts";
8import {
9 applyClaudePromptEffortPrefix,
10 getModelCapabilities,
11 isClaudeUltrathinkPrompt,
12 trimOrNull,
13 getDefaultEffort,
14 hasEffortLevel,
15} from "@t3tools/shared/model";
16import { memo, useCallback, useState } from "react";
17import { ChevronDownIcon } from "lucide-react";
18import { Button } from "../ui/button";
19import {
20 Menu,
21 MenuGroup,
22 MenuPopup,
23 MenuRadioGroup,
24 MenuRadioItem,
25 MenuSeparator as MenuDivider,
26 MenuTrigger,
27} from "../ui/menu";
28import { useComposerDraftStore } from "../../composerDraftStore";
29
30type ProviderOptions = ProviderModelOptions[ProviderKind];
31
32const ULTRATHINK_PROMPT_PREFIX = "Ultrathink:\n";
33
34function getRawEffort(
35 provider: ProviderKind,
36 modelOptions: ProviderOptions | null | undefined,
37): string | null {
38 if (provider === "codex") {
39 return trimOrNull((modelOptions as CodexModelOptions | undefined)?.reasoningEffort);
40 }
41 return trimOrNull((modelOptions as ClaudeModelOptions | undefined)?.effort);
42}
43
44function buildNextOptions(
45 provider: ProviderKind,
46 modelOptions: ProviderOptions | null | undefined,
47 patch: Record<string, unknown>,
48): ProviderOptions {
49 if (provider === "codex") {
50 return { ...(modelOptions as CodexModelOptions | undefined), ...patch } as CodexModelOptions;
51 }
52 return { ...(modelOptions as ClaudeModelOptions | undefined), ...patch } as ClaudeModelOptions;
53}
54
55function getSelectedTraits(
56 provider: ProviderKind,
57 model: string | null | undefined,
58 prompt: string,
59 modelOptions: ProviderOptions | null | undefined,
60) {
61 const caps = getModelCapabilities(provider, model);
62 const effortLevels = caps.reasoningEffortLevels;
63 const defaultEffort = getDefaultEffort(caps);
64
65 // Resolve effort from options (provider-specific key)
66 const resolvedEffort = getRawEffort(provider, modelOptions);
67
68 // Filter out prompt-injected efforts from the "current effort" display
69 const isPromptInjected = resolvedEffort
70 ? caps.promptInjectedEffortLevels.includes(resolvedEffort)
71 : false;
72 const effort =
73 resolvedEffort && !isPromptInjected && hasEffortLevel(caps, resolvedEffort)
74 ? resolvedEffort
75 : defaultEffort && hasEffortLevel(caps, defaultEffort)
76 ? defaultEffort
77 : null;
78
79 // Thinking toggle (only for models that support it)
80 const thinkingEnabled = caps.supportsThinkingToggle
81 ? ((modelOptions as ClaudeModelOptions | undefined)?.thinking ?? true)
82 : null;
83
84 // Fast mode
85 const fastModeEnabled =
86 caps.supportsFastMode &&
87 (modelOptions as { fastMode?: boolean } | undefined)?.fastMode === true;
88
89 // Prompt-controlled effort (e.g. ultrathink in prompt text)
90 const ultrathinkPromptControlled =
91 caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt);
92
93 return {
94 caps,
95 effort,
96 effortLevels,
97 thinkingEnabled,
98 fastModeEnabled,
99 ultrathinkPromptControlled,
100 };
101}
102
103export interface TraitsMenuContentProps {
104 provider: ProviderKind;
105 threadId: ThreadId;
106 model: string | null | undefined;
107 prompt: string;
108 onPromptChange: (prompt: string) => void;
109 modelOptions?: ProviderOptions | null | undefined;
110}
111
112export const TraitsMenuContent = memo(function TraitsMenuContentImpl({
113 provider,
114 threadId,
115 model,
116 prompt,
117 onPromptChange,
118 modelOptions,
119}: TraitsMenuContentProps) {
120 const setProviderModelOptions = useComposerDraftStore((store) => store.setProviderModelOptions);
121 const {
122 caps,
123 effort,
124 effortLevels,
125 thinkingEnabled,
126 fastModeEnabled,
127 ultrathinkPromptControlled,
128 } = getSelectedTraits(provider, model, prompt, modelOptions);
129 const defaultEffort = getDefaultEffort(caps);
130
131 const handleEffortChange = useCallback(
132 (value: string) => {
133 if (ultrathinkPromptControlled) return;
134 if (!value) return;
135 const nextOption = effortLevels.find((option) => option.value === value);
136 if (!nextOption) return;
137 if (caps.promptInjectedEffortLevels.includes(nextOption.value)) {
138 const nextPrompt =
139 prompt.trim().length === 0
140 ? ULTRATHINK_PROMPT_PREFIX
141 : applyClaudePromptEffortPrefix(prompt, "ultrathink");
142 onPromptChange(nextPrompt);
143 return;
144 }
145 const effortKey = provider === "codex" ? "reasoningEffort" : "effort";
146 setProviderModelOptions(
147 threadId,
148 provider,
149 buildNextOptions(provider, modelOptions, { [effortKey]: nextOption.value }),
150 { persistSticky: true },
151 );
152 },
153 [
154 ultrathinkPromptControlled,
155 modelOptions,
156 onPromptChange,
157 threadId,
158 setProviderModelOptions,
159 effortLevels,
160 prompt,
161 caps.promptInjectedEffortLevels,
162 provider,
163 ],
164 );
165
166 if (effort === null && thinkingEnabled === null) {
167 return null;
168 }
169
170 return (
171 <>
172 {effort ? (
173 <>
174 <MenuGroup>
175 <div className="px-2 pt-1.5 pb-1 font-medium text-muted-foreground text-xs">Effort</div>
176 {ultrathinkPromptControlled ? (
177 <div className="px-2 pb-1.5 text-muted-foreground/80 text-xs">
178 Remove Ultrathink from the prompt to change effort.
179 </div>
180 ) : null}
181 <MenuRadioGroup value={effort} onValueChange={handleEffortChange}>
182 {effortLevels.map((option) => (
183 <MenuRadioItem
184 key={option.value}
185 value={option.value}
186 disabled={ultrathinkPromptControlled}
187 >
188 {option.label}
189 {option.value === defaultEffort ? " (default)" : ""}
190 </MenuRadioItem>
191 ))}
192 </MenuRadioGroup>
193 </MenuGroup>
194 </>
195 ) : thinkingEnabled !== null ? (
196 <MenuGroup>
197 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Thinking</div>
198 <MenuRadioGroup
199 value={thinkingEnabled ? "on" : "off"}
200 onValueChange={(value) => {
201 setProviderModelOptions(
202 threadId,
203 provider,
204 buildNextOptions(provider, modelOptions, { thinking: value === "on" }),
205 { persistSticky: true },
206 );
207 }}
208 >
209 <MenuRadioItem value="on">On (default)</MenuRadioItem>
210 <MenuRadioItem value="off">Off</MenuRadioItem>
211 </MenuRadioGroup>
212 </MenuGroup>
213 ) : null}
214 {caps.supportsFastMode ? (
215 <>
216 <MenuDivider />
217 <MenuGroup>
218 <div className="px-2 py-1.5 font-medium text-muted-foreground text-xs">Fast Mode</div>
219 <MenuRadioGroup
220 value={fastModeEnabled ? "on" : "off"}
221 onValueChange={(value) => {
222 setProviderModelOptions(
223 threadId,
224 provider,
225 buildNextOptions(provider, modelOptions, { fastMode: value === "on" }),
226 { persistSticky: true },
227 );
228 }}
229 >
230 <MenuRadioItem value="off">off</MenuRadioItem>
231 <MenuRadioItem value="on">on</MenuRadioItem>
232 </MenuRadioGroup>
233 </MenuGroup>
234 </>
235 ) : null}
236 </>
237 );
238});
239
240export const TraitsPicker = memo(function TraitsPicker({
241 provider,
242 threadId,
243 model,
244 prompt,
245 onPromptChange,
246 modelOptions,
247}: TraitsMenuContentProps) {
248 const [isMenuOpen, setIsMenuOpen] = useState(false);
249 const {
250 caps,
251 effort,
252 effortLevels,
253 thinkingEnabled,
254 fastModeEnabled,
255 ultrathinkPromptControlled,
256 } = getSelectedTraits(provider, model, prompt, modelOptions);
257
258 const effortLabel = effort
259 ? (effortLevels.find((l) => l.value === effort)?.label ?? effort)
260 : null;
261 const triggerLabel = [
262 ultrathinkPromptControlled
263 ? "Ultrathink"
264 : effortLabel
265 ? effortLabel
266 : thinkingEnabled === null
267 ? null
268 : `Thinking ${thinkingEnabled ? "On" : "Off"}`,
269 ...(caps.supportsFastMode && fastModeEnabled ? ["Fast"] : []),
270 ]
271 .filter(Boolean)
272 .join(" · ");
273
274 const isCodexStyle = provider === "codex";
275
276 return (
277 <Menu
278 open={isMenuOpen}
279 onOpenChange={(open) => {
280 setIsMenuOpen(open);
281 }}
282 >
283 <MenuTrigger
284 render={
285 <Button
286 size="sm"
287 variant="ghost"
288 className={
289 isCodexStyle
290 ? "min-w-0 max-w-40 shrink justify-start overflow-hidden whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:max-w-48 sm:px-3 [&_svg]:mx-0"
291 : "shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
292 }
293 />
294 }
295 >
296 {isCodexStyle ? (
297 <span className="flex min-w-0 w-full items-center gap-2 overflow-hidden">
298 {triggerLabel}
299 <ChevronDownIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
300 </span>
301 ) : (
302 <>
303 <span>{triggerLabel}</span>
304 <ChevronDownIcon aria-hidden="true" className="size-3 opacity-60" />
305 </>
306 )}
307 </MenuTrigger>
308 <MenuPopup align="start">
309 <TraitsMenuContent
310 provider={provider}
311 threadId={threadId}
312 model={model}
313 prompt={prompt}
314 onPromptChange={onPromptChange}
315 modelOptions={modelOptions}
316 />
317 </MenuPopup>
318 </Menu>
319 );
320});
apps/web/src/components/chat/composerProviderRegistry.test.tsx+54-18
......@@ -34,12 +34,51 @@ describe("getComposerProviderState", () => {
3434 provider: "codex",
3535 promptEffort: "low",
3636 modelOptionsForDispatch: {
37 reasoningEffort: "low",
38 fastMode: true,
39 },
40 });
41 });
42
43 it("preserves codex fast mode when it is the only active option", () => {
44 const state = getComposerProviderState({
45 provider: "codex",
46 model: "gpt-5.4",
47 prompt: "",
48 modelOptions: {
3749 codex: {
38 reasoningEffort: "low",
3950 fastMode: true,
4051 },
4152 },
4253 });
54
55 expect(state).toEqual({
56 provider: "codex",
57 promptEffort: "high",
58 modelOptionsForDispatch: {
59 fastMode: true,
60 },
61 });
62 });
63
64 it("drops explicit codex default/off overrides from dispatch while keeping the selected effort label", () => {
65 const state = getComposerProviderState({
66 provider: "codex",
67 model: "gpt-5.4",
68 prompt: "",
69 modelOptions: {
70 codex: {
71 reasoningEffort: "high",
72 fastMode: false,
73 },
74 },
75 });
76
77 expect(state).toEqual({
78 provider: "codex",
79 promptEffort: "high",
80 modelOptionsForDispatch: undefined,
81 });
4382 });
4483
4584 it("returns Claude defaults for effort-capable models", () => {
......@@ -73,9 +112,7 @@ describe("getComposerProviderState", () => {
73112 provider: "claudeAgent",
74113 promptEffort: "medium",
75114 modelOptionsForDispatch: {
76 claudeAgent: {
77 effort: "medium",
78 },
115 effort: "medium",
79116 },
80117 composerFrameClassName: "ultrathink-frame",
81118 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]",
......@@ -100,21 +137,18 @@ describe("getComposerProviderState", () => {
100137 provider: "claudeAgent",
101138 promptEffort: null,
102139 modelOptionsForDispatch: {
103 claudeAgent: {
104 thinking: false,
105 },
140 thinking: false,
106141 },
107142 });
108143 });
109144
110 it("ignores codex options while resolving Claude state", () => {
145 it("preserves Claude fast mode when it is the only active option", () => {
111146 const state = getComposerProviderState({
112147 provider: "claudeAgent",
113148 model: "claude-opus-4-6",
114149 prompt: "",
115150 modelOptions: {
116 codex: {
117 reasoningEffort: "low",
151 claudeAgent: {
118152 fastMode: true,
119153 },
120154 },
......@@ -123,25 +157,27 @@ describe("getComposerProviderState", () => {
123157 expect(state).toEqual({
124158 provider: "claudeAgent",
125159 promptEffort: "high",
126 modelOptionsForDispatch: undefined,
160 modelOptionsForDispatch: {
161 fastMode: true,
162 },
127163 });
128164 });
129165
130 it("ignores Claude options while resolving codex state", () => {
166 it("drops explicit Claude default/off overrides from dispatch while keeping the selected effort label", () => {
131167 const state = getComposerProviderState({
132 provider: "codex",
133 model: "gpt-5.4",
134 prompt: "Ultrathink:\nThis should not matter",
168 provider: "claudeAgent",
169 model: "claude-opus-4-6",
170 prompt: "",
135171 modelOptions: {
136172 claudeAgent: {
137 effort: "max",
138 fastMode: true,
173 effort: "high",
174 fastMode: false,
139175 },
140176 },
141177 });
142178
143179 expect(state).toEqual({
144 provider: "codex",
180 provider: "claudeAgent",
145181 promptEffort: "high",
146182 modelOptionsForDispatch: undefined,
147183 });
apps/web/src/components/chat/composerProviderRegistry.tsx+108-57
......@@ -5,17 +5,16 @@ import {
55 type ThreadId,
66} from "@t3tools/contracts";
77import {
8 getDefaultReasoningEffort,
9 getReasoningEffortOptions,
8 getModelCapabilities,
109 isClaudeUltrathinkPrompt,
1110 normalizeClaudeModelOptions,
1211 normalizeCodexModelOptions,
13 resolveReasoningEffortForProvider,
14 supportsClaudeUltrathinkKeyword,
12 trimOrNull,
13 getDefaultEffort,
14 hasEffortLevel,
1515} from "@t3tools/shared/model";
1616import type { ReactNode } from "react";
17import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./ClaudeTraitsPicker";
18import { CodexTraitsMenuContent, CodexTraitsPicker } from "./CodexTraitsPicker";
17import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
1918
2019export type ComposerProviderStateInput = {
2120 provider: ProviderKind;
......@@ -27,7 +26,7 @@ export type ComposerProviderStateInput = {
2726export type ComposerProviderState = {
2827 provider: ProviderKind;
2928 promptEffort: string | null;
30 modelOptionsForDispatch: ProviderModelOptions | undefined;
29 modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined;
3130 composerFrameClassName?: string;
3231 composerSurfaceClassName?: string;
3332 modelPickerIconClassName?: string;
......@@ -38,70 +37,114 @@ type ProviderRegistryEntry = {
3837 renderTraitsMenuContent: (input: {
3938 threadId: ThreadId;
4039 model: ModelSlug;
40 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
41 prompt: string;
4142 onPromptChange: (prompt: string) => void;
4243 }) => ReactNode;
4344 renderTraitsPicker: (input: {
4445 threadId: ThreadId;
4546 model: ModelSlug;
47 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
48 prompt: string;
4649 onPromptChange: (prompt: string) => void;
4750 }) => ReactNode;
4851};
4952
53function getProviderStateFromCapabilities(
54 input: ComposerProviderStateInput,
55): ComposerProviderState {
56 const { provider, model, prompt, modelOptions } = input;
57 const caps = getModelCapabilities(provider, model);
58 const providerOptions = modelOptions?.[provider];
59
60 // Resolve effort
61 const rawEffort = providerOptions
62 ? "effort" in providerOptions
63 ? providerOptions.effort
64 : "reasoningEffort" in providerOptions
65 ? providerOptions.reasoningEffort
66 : null
67 : null;
68
69 const draftEffort = trimOrNull(rawEffort);
70 const defaultEffort = getDefaultEffort(caps);
71 const isPromptInjected = draftEffort
72 ? caps.promptInjectedEffortLevels.includes(draftEffort)
73 : false;
74 const promptEffort =
75 draftEffort && !isPromptInjected && hasEffortLevel(caps, draftEffort)
76 ? draftEffort
77 : defaultEffort && hasEffortLevel(caps, defaultEffort)
78 ? defaultEffort
79 : null;
80
81 // Normalize options for dispatch
82 const normalizedOptions =
83 provider === "codex"
84 ? normalizeCodexModelOptions(model, providerOptions)
85 : normalizeClaudeModelOptions(model, providerOptions);
86
87 // Ultrathink styling (driven by capabilities data, not provider identity)
88 const ultrathinkActive =
89 caps.promptInjectedEffortLevels.length > 0 && isClaudeUltrathinkPrompt(prompt);
90
91 return {
92 provider,
93 promptEffort,
94 modelOptionsForDispatch: normalizedOptions,
95 ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}),
96 ...(ultrathinkActive
97 ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" }
98 : {}),
99 ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}),
100 };
101}
102
50103const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
51104 codex: {
52 getState: ({ modelOptions }) => {
53 const promptEffort =
54 resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ??
55 getDefaultReasoningEffort("codex");
56 const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex);
57
58 return {
59 provider: "codex",
60 promptEffort,
61 modelOptionsForDispatch: normalizedCodexOptions
62 ? { codex: normalizedCodexOptions }
63 : undefined,
64 };
65 },
66 renderTraitsMenuContent: ({ threadId }) => <CodexTraitsMenuContent threadId={threadId} />,
67 renderTraitsPicker: ({ threadId }) => <CodexTraitsPicker threadId={threadId} />,
105 getState: (input) => getProviderStateFromCapabilities(input),
106 renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => (
107 <TraitsMenuContent
108 provider="codex"
109 threadId={threadId}
110 model={model}
111 modelOptions={modelOptions}
112 prompt={prompt}
113 onPromptChange={onPromptChange}
114 />
115 ),
116 renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => (
117 <TraitsPicker
118 provider="codex"
119 threadId={threadId}
120 model={model}
121 modelOptions={modelOptions}
122 prompt={prompt}
123 onPromptChange={onPromptChange}
124 />
125 ),
68126 },
69127 claudeAgent: {
70 getState: ({ model, prompt, modelOptions }) => {
71 const reasoningOptions = getReasoningEffortOptions("claudeAgent", model);
72 const draftEffort = resolveReasoningEffortForProvider(
73 "claudeAgent",
74 modelOptions?.claudeAgent?.effort,
75 );
76 const defaultEffort = getDefaultReasoningEffort("claudeAgent");
77 const promptEffort =
78 draftEffort && draftEffort !== "ultrathink" && reasoningOptions.includes(draftEffort)
79 ? draftEffort
80 : reasoningOptions.includes(defaultEffort)
81 ? defaultEffort
82 : null;
83 const normalizedClaudeOptions = normalizeClaudeModelOptions(model, modelOptions?.claudeAgent);
84 const ultrathinkActive =
85 supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt);
86
87 return {
88 provider: "claudeAgent",
89 promptEffort,
90 modelOptionsForDispatch: normalizedClaudeOptions
91 ? { claudeAgent: normalizedClaudeOptions }
92 : undefined,
93 ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}),
94 ...(ultrathinkActive
95 ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" }
96 : {}),
97 ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}),
98 };
99 },
100 renderTraitsMenuContent: ({ threadId, model, onPromptChange }) => (
101 <ClaudeTraitsMenuContent threadId={threadId} model={model} onPromptChange={onPromptChange} />
128 getState: (input) => getProviderStateFromCapabilities(input),
129 renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => (
130 <TraitsMenuContent
131 provider="claudeAgent"
132 threadId={threadId}
133 model={model}
134 modelOptions={modelOptions}
135 prompt={prompt}
136 onPromptChange={onPromptChange}
137 />
102138 ),
103 renderTraitsPicker: ({ threadId, model, onPromptChange }) => (
104 <ClaudeTraitsPicker threadId={threadId} model={model} onPromptChange={onPromptChange} />
139 renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => (
140 <TraitsPicker
141 provider="claudeAgent"
142 threadId={threadId}
143 model={model}
144 modelOptions={modelOptions}
145 prompt={prompt}
146 onPromptChange={onPromptChange}
147 />
105148 ),
106149 },
107150};
......@@ -114,11 +157,15 @@ export function renderProviderTraitsMenuContent(input: {
114157 provider: ProviderKind;
115158 threadId: ThreadId;
116159 model: ModelSlug;
160 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
161 prompt: string;
117162 onPromptChange: (prompt: string) => void;
118163}): ReactNode {
119164 return composerProviderRegistry[input.provider].renderTraitsMenuContent({
120165 threadId: input.threadId,
121166 model: input.model,
167 modelOptions: input.modelOptions,
168 prompt: input.prompt,
122169 onPromptChange: input.onPromptChange,
123170 });
124171}
......@@ -127,11 +174,15 @@ export function renderProviderTraitsPicker(input: {
127174 provider: ProviderKind;
128175 threadId: ThreadId;
129176 model: ModelSlug;
177 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
178 prompt: string;
130179 onPromptChange: (prompt: string) => void;
131180}): ReactNode {
132181 return composerProviderRegistry[input.provider].renderTraitsPicker({
133182 threadId: input.threadId,
134183 model: input.model,
184 modelOptions: input.modelOptions,
185 prompt: input.prompt,
135186 onPromptChange: input.onPromptChange,
136187 });
137188}
apps/web/src/composerDraftStore.test.ts+241-156
......@@ -1,5 +1,10 @@
11import * as Schema from "effect/Schema";
2import { ProjectId, ThreadId } from "@t3tools/contracts";
2import {
3 ProjectId,
4 ThreadId,
5 type ModelSelection,
6 type ProviderModelOptions,
7} from "@t3tools/contracts";
38import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
49
510import {
......@@ -67,11 +72,27 @@ function resetComposerDraftStore() {
6772 draftsByThreadId: {},
6873 draftThreadsByThreadId: {},
6974 projectDraftThreadIdByProjectId: {},
70 stickyModel: null,
71 stickyModelOptions: {},
75 stickyModelSelectionByProvider: {},
76 stickyActiveProvider: null,
7277 });
7378}
7479
80function modelSelection(
81 provider: "codex" | "claudeAgent",
82 model: string,
83 options?: ModelSelection["options"],
84): ModelSelection {
85 return {
86 provider,
87 model,
88 ...(options ? { options } : {}),
89 } as ModelSelection;
90}
91
92function providerModelOptions(options: ProviderModelOptions): ProviderModelOptions {
93 return options;
94}
95
7596describe("composerDraftStore addImages", () => {
7697 const threadId = ThreadId.makeUnsafe("thread-dedupe");
7798 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
......@@ -197,6 +218,8 @@ describe("composerDraftStore syncPersistedAttachments", () => {
197218 draftsByThreadId: {},
198219 draftThreadsByThreadId: {},
199220 projectDraftThreadIdByProjectId: {},
221 stickyModelSelectionByProvider: {},
222 stickyActiveProvider: null,
200223 });
201224 });
202225
......@@ -253,6 +276,8 @@ describe("composerDraftStore terminal contexts", () => {
253276 draftsByThreadId: {},
254277 draftThreadsByThreadId: {},
255278 projectDraftThreadIdByProjectId: {},
279 stickyModelSelectionByProvider: {},
280 stickyActiveProvider: null,
256281 });
257282 });
258283
......@@ -595,62 +620,58 @@ describe("composerDraftStore project draft thread mapping", () => {
595620 });
596621});
597622
598describe("composerDraftStore modelOptions", () => {
623describe("composerDraftStore modelSelection", () => {
599624 const threadId = ThreadId.makeUnsafe("thread-model-options");
600625
601626 beforeEach(() => {
602627 resetComposerDraftStore();
603628 });
604629
605 it("stores provider-scoped model options in the draft", () => {
630 it("stores a model selection in the draft", () => {
606631 const store = useComposerDraftStore.getState();
607 store.setModelOptions(threadId, {
608 codex: {
632 store.setModelSelection(
633 threadId,
634 modelSelection("codex", "gpt-5.3-codex", {
609635 reasoningEffort: "xhigh",
610636 fastMode: true,
611 },
612 claudeAgent: {
613 thinking: false,
614 },
615 });
637 }),
638 );
616639
617 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
618 codex: {
640 expect(
641 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
642 ).toEqual(
643 modelSelection("codex", "gpt-5.3-codex", {
619644 reasoningEffort: "xhigh",
620645 fastMode: true,
621 },
622 claudeAgent: {
623 thinking: false,
624 },
625 });
646 }),
647 );
626648 });
627649
628 it("drops default-only model options from the draft", () => {
650 it("keeps default-only model selections on the draft", () => {
629651 const store = useComposerDraftStore.getState();
630 store.setModelOptions(threadId, {
631 codex: {
632 reasoningEffort: "high",
633 },
634 claudeAgent: {
635 thinking: true,
636 },
637 });
652 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4"));
638653
639 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
654 expect(
655 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
656 ).toEqual(modelSelection("codex", "gpt-5.4"));
640657 });
641658
642 it("replaces only the targeted provider model options", () => {
659 it("replaces only the targeted provider options on the current model selection", () => {
643660 const store = useComposerDraftStore.getState();
644661
645 store.setModelOptions(threadId, {
646 codex: {
647 reasoningEffort: "xhigh",
648 },
649 claudeAgent: {
662 store.setModelSelection(
663 threadId,
664 modelSelection("claudeAgent", "claude-opus-4-6", {
650665 effort: "max",
651666 fastMode: true,
652 },
653 });
667 }),
668 );
669 store.setStickyModelSelection(
670 modelSelection("claudeAgent", "claude-opus-4-6", {
671 effort: "max",
672 fastMode: true,
673 }),
674 );
654675
655676 store.setProviderModelOptions(
656677 threadId,
......@@ -661,114 +682,166 @@ describe("composerDraftStore modelOptions", () => {
661682 { persistSticky: true },
662683 );
663684
664 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
665 codex: {
666 reasoningEffort: "xhigh",
667 },
668 claudeAgent: {
685 expect(
686 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
687 .claudeAgent,
688 ).toEqual(
689 modelSelection("claudeAgent", "claude-opus-4-6", {
669690 thinking: false,
670 },
671 });
672 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({
673 codex: {
674 reasoningEffort: "xhigh",
675 },
676 claudeAgent: {
691 }),
692 );
693 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual(
694 modelSelection("claudeAgent", "claude-opus-4-6", {
677695 thinking: false,
678 },
679 });
696 }),
697 );
680698 });
681699
682 it("removes only the targeted provider entry when next options normalize empty", () => {
700 it("keeps explicit default-state overrides on the selection", () => {
683701 const store = useComposerDraftStore.getState();
684702
685 store.setModelOptions(threadId, {
686 codex: {
687 reasoningEffort: "xhigh",
688 },
689 claudeAgent: {
703 store.setModelSelection(
704 threadId,
705 modelSelection("claudeAgent", "claude-opus-4-6", {
690706 effort: "max",
691 },
692 });
707 }),
708 );
693709
694710 store.setProviderModelOptions(threadId, "claudeAgent", {
695711 thinking: true,
696712 });
697713
698 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
699 codex: {
700 reasoningEffort: "xhigh",
701 },
702 });
703 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({});
714 expect(
715 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
716 .claudeAgent,
717 ).toEqual(
718 modelSelection("claudeAgent", "claude-opus-4-6", {
719 thinking: true,
720 }),
721 );
722 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider).toEqual({});
704723 });
705724
706 it("removes model options entirely when the last provider entry normalizes empty", () => {
725 it("keeps explicit off/default codex overrides on the selection", () => {
707726 const store = useComposerDraftStore.getState();
708727
709 store.setModelOptions(threadId, {
710 codex: {
711 fastMode: true,
712 },
713 });
728 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true }));
714729
715730 store.setProviderModelOptions(threadId, "codex", {
716731 reasoningEffort: "high",
717732 fastMode: false,
718733 });
719734
720 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
735 expect(
736 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
737 ).toEqual(
738 modelSelection("codex", "gpt-5.4", {
739 reasoningEffort: "high",
740 fastMode: false,
741 }),
742 );
721743 });
722744
723745 it("updates only the draft when sticky persistence is omitted", () => {
724746 const store = useComposerDraftStore.getState();
725747
726 store.setStickyModelOptions({
727 codex: {
728 fastMode: true,
729 },
730 });
731 store.setModelOptions(threadId, {
732 codex: {
733 fastMode: true,
734 },
735 claudeAgent: {
736 effort: "max",
737 },
738 });
748 store.setStickyModelSelection(
749 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
750 );
751 store.setModelSelection(
752 threadId,
753 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
754 );
739755
740756 store.setProviderModelOptions(threadId, "claudeAgent", {
741757 thinking: false,
742758 });
743759
744 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
745 codex: {
746 fastMode: true,
747 },
748 claudeAgent: {
760 expect(
761 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
762 .claudeAgent,
763 ).toEqual(
764 modelSelection("claudeAgent", "claude-opus-4-6", {
749765 thinking: false,
750 },
751 });
752 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({
753 codex: {
766 }),
767 );
768 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual(
769 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
770 );
771 });
772
773 it("does not clear other provider options when setting options for a single provider", () => {
774 const store = useComposerDraftStore.getState();
775
776 // Set options for both providers
777 store.setModelOptions(
778 threadId,
779 providerModelOptions({
780 codex: { fastMode: true },
781 claudeAgent: { effort: "max" },
782 }),
783 );
784
785 // Now set options for only codex — claudeAgent should be untouched
786 store.setModelOptions(threadId, providerModelOptions({ codex: { reasoningEffort: "xhigh" } }));
787
788 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
789 expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" });
790 expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" });
791 });
792
793 it("preserves other provider options when switching the active model selection", () => {
794 const store = useComposerDraftStore.getState();
795
796 store.setModelOptions(
797 threadId,
798 providerModelOptions({
799 codex: { fastMode: true },
800 claudeAgent: { effort: "max" },
801 }),
802 );
803
804 store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6"));
805
806 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
807 expect(draft?.modelSelectionByProvider.claudeAgent).toEqual(
808 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
809 );
810 expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ fastMode: true });
811 expect(draft?.activeProvider).toBe("claudeAgent");
812 });
813
814 it("creates the first sticky snapshot from provider option changes", () => {
815 const store = useComposerDraftStore.getState();
816
817 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4"));
818
819 store.setProviderModelOptions(
820 threadId,
821 "codex",
822 {
754823 fastMode: true,
755824 },
756 });
825 { persistSticky: true },
826 );
827
828 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual(
829 modelSelection("codex", "gpt-5.4", {
830 fastMode: true,
831 }),
832 );
757833 });
758834
759835 it("updates only the draft when sticky persistence is disabled", () => {
760836 const store = useComposerDraftStore.getState();
761837
762 store.setStickyModelOptions({
763 claudeAgent: {
764 effort: "max",
765 },
766 });
767 store.setModelOptions(threadId, {
768 claudeAgent: {
769 effort: "max",
770 },
771 });
838 store.setStickyModelSelection(
839 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
840 );
841 store.setModelSelection(
842 threadId,
843 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
844 );
772845
773846 store.setProviderModelOptions(
774847 threadId,
......@@ -779,34 +852,35 @@ describe("composerDraftStore modelOptions", () => {
779852 { persistSticky: false },
780853 );
781854
782 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({
783 claudeAgent: {
855 expect(
856 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
857 .claudeAgent,
858 ).toEqual(
859 modelSelection("claudeAgent", "claude-opus-4-6", {
784860 thinking: false,
785 },
786 });
787 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({
788 claudeAgent: {
789 effort: "max",
790 },
791 });
861 }),
862 );
863 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual(
864 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
865 );
792866 });
793867});
794868
795describe("composerDraftStore setModel", () => {
869describe("composerDraftStore setModelSelection", () => {
796870 const threadId = ThreadId.makeUnsafe("thread-model");
797871
798872 beforeEach(() => {
799873 resetComposerDraftStore();
800874 });
801875
802 it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => {
876 it("keeps explicit model overrides instead of coercing to null", () => {
803877 const store = useComposerDraftStore.getState();
804878
805 store.setModel(threadId, "gpt-5.3-codex");
879 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.3-codex"));
806880
807 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.model).toBe(
808 "gpt-5.3-codex",
809 );
881 expect(
882 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
883 ).toEqual(modelSelection("codex", "gpt-5.3-codex"));
810884 });
811885});
812886
......@@ -815,63 +889,74 @@ describe("composerDraftStore sticky composer settings", () => {
815889 resetComposerDraftStore();
816890 });
817891
818 it("stores sticky model and codex model options", () => {
892 it("stores a sticky model selection", () => {
819893 const store = useComposerDraftStore.getState();
820894
821 store.setStickyModel("gpt-5.3-codex");
822 store.setStickyModelOptions({
823 codex: {
895 store.setStickyModelSelection(
896 modelSelection("codex", "gpt-5.3-codex", {
824897 reasoningEffort: "medium",
825898 fastMode: true,
826 },
827 });
899 }),
900 );
828901
829 expect(useComposerDraftStore.getState()).toMatchObject({
830 stickyModel: "gpt-5.3-codex",
831 stickyModelOptions: {
832 codex: {
833 reasoningEffort: "medium",
834 fastMode: true,
835 },
836 },
837 });
902 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual(
903 modelSelection("codex", "gpt-5.3-codex", {
904 reasoningEffort: "medium",
905 fastMode: true,
906 }),
907 );
908 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex");
838909 });
839910
840 it("normalizes empty sticky model options", () => {
911 it("normalizes empty sticky model options by dropping selection options", () => {
841912 const store = useComposerDraftStore.getState();
842913
843 store.setStickyModelOptions({
844 codex: {
845 fastMode: false,
914 store.setStickyModelSelection(modelSelection("codex", "gpt-5.4"));
915
916 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual(
917 modelSelection("codex", "gpt-5.4"),
918 );
919 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex");
920 });
921
922 it("applies sticky activeProvider to new drafts", () => {
923 const store = useComposerDraftStore.getState();
924 const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider");
925
926 store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6"));
927 store.applyStickyState(threadId);
928
929 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
930 modelSelectionByProvider: {
931 claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"),
846932 },
933 activeProvider: "claudeAgent",
847934 });
848
849 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({});
850935 });
851936});
852937
853describe("composerDraftStore setProvider", () => {
938describe("composerDraftStore provider-scoped option updates", () => {
854939 const threadId = ThreadId.makeUnsafe("thread-provider");
855940
856941 beforeEach(() => {
857942 resetComposerDraftStore();
858943 });
859944
860 it("persists provider-only selection even when prompt/model are empty", () => {
861 const store = useComposerDraftStore.getState();
862
863 store.setProvider(threadId, "codex");
864
865 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("codex");
866 });
867
868 it("removes empty provider-only draft when provider is reset", () => {
945 it("retains off-provider option memory without changing the active selection", () => {
869946 const store = useComposerDraftStore.getState();
870
871 store.setProvider(threadId, "codex");
872 store.setProvider(threadId, null);
873
874 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();
947 store.setModelSelection(
948 threadId,
949 modelSelection("codex", "gpt-5.3-codex", {
950 reasoningEffort: "medium",
951 }),
952 );
953 store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" });
954 const draft = useComposerDraftStore.getState().draftsByThreadId[threadId];
955 expect(draft?.modelSelectionByProvider.codex).toEqual(
956 modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }),
957 );
958 expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" });
959 expect(draft?.activeProvider).toBe("codex");
875960 });
876961});
877962
apps/web/src/composerDraftStore.ts+583-180
......@@ -2,7 +2,8 @@ import {
22 CODEX_REASONING_EFFORT_OPTIONS,
33 type ClaudeCodeEffort,
44 type CodexReasoningEffort,
5 DEFAULT_REASONING_EFFORT_BY_PROVIDER,
5 type ModelSlug,
6 ModelSelection,
67 ProjectId,
78 ProviderInteractionMode,
89 ProviderKind,
......@@ -13,8 +14,14 @@ import {
1314import * as Schema from "effect/Schema";
1415import * as Equal from "effect/Equal";
1516import { DeepMutable } from "effect/Types";
16import { normalizeModelSlug } from "@t3tools/shared/model";
17import {
18 getDefaultModel,
19 normalizeModelSlug,
20 resolveModelSlugForProvider,
21} from "@t3tools/shared/model";
22import { useMemo } from "react";
1723import { getLocalStorageItem } from "./hooks/useLocalStorage";
24import { resolveAppModelSelection } from "./appSettings";
1825import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
1926import {
2027 type TerminalContextDraft,
......@@ -26,7 +33,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
2633import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";
2734
2835export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
29const COMPOSER_DRAFT_STORAGE_VERSION = 2;
36const COMPOSER_DRAFT_STORAGE_VERSION = 3;
3037const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
3138export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
3239
......@@ -73,9 +80,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({
7380 prompt: Schema.String,
7481 attachments: Schema.Array(PersistedComposerImageAttachment),
7582 terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),
76 provider: Schema.optionalKey(ProviderKind),
77 model: Schema.optionalKey(Schema.String),
78 modelOptions: Schema.optionalKey(ProviderModelOptions),
83 modelSelectionByProvider: Schema.optionalKey(
84 Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)),
85 ),
86 activeProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)),
7987 runtimeMode: Schema.optionalKey(RuntimeMode),
8088 interactionMode: Schema.optionalKey(ProviderInteractionMode),
8189});
......@@ -88,7 +96,38 @@ const LegacyCodexFields = Schema.Struct({
8896});
8997type LegacyCodexFields = typeof LegacyCodexFields.Type;
9098
91type LegacyPersistedCodexThreadDraftState = PersistedComposerThreadDraftState & LegacyCodexFields;
99const LegacyThreadModelFields = Schema.Struct({
100 provider: Schema.optionalKey(ProviderKind),
101 model: Schema.optionalKey(Schema.String),
102 modelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)),
103});
104type LegacyThreadModelFields = typeof LegacyThreadModelFields.Type;
105
106type LegacyV2ThreadDraftFields = {
107 modelSelection?: ModelSelection | null;
108 modelOptions?: ProviderModelOptions | null;
109};
110
111type LegacyPersistedComposerThreadDraftState = PersistedComposerThreadDraftState &
112 LegacyCodexFields &
113 LegacyThreadModelFields &
114 LegacyV2ThreadDraftFields;
115
116const LegacyStickyModelFields = Schema.Struct({
117 stickyProvider: Schema.optionalKey(ProviderKind),
118 stickyModel: Schema.optionalKey(Schema.String),
119 stickyModelOptions: Schema.optionalKey(Schema.NullOr(ProviderModelOptions)),
120});
121type LegacyStickyModelFields = typeof LegacyStickyModelFields.Type;
122
123type LegacyV2StoreFields = {
124 stickyModelSelection?: ModelSelection | null;
125 stickyModelOptions?: ProviderModelOptions | null;
126};
127
128type LegacyPersistedComposerDraftStoreState = PersistedComposerDraftStoreState &
129 LegacyStickyModelFields &
130 LegacyV2StoreFields;
92131
93132const PersistedDraftThreadState = Schema.Struct({
94133 projectId: ProjectId,
......@@ -105,8 +144,10 @@ const PersistedComposerDraftStoreState = Schema.Struct({
105144 draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState),
106145 draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState),
107146 projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId),
108 stickyModel: Schema.NullOr(Schema.String),
109 stickyModelOptions: ProviderModelOptions,
147 stickyModelSelectionByProvider: Schema.optionalKey(
148 Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)),
149 ),
150 stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)),
110151});
111152type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;
112153
......@@ -115,15 +156,14 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({
115156 state: PersistedComposerDraftStoreState,
116157});
117158
118interface ComposerThreadDraftState {
159export interface ComposerThreadDraftState {
119160 prompt: string;
120161 images: ComposerImageAttachment[];
121162 nonPersistedImageIds: string[];
122163 persistedAttachments: PersistedComposerImageAttachment[];
123164 terminalContexts: TerminalContextDraft[];
124 provider: ProviderKind | null;
125 model: string | null;
126 modelOptions: ProviderModelOptions | null;
165 modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
166 activeProvider: ProviderKind | null;
127167 runtimeMode: RuntimeMode | null;
128168 interactionMode: ProviderInteractionMode | null;
129169}
......@@ -146,8 +186,8 @@ interface ComposerDraftStoreState {
146186 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
147187 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;
148188 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
149 stickyModel: string | null;
150 stickyModelOptions: ProviderModelOptions;
189 stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
190 stickyActiveProvider: ProviderKind | null;
151191 getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;
152192 getDraftThread: (threadId: ThreadId) => DraftThreadState | null;
153193 setProjectDraftThreadId: (
......@@ -177,16 +217,18 @@ interface ComposerDraftStoreState {
177217 clearProjectDraftThreadId: (projectId: ProjectId) => void;
178218 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
179219 clearDraftThread: (threadId: ThreadId) => void;
180 setStickyModel: (model: string | null | undefined) => void;
181 setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void;
220 setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void;
182221 setPrompt: (threadId: ThreadId, prompt: string) => void;
183222 setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
184 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;
185 setModel: (threadId: ThreadId, model: string | null | undefined) => void;
223 setModelSelection: (
224 threadId: ThreadId,
225 modelSelection: ModelSelection | null | undefined,
226 ) => void;
186227 setModelOptions: (
187228 threadId: ThreadId,
188229 modelOptions: ProviderModelOptions | null | undefined,
189230 ) => void;
231 applyStickyState: (threadId: ThreadId) => void;
190232 setProviderModelOptions: (
191233 threadId: ThreadId,
192234 provider: ProviderKind,
......@@ -221,14 +263,42 @@ interface ComposerDraftStoreState {
221263 clearComposerContent: (threadId: ThreadId) => void;
222264}
223265
224const EMPTY_PROVIDER_MODEL_OPTIONS = Object.freeze<ProviderModelOptions>({});
266export interface EffectiveComposerModelState {
267 selectedModel: ModelSlug;
268 modelOptions: ProviderModelOptions | null;
269}
270
271function providerModelOptionsFromSelection(
272 modelSelection: ModelSelection | null | undefined,
273): ProviderModelOptions | null {
274 if (!modelSelection?.options) {
275 return null;
276 }
277
278 return {
279 [modelSelection.provider]: modelSelection.options,
280 };
281}
282
283function modelSelectionByProviderToOptions(
284 map: Partial<Record<ProviderKind, ModelSelection>> | null | undefined,
285): ProviderModelOptions | null {
286 if (!map) return null;
287 const result: Record<string, unknown> = {};
288 for (const [provider, selection] of Object.entries(map)) {
289 if (selection?.options) {
290 result[provider] = selection.options;
291 }
292 }
293 return Object.keys(result).length > 0 ? (result as ProviderModelOptions) : null;
294}
225295
226296const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({
227297 draftsByThreadId: {},
228298 draftThreadsByThreadId: {},
229299 projectDraftThreadIdByProjectId: {},
230 stickyModel: null,
231 stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS,
300 stickyModelSelectionByProvider: {},
301 stickyActiveProvider: null,
232302});
233303
234304const EMPTY_IMAGES: ComposerImageAttachment[] = [];
......@@ -238,15 +308,17 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
238308Object.freeze(EMPTY_IMAGES);
239309Object.freeze(EMPTY_IDS);
240310Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
311const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderKind, ModelSelection>> =
312 Object.freeze({});
313
241314const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({
242315 prompt: "",
243316 images: EMPTY_IMAGES,
244317 nonPersistedImageIds: EMPTY_IDS,
245318 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
246319 terminalContexts: EMPTY_TERMINAL_CONTEXTS,
247 provider: null,
248 model: null,
249 modelOptions: null,
320 modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER,
321 activeProvider: null,
250322 runtimeMode: null,
251323 interactionMode: null,
252324});
......@@ -258,9 +330,8 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
258330 nonPersistedImageIds: [],
259331 persistedAttachments: [],
260332 terminalContexts: [],
261 provider: null,
262 model: null,
263 modelOptions: null,
333 modelSelectionByProvider: {},
334 activeProvider: null,
264335 runtimeMode: null,
265336 interactionMode: null,
266337 };
......@@ -329,9 +400,8 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
329400 draft.images.length === 0 &&
330401 draft.persistedAttachments.length === 0 &&
331402 draft.terminalContexts.length === 0 &&
332 draft.provider === null &&
333 draft.model === null &&
334 draft.modelOptions === null &&
403 Object.keys(draft.modelSelectionByProvider).length === 0 &&
404 draft.activeProvider === null &&
335405 draft.runtimeMode === null &&
336406 draft.interactionMode === null
337407 );
......@@ -370,20 +440,28 @@ function normalizeProviderModelOptions(
370440 ? legacy.effort
371441 : undefined;
372442 const codexFastMode =
373 codexCandidate?.fastMode === true ||
374 (provider === "codex" && legacy?.codexFastMode === true) ||
375 (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast");
443 codexCandidate?.fastMode === true
444 ? true
445 : codexCandidate?.fastMode === false
446 ? false
447 : (provider === "codex" && legacy?.codexFastMode === true) ||
448 (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast")
449 ? true
450 : undefined;
376451 const codex =
377 codexReasoningEffort && codexReasoningEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex
452 codexReasoningEffort !== undefined || codexFastMode !== undefined
378453 ? {
379 reasoningEffort: codexReasoningEffort,
380 ...(codexFastMode ? { fastMode: true } : {}),
454 ...(codexReasoningEffort !== undefined ? { reasoningEffort: codexReasoningEffort } : {}),
455 ...(codexFastMode !== undefined ? { fastMode: codexFastMode } : {}),
381456 }
382 : codexFastMode
383 ? { fastMode: true }
384 : undefined;
457 : undefined;
385458
386 const claudeThinking = claudeCandidate?.thinking === false ? false : undefined;
459 const claudeThinking =
460 claudeCandidate?.thinking === true
461 ? true
462 : claudeCandidate?.thinking === false
463 ? false
464 : undefined;
387465 const claudeEffort: ClaudeCodeEffort | undefined =
388466 claudeCandidate?.effort === "low" ||
389467 claudeCandidate?.effort === "medium" ||
......@@ -392,17 +470,18 @@ function normalizeProviderModelOptions(
392470 claudeCandidate?.effort === "ultrathink"
393471 ? claudeCandidate.effort
394472 : undefined;
395 const claudeFastMode = claudeCandidate?.fastMode === true;
473 const claudeFastMode =
474 claudeCandidate?.fastMode === true
475 ? true
476 : claudeCandidate?.fastMode === false
477 ? false
478 : undefined;
396479 const claude =
397 claudeThinking === false ||
398 (claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent) ||
399 claudeFastMode
480 claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined
400481 ? {
401 ...(claudeThinking === false ? { thinking: false } : {}),
402 ...(claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent
403 ? { effort: claudeEffort }
404 : {}),
405 ...(claudeFastMode ? { fastMode: true } : {}),
482 ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}),
483 ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}),
484 ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}),
406485 }
407486 : undefined;
408487
......@@ -415,7 +494,73 @@ function normalizeProviderModelOptions(
415494 };
416495}
417496
418function replaceProviderModelOptions(
497function normalizeModelSelection(
498 value: unknown,
499 legacy?: {
500 provider?: unknown;
501 model?: unknown;
502 modelOptions?: unknown;
503 legacyCodex?: LegacyCodexFields;
504 },
505): ModelSelection | null {
506 const candidate = value && typeof value === "object" ? (value as Record<string, unknown>) : null;
507 const provider = normalizeProviderKind(candidate?.provider ?? legacy?.provider);
508 if (provider === null) {
509 return null;
510 }
511 const rawModel = candidate?.model ?? legacy?.model;
512 if (typeof rawModel !== "string") {
513 return null;
514 }
515 const model = normalizeModelSlug(rawModel, provider);
516 if (!model) {
517 return null;
518 }
519 const modelOptions = normalizeProviderModelOptions(
520 candidate?.options ? { [provider]: candidate.options } : legacy?.modelOptions,
521 provider,
522 provider === "codex" ? legacy?.legacyCodex : undefined,
523 );
524 const options = provider === "codex" ? modelOptions?.codex : modelOptions?.claudeAgent;
525 return {
526 provider,
527 model,
528 ...(options ? { options } : {}),
529 };
530}
531
532// ── Legacy sync helpers (used only during migration from v2 storage) ──
533
534function legacySyncModelSelectionOptions(
535 modelSelection: ModelSelection | null,
536 modelOptions: ProviderModelOptions | null | undefined,
537): ModelSelection | null {
538 if (modelSelection === null) {
539 return null;
540 }
541 const options = modelOptions?.[modelSelection.provider];
542 return {
543 provider: modelSelection.provider,
544 model: modelSelection.model,
545 ...(options ? { options } : {}),
546 };
547}
548
549function legacyMergeModelSelectionIntoProviderModelOptions(
550 modelSelection: ModelSelection | null,
551 currentModelOptions: ProviderModelOptions | null | undefined,
552): ProviderModelOptions | null {
553 if (modelSelection?.options === undefined) {
554 return normalizeProviderModelOptions(currentModelOptions);
555 }
556 return legacyReplaceProviderModelOptions(
557 normalizeProviderModelOptions(currentModelOptions),
558 modelSelection.provider,
559 modelSelection.options,
560 );
561}
562
563function legacyReplaceProviderModelOptions(
419564 currentModelOptions: ProviderModelOptions | null | undefined,
420565 provider: ProviderKind,
421566 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
......@@ -433,6 +578,72 @@ function replaceProviderModelOptions(
433578 });
434579}
435580
581// ── New helpers for the consolidated representation ────────────────────
582
583function legacyToModelSelectionByProvider(
584 modelSelection: ModelSelection | null,
585 modelOptions: ProviderModelOptions | null | undefined,
586): Partial<Record<ProviderKind, ModelSelection>> {
587 const result: Partial<Record<ProviderKind, ModelSelection>> = {};
588 // Add entries from the options bag (for non-active providers)
589 if (modelOptions) {
590 for (const provider of ["codex", "claudeAgent"] as const) {
591 const options = modelOptions[provider];
592 if (options && Object.keys(options).length > 0) {
593 result[provider] = {
594 provider,
595 model:
596 modelSelection?.provider === provider
597 ? modelSelection.model
598 : getDefaultModel(provider),
599 options,
600 };
601 }
602 }
603 }
604 // Add/overwrite the active selection (it's authoritative for its provider)
605 if (modelSelection) {
606 result[modelSelection.provider] = modelSelection;
607 }
608 return result;
609}
610
611export function deriveEffectiveComposerModelState(input: {
612 draft:
613 | Pick<ComposerThreadDraftState, "modelSelectionByProvider" | "activeProvider">
614 | null
615 | undefined;
616 selectedProvider: ProviderKind;
617 threadModelSelection: ModelSelection | null | undefined;
618 projectModelSelection: ModelSelection | null | undefined;
619 customModelsByProvider: Record<ProviderKind, readonly string[]>;
620}): EffectiveComposerModelState {
621 const baseModel = resolveModelSlugForProvider(
622 input.selectedProvider,
623 input.threadModelSelection?.model ??
624 input.projectModelSelection?.model ??
625 getDefaultModel(input.selectedProvider),
626 );
627 const activeSelection = input.draft?.modelSelectionByProvider?.[input.selectedProvider];
628 const selectedModel = activeSelection?.model
629 ? resolveAppModelSelection(
630 input.selectedProvider,
631 input.customModelsByProvider,
632 activeSelection.model,
633 )
634 : baseModel;
635 const modelOptions =
636 modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ??
637 providerModelOptionsFromSelection(input.threadModelSelection) ??
638 providerModelOptionsFromSelection(input.projectModelSelection) ??
639 null;
640
641 return {
642 selectedModel,
643 modelOptions,
644 };
645}
646
436647function revokeObjectPreviewUrl(previewUrl: string): void {
437648 if (typeof URL === "undefined") {
438649 return;
......@@ -619,10 +830,6 @@ function normalizePersistedDraftThreads(
619830
620831function normalizePersistedDraftsByThreadId(
621832 rawDraftMap: unknown,
622 resolveModelOptions: (
623 draftCandidate: PersistedComposerThreadDraftState | LegacyPersistedCodexThreadDraftState,
624 provider: ProviderKind | null,
625 ) => ProviderModelOptions | null,
626833): PersistedComposerDraftStoreState["draftsByThreadId"] {
627834 if (!rawDraftMap || typeof rawDraftMap !== "object") {
628835 return {};
......@@ -637,9 +844,7 @@ function normalizePersistedDraftsByThreadId(
637844 if (!draftValue || typeof draftValue !== "object") {
638845 continue;
639846 }
640 const draftCandidate = draftValue as
641 | PersistedComposerThreadDraftState
642 | LegacyPersistedCodexThreadDraftState;
847 const draftCandidate = draftValue as PersistedComposerThreadDraftState;
643848 const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : "";
644849 const attachments = Array.isArray(draftCandidate.attachments)
645850 ? draftCandidate.attachments.flatMap((entry) => {
......@@ -653,11 +858,6 @@ function normalizePersistedDraftsByThreadId(
653858 return normalized ? [normalized] : [];
654859 })
655860 : [];
656 const provider = normalizeProviderKind(draftCandidate.provider);
657 const model =
658 typeof draftCandidate.model === "string"
659 ? normalizeModelSlug(draftCandidate.model, provider ?? "codex")
660 : null;
661861 const runtimeMode =
662862 draftCandidate.runtimeMode === "approval-required" ||
663863 draftCandidate.runtimeMode === "full-access"
......@@ -667,18 +867,63 @@ function normalizePersistedDraftsByThreadId(
667867 draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default"
668868 ? draftCandidate.interactionMode
669869 : null;
670 const modelOptions = resolveModelOptions(draftCandidate, provider);
671870 const prompt = ensureInlineTerminalContextPlaceholders(
672871 promptCandidate,
673872 terminalContexts.length,
674873 );
874 // If the draft already has the v3 shape, use it directly
875 const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState;
876 let modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = {};
877 let activeProvider: ProviderKind | null = null;
878
879 if (
880 draftCandidate.modelSelectionByProvider &&
881 typeof draftCandidate.modelSelectionByProvider === "object"
882 ) {
883 // v3 format
884 modelSelectionByProvider = draftCandidate.modelSelectionByProvider as Partial<
885 Record<ProviderKind, ModelSelection>
886 >;
887 activeProvider = normalizeProviderKind(draftCandidate.activeProvider);
888 } else {
889 // v2 or legacy format: migrate
890 const normalizedModelOptions =
891 normalizeProviderModelOptions(
892 legacyDraftCandidate.modelOptions,
893 undefined,
894 legacyDraftCandidate,
895 ) ?? null;
896 const normalizedModelSelection = normalizeModelSelection(
897 legacyDraftCandidate.modelSelection,
898 {
899 provider: legacyDraftCandidate.provider,
900 model: legacyDraftCandidate.model,
901 modelOptions: normalizedModelOptions ?? legacyDraftCandidate.modelOptions,
902 legacyCodex: legacyDraftCandidate,
903 },
904 );
905 const mergedModelOptions = legacyMergeModelSelectionIntoProviderModelOptions(
906 normalizedModelSelection,
907 normalizedModelOptions,
908 );
909 const modelSelection = legacySyncModelSelectionOptions(
910 normalizedModelSelection,
911 mergedModelOptions,
912 );
913 modelSelectionByProvider = legacyToModelSelectionByProvider(
914 modelSelection,
915 mergedModelOptions,
916 );
917 activeProvider = modelSelection?.provider ?? null;
918 }
919
920 const hasModelData =
921 Object.keys(modelSelectionByProvider).length > 0 || activeProvider !== null;
675922 if (
676923 promptCandidate.length === 0 &&
677924 attachments.length === 0 &&
678925 terminalContexts.length === 0 &&
679 !provider &&
680 !model &&
681 modelOptions === null &&
926 !hasModelData &&
682927 !runtimeMode &&
683928 !interactionMode
684929 ) {
......@@ -688,9 +933,7 @@ function normalizePersistedDraftsByThreadId(
688933 prompt,
689934 attachments,
690935 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
691 ...(provider ? { provider } : {}),
692 ...(model ? { model } : {}),
693 ...(modelOptions ? { modelOptions } : {}),
936 ...(hasModelData ? { modelSelectionByProvider, activeProvider } : {}),
694937 ...(runtimeMode ? { runtimeMode } : {}),
695938 ...(interactionMode ? { interactionMode } : {}),
696939 };
......@@ -701,40 +944,45 @@ function normalizePersistedDraftsByThreadId(
701944
702945function migratePersistedComposerDraftStoreState(
703946 persistedState: unknown,
704 persistedVersion: number,
705947): PersistedComposerDraftStoreState {
706948 if (!persistedState || typeof persistedState !== "object") {
707949 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
708950 }
709 const candidate = persistedState as Record<string, unknown>;
951 const candidate = persistedState as LegacyPersistedComposerDraftStoreState;
710952 const rawDraftMap = candidate.draftsByThreadId;
711953 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
712954 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
713 const stickyModel =
714 typeof candidate.stickyModel === "string"
715 ? (normalizeModelSlug(candidate.stickyModel, "codex") ?? null)
716 : null;
717 const stickyModelOptions =
718 normalizeProviderModelOptions(candidate.stickyModelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS;
955
956 // Migrate sticky state from v2 (dual) to v3 (consolidated)
957 const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {};
958 const normalizedStickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, {
959 provider: candidate.stickyProvider ?? "codex",
960 model: candidate.stickyModel,
961 modelOptions: stickyModelOptions,
962 });
963 const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions(
964 normalizedStickyModelSelection,
965 stickyModelOptions,
966 );
967 const stickyModelSelection = legacySyncModelSelectionOptions(
968 normalizedStickyModelSelection,
969 nextStickyModelOptions,
970 );
971 const stickyModelSelectionByProvider = legacyToModelSelectionByProvider(
972 stickyModelSelection,
973 nextStickyModelOptions,
974 );
975 const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null;
976
719977 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
720978 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);
721 const draftsByThreadId = normalizePersistedDraftsByThreadId(
722 rawDraftMap,
723 (draftCandidate, provider) =>
724 persistedVersion >= COMPOSER_DRAFT_STORAGE_VERSION
725 ? normalizeProviderModelOptions(draftCandidate.modelOptions, provider)
726 : normalizeProviderModelOptions(
727 draftCandidate.modelOptions,
728 provider,
729 draftCandidate as LegacyPersistedCodexThreadDraftState,
730 ),
731 );
979 const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap);
732980 return {
733981 draftsByThreadId,
734982 draftThreadsByThreadId,
735983 projectDraftThreadIdByProjectId,
736 stickyModel,
737 stickyModelOptions,
984 stickyModelSelectionByProvider,
985 stickyActiveProvider,
738986 };
739987}
740988
......@@ -748,13 +996,13 @@ function partializeComposerDraftStoreState(
748996 if (typeof threadId !== "string" || threadId.length === 0) {
749997 continue;
750998 }
999 const hasModelData =
1000 Object.keys(draft.modelSelectionByProvider).length > 0 || draft.activeProvider !== null;
7511001 if (
7521002 draft.prompt.length === 0 &&
7531003 draft.persistedAttachments.length === 0 &&
7541004 draft.terminalContexts.length === 0 &&
755 draft.provider === null &&
756 draft.model === null &&
757 draft.modelOptions === null &&
1005 !hasModelData &&
7581006 draft.runtimeMode === null &&
7591007 draft.interactionMode === null
7601008 ) {
......@@ -776,9 +1024,12 @@ function partializeComposerDraftStoreState(
7761024 })),
7771025 }
7781026 : {}),
779 ...(draft.model ? { model: draft.model } : {}),
780 ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}),
781 ...(draft.provider ? { provider: draft.provider } : {}),
1027 ...(hasModelData
1028 ? {
1029 modelSelectionByProvider: draft.modelSelectionByProvider,
1030 activeProvider: draft.activeProvider,
1031 }
1032 : {}),
7821033 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),
7831034 ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}),
7841035 };
......@@ -788,8 +1039,8 @@ function partializeComposerDraftStoreState(
7881039 draftsByThreadId: persistedDraftsByThreadId,
7891040 draftThreadsByThreadId: state.draftThreadsByThreadId,
7901041 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
791 stickyModel: state.stickyModel,
792 stickyModelOptions: state.stickyModelOptions,
1042 stickyModelSelectionByProvider: state.stickyModelSelectionByProvider,
1043 stickyActiveProvider: state.stickyActiveProvider,
7931044 };
7941045}
7951046
......@@ -799,29 +1050,58 @@ function normalizeCurrentPersistedComposerDraftStoreState(
7991050 if (!persistedState || typeof persistedState !== "object") {
8001051 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
8011052 }
802 const normalizedPersistedState = persistedState as Record<string, unknown>;
1053 const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState;
8031054 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
8041055 normalizePersistedDraftThreads(
8051056 normalizedPersistedState.draftThreadsByThreadId,
8061057 normalizedPersistedState.projectDraftThreadIdByProjectId,
8071058 );
808 const stickyModel =
809 typeof normalizedPersistedState.stickyModel === "string"
810 ? (normalizeModelSlug(normalizedPersistedState.stickyModel, "codex") ?? null)
811 : null;
812 const stickyModelOptions =
813 normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ??
814 EMPTY_PROVIDER_MODEL_OPTIONS;
1059
1060 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats
1061 let stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = {};
1062 let stickyActiveProvider: ProviderKind | null = null;
1063 if (
1064 normalizedPersistedState.stickyModelSelectionByProvider &&
1065 typeof normalizedPersistedState.stickyModelSelectionByProvider === "object"
1066 ) {
1067 stickyModelSelectionByProvider =
1068 normalizedPersistedState.stickyModelSelectionByProvider as Partial<
1069 Record<ProviderKind, ModelSelection>
1070 >;
1071 stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyActiveProvider);
1072 } else {
1073 // Legacy migration path
1074 const stickyModelOptions =
1075 normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? {};
1076 const normalizedStickyModelSelection = normalizeModelSelection(
1077 normalizedPersistedState.stickyModelSelection,
1078 {
1079 provider: normalizedPersistedState.stickyProvider,
1080 model: normalizedPersistedState.stickyModel,
1081 modelOptions: stickyModelOptions,
1082 },
1083 );
1084 const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions(
1085 normalizedStickyModelSelection,
1086 stickyModelOptions,
1087 );
1088 const stickyModelSelection = legacySyncModelSelectionOptions(
1089 normalizedStickyModelSelection,
1090 nextStickyModelOptions,
1091 );
1092 stickyModelSelectionByProvider = legacyToModelSelectionByProvider(
1093 stickyModelSelection,
1094 nextStickyModelOptions,
1095 );
1096 stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyProvider);
1097 }
1098
8151099 return {
816 draftsByThreadId: normalizePersistedDraftsByThreadId(
817 normalizedPersistedState.draftsByThreadId,
818 (draftCandidate, provider) =>
819 normalizeProviderModelOptions(draftCandidate.modelOptions, provider),
820 ),
1100 draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId),
8211101 draftThreadsByThreadId,
8221102 projectDraftThreadIdByProjectId,
823 stickyModel,
824 stickyModelOptions,
1103 stickyModelSelectionByProvider,
1104 stickyActiveProvider,
8251105 };
8261106}
8271107
......@@ -948,6 +1228,11 @@ function hydrateImagesFromPersisted(
9481228function toHydratedThreadDraft(
9491229 persistedDraft: PersistedComposerThreadDraftState,
9501230): ComposerThreadDraftState {
1231 // The persisted draft is already in v3 shape (migration handles older formats)
1232 const modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> =
1233 persistedDraft.modelSelectionByProvider ?? {};
1234 const activeProvider = normalizeProviderKind(persistedDraft.activeProvider) ?? null;
1235
9511236 return {
9521237 prompt: persistedDraft.prompt,
9531238 images: hydrateImagesFromPersisted(persistedDraft.attachments),
......@@ -958,9 +1243,8 @@ function toHydratedThreadDraft(
9581243 ...context,
9591244 text: "",
9601245 })) ?? [],
961 provider: persistedDraft.provider ?? null,
962 model: persistedDraft.model ?? null,
963 modelOptions: persistedDraft.modelOptions ?? null,
1246 modelSelectionByProvider,
1247 activeProvider,
9641248 runtimeMode: persistedDraft.runtimeMode ?? null,
9651249 interactionMode: persistedDraft.interactionMode ?? null,
9661250 };
......@@ -972,8 +1256,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
9721256 draftsByThreadId: {},
9731257 draftThreadsByThreadId: {},
9741258 projectDraftThreadIdByProjectId: {},
975 stickyModel: null,
976 stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS,
1259 stickyModelSelectionByProvider: {},
1260 stickyActiveProvider: null,
9771261 getDraftThreadByProjectId: (projectId) => {
9781262 if (projectId.length === 0) {
9791263 return null;
......@@ -1219,27 +1503,67 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
12191503 };
12201504 });
12211505 },
1222 setStickyModel: (model) => {
1223 const normalizedModel = normalizeModelSlug(model, "codex") ?? null;
1506 setStickyModelSelection: (modelSelection) => {
1507 const normalized = normalizeModelSelection(modelSelection);
12241508 set((state) => {
1225 if (state.stickyModel === normalizedModel) {
1509 if (!normalized) {
12261510 return state;
12271511 }
1512 const nextMap: Partial<Record<ProviderKind, ModelSelection>> = {
1513 ...state.stickyModelSelectionByProvider,
1514 [normalized.provider]: normalized,
1515 };
1516 if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) {
1517 return state.stickyActiveProvider === normalized.provider
1518 ? state
1519 : { stickyActiveProvider: normalized.provider };
1520 }
12281521 return {
1229 stickyModel: normalizedModel,
1522 stickyModelSelectionByProvider: nextMap,
1523 stickyActiveProvider: normalized.provider,
12301524 };
12311525 });
12321526 },
1233 setStickyModelOptions: (modelOptions) => {
1234 const normalizedModelOptions =
1235 normalizeProviderModelOptions(modelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS;
1527 applyStickyState: (threadId) => {
1528 if (threadId.length === 0) {
1529 return;
1530 }
12361531 set((state) => {
1237 if (Equal.equals(state.stickyModelOptions, normalizedModelOptions)) {
1532 const stickyMap = state.stickyModelSelectionByProvider;
1533 const stickyActiveProvider = state.stickyActiveProvider;
1534 if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) {
12381535 return state;
12391536 }
1240 return {
1241 stickyModelOptions: normalizedModelOptions,
1537 const existing = state.draftsByThreadId[threadId];
1538 const base = existing ?? createEmptyThreadDraft();
1539 const nextMap = { ...base.modelSelectionByProvider };
1540 for (const [provider, selection] of Object.entries(stickyMap)) {
1541 if (selection) {
1542 const current = nextMap[provider as ProviderKind];
1543 nextMap[provider as ProviderKind] = {
1544 ...selection,
1545 model: current?.model ?? selection.model,
1546 };
1547 }
1548 }
1549 if (
1550 Equal.equals(base.modelSelectionByProvider, nextMap) &&
1551 base.activeProvider === stickyActiveProvider
1552 ) {
1553 return state;
1554 }
1555 const nextDraft: ComposerThreadDraftState = {
1556 ...base,
1557 modelSelectionByProvider: nextMap,
1558 activeProvider: stickyActiveProvider,
12421559 };
1560 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1561 if (shouldRemoveDraft(nextDraft)) {
1562 delete nextDraftsByThreadId[threadId];
1563 } else {
1564 nextDraftsByThreadId[threadId] = nextDraft;
1565 }
1566 return { draftsByThreadId: nextDraftsByThreadId };
12431567 });
12441568 },
12451569 setPrompt: (threadId, prompt) => {
......@@ -1285,50 +1609,43 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
12851609 return { draftsByThreadId: nextDraftsByThreadId };
12861610 });
12871611 },
1288 setProvider: (threadId, provider) => {
1612 setModelSelection: (threadId, modelSelection) => {
12891613 if (threadId.length === 0) {
12901614 return;
12911615 }
1292 const normalizedProvider = normalizeProviderKind(provider);
1616 const normalized = normalizeModelSelection(modelSelection);
12931617 set((state) => {
12941618 const existing = state.draftsByThreadId[threadId];
1295 if (!existing && normalizedProvider === null) {
1619 if (!existing && normalized === null) {
12961620 return state;
12971621 }
12981622 const base = existing ?? createEmptyThreadDraft();
1299 if (base.provider === normalizedProvider) {
1300 return state;
1301 }
1302 const nextDraft: ComposerThreadDraftState = {
1303 ...base,
1304 provider: normalizedProvider,
1305 };
1306 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1307 if (shouldRemoveDraft(nextDraft)) {
1308 delete nextDraftsByThreadId[threadId];
1309 } else {
1310 nextDraftsByThreadId[threadId] = nextDraft;
1311 }
1312 return { draftsByThreadId: nextDraftsByThreadId };
1313 });
1314 },
1315 setModel: (threadId, model) => {
1316 if (threadId.length === 0) {
1317 return;
1318 }
1319 set((state) => {
1320 const existing = state.draftsByThreadId[threadId];
1321 const normalizedModel = normalizeModelSlug(model, existing?.provider ?? "codex") ?? null;
1322 if (!existing && normalizedModel === null) {
1323 return state;
1623 const nextMap = { ...base.modelSelectionByProvider };
1624 if (normalized) {
1625 const current = nextMap[normalized.provider];
1626 if (normalized.options !== undefined) {
1627 // Explicit options provided → use them
1628 nextMap[normalized.provider] = normalized;
1629 } else {
1630 // No options in selection → preserve existing options, update provider+model
1631 nextMap[normalized.provider] = {
1632 provider: normalized.provider,
1633 model: normalized.model,
1634 ...(current?.options ? { options: current.options } : {}),
1635 };
1636 }
13241637 }
1325 const base = existing ?? createEmptyThreadDraft();
1326 if (base.model === normalizedModel) {
1638 const nextActiveProvider = normalized?.provider ?? base.activeProvider;
1639 if (
1640 Equal.equals(base.modelSelectionByProvider, nextMap) &&
1641 base.activeProvider === nextActiveProvider
1642 ) {
13271643 return state;
13281644 }
13291645 const nextDraft: ComposerThreadDraftState = {
13301646 ...base,
1331 model: normalizedModel,
1647 modelSelectionByProvider: nextMap,
1648 activeProvider: nextActiveProvider,
13321649 };
13331650 const nextDraftsByThreadId = { ...state.draftsByThreadId };
13341651 if (shouldRemoveDraft(nextDraft)) {
......@@ -1343,20 +1660,37 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
13431660 if (threadId.length === 0) {
13441661 return;
13451662 }
1663 const normalizedOpts = normalizeProviderModelOptions(modelOptions);
13461664 set((state) => {
13471665 const existing = state.draftsByThreadId[threadId];
1348 const provider = existing?.provider ?? null;
1349 const nextModelOptions = normalizeProviderModelOptions(modelOptions, provider);
1350 if (!existing && nextModelOptions === null) {
1666 if (!existing && normalizedOpts === null) {
13511667 return state;
13521668 }
13531669 const base = existing ?? createEmptyThreadDraft();
1354 if (Equal.equals(base.modelOptions, nextModelOptions)) {
1670 const nextMap = { ...base.modelSelectionByProvider };
1671 for (const provider of ["codex", "claudeAgent"] as const) {
1672 // Only touch providers explicitly present in the input
1673 if (!normalizedOpts || !(provider in normalizedOpts)) continue;
1674 const opts = normalizedOpts[provider];
1675 const current = nextMap[provider];
1676 if (opts) {
1677 nextMap[provider] = {
1678 provider,
1679 model: current?.model ?? getDefaultModel(provider),
1680 options: opts,
1681 };
1682 } else if (current?.options) {
1683 // Remove options but keep the selection
1684 const { options: _, ...rest } = current;
1685 nextMap[provider] = rest as ModelSelection;
1686 }
1687 }
1688 if (Equal.equals(base.modelSelectionByProvider, nextMap)) {
13551689 return state;
13561690 }
13571691 const nextDraft: ComposerThreadDraftState = {
13581692 ...base,
1359 modelOptions: nextModelOptions,
1693 modelSelectionByProvider: nextMap,
13601694 };
13611695 const nextDraftsByThreadId = { ...state.draftsByThreadId };
13621696 if (shouldRemoveDraft(nextDraft)) {
......@@ -1375,29 +1709,67 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
13751709 if (normalizedProvider === null) {
13761710 return;
13771711 }
1712 // Normalize just this provider's options
1713 const normalizedOpts = normalizeProviderModelOptions(
1714 { [normalizedProvider]: nextProviderOptions },
1715 normalizedProvider,
1716 );
1717 const providerOpts = normalizedOpts?.[normalizedProvider];
1718
13781719 set((state) => {
13791720 const existing = state.draftsByThreadId[threadId];
13801721 const base = existing ?? createEmptyThreadDraft();
1381 const nextModelOptions = replaceProviderModelOptions(
1382 base.modelOptions,
1383 normalizedProvider,
1384 nextProviderOptions,
1385 );
1386 const nextStickyModelOptions =
1387 options?.persistSticky === true
1388 ? (nextModelOptions ?? EMPTY_PROVIDER_MODEL_OPTIONS)
1389 : state.stickyModelOptions;
1722
1723 // Update the map entry for this provider
1724 const nextMap = { ...base.modelSelectionByProvider };
1725 const currentForProvider = nextMap[normalizedProvider];
1726 if (providerOpts) {
1727 nextMap[normalizedProvider] = {
1728 provider: normalizedProvider,
1729 model: currentForProvider?.model ?? getDefaultModel(normalizedProvider),
1730 options: providerOpts,
1731 };
1732 } else if (currentForProvider?.options) {
1733 const { options: _, ...rest } = currentForProvider;
1734 nextMap[normalizedProvider] = rest as ModelSelection;
1735 }
1736
1737 // Handle sticky persistence
1738 let nextStickyMap = state.stickyModelSelectionByProvider;
1739 let nextStickyActiveProvider = state.stickyActiveProvider;
1740 if (options?.persistSticky === true) {
1741 nextStickyMap = { ...state.stickyModelSelectionByProvider };
1742 const stickyBase =
1743 nextStickyMap[normalizedProvider] ??
1744 base.modelSelectionByProvider[normalizedProvider] ??
1745 ({
1746 provider: normalizedProvider,
1747 model: getDefaultModel(normalizedProvider),
1748 } as ModelSelection);
1749 if (providerOpts) {
1750 nextStickyMap[normalizedProvider] = {
1751 ...stickyBase,
1752 provider: normalizedProvider,
1753 options: providerOpts,
1754 };
1755 } else if (stickyBase.options) {
1756 const { options: _, ...rest } = stickyBase;
1757 nextStickyMap[normalizedProvider] = rest as ModelSelection;
1758 }
1759 nextStickyActiveProvider = base.activeProvider ?? normalizedProvider;
1760 }
13901761
13911762 if (
1392 Equal.equals(base.modelOptions, nextModelOptions) &&
1393 Equal.equals(state.stickyModelOptions, nextStickyModelOptions)
1763 Equal.equals(base.modelSelectionByProvider, nextMap) &&
1764 Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) &&
1765 state.stickyActiveProvider === nextStickyActiveProvider
13941766 ) {
13951767 return state;
13961768 }
13971769
13981770 const nextDraft: ComposerThreadDraftState = {
13991771 ...base,
1400 modelOptions: nextModelOptions,
1772 modelSelectionByProvider: nextMap,
14011773 };
14021774 const nextDraftsByThreadId = { ...state.draftsByThreadId };
14031775 if (shouldRemoveDraft(nextDraft)) {
......@@ -1409,7 +1781,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
14091781 return {
14101782 draftsByThreadId: nextDraftsByThreadId,
14111783 ...(options?.persistSticky === true
1412 ? { stickyModelOptions: nextStickyModelOptions }
1784 ? {
1785 stickyModelSelectionByProvider: nextStickyMap,
1786 stickyActiveProvider: nextStickyActiveProvider,
1787 }
14131788 : {}),
14141789 };
14151790 });
......@@ -1768,8 +2143,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
17682143 draftsByThreadId,
17692144 draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,
17702145 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,
1771 stickyModel: normalizedPersisted.stickyModel,
1772 stickyModelOptions: normalizedPersisted.stickyModelOptions,
2146 stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {},
2147 stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null,
17732148 };
17742149 },
17752150 },
......@@ -1780,6 +2155,34 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS
17802155 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);
17812156}
17822157
2158export function useEffectiveComposerModelState(input: {
2159 threadId: ThreadId;
2160 selectedProvider: ProviderKind;
2161 threadModelSelection: ModelSelection | null | undefined;
2162 projectModelSelection: ModelSelection | null | undefined;
2163 customModelsByProvider: Record<ProviderKind, readonly string[]>;
2164}): EffectiveComposerModelState {
2165 const draft = useComposerThreadDraft(input.threadId);
2166
2167 return useMemo(
2168 () =>
2169 deriveEffectiveComposerModelState({
2170 draft,
2171 selectedProvider: input.selectedProvider,
2172 threadModelSelection: input.threadModelSelection,
2173 projectModelSelection: input.projectModelSelection,
2174 customModelsByProvider: input.customModelsByProvider,
2175 }),
2176 [
2177 draft,
2178 input.customModelsByProvider,
2179 input.projectModelSelection,
2180 input.selectedProvider,
2181 input.threadModelSelection,
2182 ],
2183 );
2184}
2185
17832186/**
17842187 * Clear draft threads that have been promoted to server threads.
17852188 *
apps/web/src/hooks/useHandleNewThread.ts+3-14
......@@ -1,7 +1,6 @@
11import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
22import { useNavigate, useParams } from "@tanstack/react-router";
33import { useCallback } from "react";
4import { inferProviderForModel } from "@t3tools/shared/model";
54import {
65 type DraftThreadEnvMode,
76 type DraftThreadState,
......@@ -13,8 +12,6 @@ import { useStore } from "../store";
1312export function useHandleNewThread() {
1413 const projects = useStore((store) => store.projects);
1514 const threads = useStore((store) => store.threads);
16 const stickyModel = useComposerDraftStore((store) => store.stickyModel);
17 const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions);
1815 const navigate = useNavigate();
1916 const routeThreadId = useParams({
2017 strict: false,
......@@ -41,9 +38,7 @@ export function useHandleNewThread() {
4138 clearProjectDraftThreadId,
4239 getDraftThread,
4340 getDraftThreadByProjectId,
44 setModel,
45 setModelOptions,
46 setProvider,
41 applyStickyState,
4742 setDraftThreadContext,
4843 setProjectDraftThreadId,
4944 } = useComposerDraftStore.getState();
......@@ -102,13 +97,7 @@ export function useHandleNewThread() {
10297 envMode: options?.envMode ?? "local",
10398 runtimeMode: DEFAULT_RUNTIME_MODE,
10499 });
105 if (stickyModel) {
106 setProvider(threadId, inferProviderForModel(stickyModel));
107 setModel(threadId, stickyModel);
108 }
109 if (Object.keys(stickyModelOptions).length > 0) {
110 setModelOptions(threadId, stickyModelOptions);
111 }
100 applyStickyState(threadId);
112101
113102 await navigate({
114103 to: "/$threadId",
......@@ -116,7 +105,7 @@ export function useHandleNewThread() {
116105 });
117106 })();
118107 },
119 [navigate, routeThreadId, stickyModel, stickyModelOptions],
108 [navigate, routeThreadId],
120109 );
121110
122111 return {
apps/web/src/store.test.ts+50-14
......@@ -16,7 +16,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1616 codexThreadId: null,
1717 projectId: ProjectId.makeUnsafe("project-1"),
1818 title: "Thread",
19 model: "gpt-5-codex",
19 modelSelection: {
20 provider: "codex",
21 model: "gpt-5-codex",
22 },
2023 runtimeMode: DEFAULT_RUNTIME_MODE,
2124 interactionMode: DEFAULT_INTERACTION_MODE,
2225 session: null,
......@@ -40,7 +43,10 @@ function makeState(thread: Thread): AppState {
4043 id: ProjectId.makeUnsafe("project-1"),
4144 name: "Project",
4245 cwd: "/tmp/project",
43 model: "gpt-5-codex",
46 defaultModelSelection: {
47 provider: "codex",
48 model: "gpt-5-codex",
49 },
4450 expanded: true,
4551 scripts: [],
4652 },
......@@ -55,7 +61,10 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"
5561 id: ThreadId.makeUnsafe("thread-1"),
5662 projectId: ProjectId.makeUnsafe("project-1"),
5763 title: "Thread",
58 model: "gpt-5.3-codex",
64 modelSelection: {
65 provider: "codex",
66 model: "gpt-5.3-codex",
67 },
5968 runtimeMode: DEFAULT_RUNTIME_MODE,
6069 interactionMode: DEFAULT_INTERACTION_MODE,
6170 branch: null,
......@@ -82,7 +91,10 @@ function makeReadModel(thread: OrchestrationReadModel["threads"][number]): Orche
8291 id: ProjectId.makeUnsafe("project-1"),
8392 title: "Project",
8493 workspaceRoot: "/tmp/project",
85 defaultModel: "gpt-5.3-codex",
94 defaultModelSelection: {
95 provider: "codex",
96 model: "gpt-5.3-codex",
97 },
8698 createdAt: "2026-02-27T00:00:00.000Z",
8799 updatedAt: "2026-02-27T00:00:00.000Z",
88100 deletedAt: null,
......@@ -100,7 +112,10 @@ function makeReadModelProject(
100112 id: ProjectId.makeUnsafe("project-1"),
101113 title: "Project",
102114 workspaceRoot: "/tmp/project",
103 defaultModel: "gpt-5.3-codex",
115 defaultModelSelection: {
116 provider: "codex",
117 model: "gpt-5.3-codex",
118 },
104119 createdAt: "2026-02-27T00:00:00.000Z",
105120 updatedAt: "2026-02-27T00:00:00.000Z",
106121 deletedAt: null,
......@@ -159,7 +174,10 @@ describe("store pure functions", () => {
159174 id: project1,
160175 name: "Project 1",
161176 cwd: "/tmp/project-1",
162 model: DEFAULT_MODEL_BY_PROVIDER.codex,
177 defaultModelSelection: {
178 provider: "codex",
179 model: DEFAULT_MODEL_BY_PROVIDER.codex,
180 },
163181 expanded: true,
164182 scripts: [],
165183 },
......@@ -167,7 +185,10 @@ describe("store pure functions", () => {
167185 id: project2,
168186 name: "Project 2",
169187 cwd: "/tmp/project-2",
170 model: DEFAULT_MODEL_BY_PROVIDER.codex,
188 defaultModelSelection: {
189 provider: "codex",
190 model: DEFAULT_MODEL_BY_PROVIDER.codex,
191 },
171192 expanded: true,
172193 scripts: [],
173194 },
......@@ -175,7 +196,10 @@ describe("store pure functions", () => {
175196 id: project3,
176197 name: "Project 3",
177198 cwd: "/tmp/project-3",
178 model: DEFAULT_MODEL_BY_PROVIDER.codex,
199 defaultModelSelection: {
200 provider: "codex",
201 model: DEFAULT_MODEL_BY_PROVIDER.codex,
202 },
179203 expanded: true,
180204 scripts: [],
181205 },
......@@ -195,20 +219,26 @@ describe("store read model sync", () => {
195219 const initialState = makeState(makeThread());
196220 const readModel = makeReadModel(
197221 makeReadModelThread({
198 model: "claude-opus-4-6",
222 modelSelection: {
223 provider: "claudeAgent",
224 model: "claude-opus-4-6",
225 },
199226 }),
200227 );
201228
202229 const next = syncServerReadModel(initialState, readModel);
203230
204 expect(next.threads[0]?.model).toBe("claude-opus-4-6");
231 expect(next.threads[0]?.modelSelection.model).toBe("claude-opus-4-6");
205232 });
206233
207234 it("resolves claude aliases when session provider is claudeAgent", () => {
208235 const initialState = makeState(makeThread());
209236 const readModel = makeReadModel(
210237 makeReadModelThread({
211 model: "sonnet",
238 modelSelection: {
239 provider: "claudeAgent",
240 model: "sonnet",
241 },
212242 session: {
213243 threadId: ThreadId.makeUnsafe("thread-1"),
214244 status: "ready",
......@@ -223,7 +253,7 @@ describe("store read model sync", () => {
223253
224254 const next = syncServerReadModel(initialState, readModel);
225255
226 expect(next.threads[0]?.model).toBe("claude-sonnet-4-6");
256 expect(next.threads[0]?.modelSelection.model).toBe("claude-sonnet-4-6");
227257 });
228258
229259 it("preserves project and thread updatedAt timestamps from the read model", () => {
......@@ -250,7 +280,10 @@ describe("store read model sync", () => {
250280 id: project2,
251281 name: "Project 2",
252282 cwd: "/tmp/project-2",
253 model: DEFAULT_MODEL_BY_PROVIDER.codex,
283 defaultModelSelection: {
284 provider: "codex",
285 model: DEFAULT_MODEL_BY_PROVIDER.codex,
286 },
254287 expanded: true,
255288 scripts: [],
256289 },
......@@ -258,7 +291,10 @@ describe("store read model sync", () => {
258291 id: project1,
259292 name: "Project 1",
260293 cwd: "/tmp/project-1",
261 model: DEFAULT_MODEL_BY_PROVIDER.codex,
294 defaultModelSelection: {
295 provider: "codex",
296 model: DEFAULT_MODEL_BY_PROVIDER.codex,
297 },
262298 expanded: true,
263299 scripts: [],
264300 },
apps/web/src/store.ts+19-26
......@@ -1,16 +1,11 @@
11import { Fragment, type ReactNode, createElement, useEffect } from "react";
22import {
3 DEFAULT_MODEL_BY_PROVIDER,
43 type ProviderKind,
54 ThreadId,
65 type OrchestrationReadModel,
76 type OrchestrationSessionStatus,
87} from "@t3tools/contracts";
9import {
10 inferProviderForModel,
11 resolveModelSlug,
12 resolveModelSlugForProvider,
13} from "@t3tools/shared/model";
8import { resolveModelSlugForProvider } from "@t3tools/shared/model";
149import { create } from "zustand";
1510import { type ChatMessage, type Project, type Thread } from "./types";
1611import { Debouncer } from "@tanstack/react-pacer";
......@@ -137,9 +132,17 @@ function mapProjectsFromReadModel(
137132 id: project.id,
138133 name: project.title,
139134 cwd: project.workspaceRoot,
140 model:
141 existing?.model ??
142 resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex),
135 defaultModelSelection:
136 existing?.defaultModelSelection ??
137 (project.defaultModelSelection
138 ? {
139 ...project.defaultModelSelection,
140 model: resolveModelSlugForProvider(
141 project.defaultModelSelection.provider,
142 project.defaultModelSelection.model,
143 ),
144 }
145 : null),
143146 expanded:
144147 existing?.expanded ??
145148 (persistedExpandedProjectCwds.size > 0
......@@ -196,16 +199,6 @@ function toLegacyProvider(providerName: string | null): ProviderKind {
196199 return "codex";
197200}
198201
199function inferProviderForThreadModel(input: {
200 readonly model: string;
201 readonly sessionProviderName: string | null;
202}): ProviderKind {
203 if (input.sessionProviderName === "codex" || input.sessionProviderName === "claudeAgent") {
204 return input.sessionProviderName;
205 }
206 return inferProviderForModel(input.model);
207}
208
209202function resolveWsHttpOrigin(): string {
210203 if (typeof window === "undefined") return "";
211204 const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();
......@@ -255,13 +248,13 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
255248 codexThreadId: null,
256249 projectId: thread.projectId,
257250 title: thread.title,
258 model: resolveModelSlugForProvider(
259 inferProviderForThreadModel({
260 model: thread.model,
261 sessionProviderName: thread.session?.providerName ?? null,
262 }),
263 thread.model,
264 ),
251 modelSelection: {
252 ...thread.modelSelection,
253 model: resolveModelSlugForProvider(
254 thread.modelSelection.provider,
255 thread.modelSelection.model,
256 ),
257 },
265258 runtimeMode: thread.runtimeMode,
266259 interactionMode: thread.interactionMode,
267260 session: thread.session
apps/web/src/types.ts+4-3
......@@ -1,4 +1,5 @@
11import type {
2 ModelSelection,
23 OrchestrationLatestTurn,
34 OrchestrationProposedPlanId,
45 OrchestrationSessionStatus,
......@@ -8,8 +9,8 @@ import type {
89 ProjectId,
910 TurnId,
1011 MessageId,
11 CheckpointRef,
1212 ProviderKind,
13 CheckpointRef,
1314 ProviderInteractionMode,
1415 RuntimeMode,
1516} from "@t3tools/contracts";
......@@ -80,7 +81,7 @@ export interface Project {
8081 id: ProjectId;
8182 name: string;
8283 cwd: string;
83 model: string;
84 defaultModelSelection: ModelSelection | null;
8485 expanded: boolean;
8586 createdAt?: string | undefined;
8687 updatedAt?: string | undefined;
......@@ -92,7 +93,7 @@ export interface Thread {
9293 codexThreadId: string | null;
9394 projectId: ProjectId;
9495 title: string;
95 model: string;
96 modelSelection: ModelSelection;
9697 runtimeMode: RuntimeMode;
9798 interactionMode: ProviderInteractionMode;
9899 session: ThreadSession | null;
apps/web/src/worktreeCleanup.test.ts+4-1
......@@ -10,7 +10,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
1010 codexThreadId: null,
1111 projectId: ProjectId.makeUnsafe("project-1"),
1212 title: "Thread",
13 model: "gpt-5.3-codex",
13 modelSelection: {
14 provider: "codex",
15 model: "gpt-5.3-codex",
16 },
1417 runtimeMode: DEFAULT_RUNTIME_MODE,
1518 interactionMode: DEFAULT_INTERACTION_MODE,
1619 session: null,
apps/web/src/wsNativeApi.test.ts+5-2
......@@ -269,7 +269,7 @@ describe("wsNativeApi", () => {
269269 projectId: ProjectId.makeUnsafe("project-1"),
270270 title: "Project",
271271 workspaceRoot: "/tmp/workspace",
272 defaultModel: null,
272 defaultModelSelection: null,
273273 scripts: [],
274274 createdAt: "2026-02-24T00:00:00.000Z",
275275 updatedAt: "2026-02-24T00:00:00.000Z",
......@@ -311,7 +311,10 @@ describe("wsNativeApi", () => {
311311 projectId: ProjectId.makeUnsafe("project-1"),
312312 title: "Project",
313313 workspaceRoot: "/tmp/project",
314 defaultModel: "gpt-5-codex",
314 defaultModelSelection: {
315 provider: "codex",
316 model: "gpt-5-codex",
317 },
315318 createdAt: "2026-02-24T00:00:00.000Z",
316319 } as const;
317320 await api.orchestration.dispatchCommand(command);
packages/contracts/src/model.ts+165-19
......@@ -26,26 +26,166 @@ export const ProviderModelOptions = Schema.Struct({
2626});
2727export type ProviderModelOptions = typeof ProviderModelOptions.Type;
2828
29type ModelOption = {
29export type EffortOption = {
30 readonly value: string;
31 readonly label: string;
32 readonly isDefault?: true;
33};
34
35export type ModelCapabilities = {
36 readonly reasoningEffortLevels: readonly EffortOption[];
37 readonly supportsFastMode: boolean;
38 readonly supportsThinkingToggle: boolean;
39 readonly promptInjectedEffortLevels: readonly string[];
40};
41
42type ModelDefinition = {
3043 readonly slug: string;
3144 readonly name: string;
45 readonly capabilities: ModelCapabilities;
3246};
3347
48/**
49 * TODO: This should not be a static array, each provider
50 * should return its own model list over the WS API.
51 */
3452export const MODEL_OPTIONS_BY_PROVIDER = {
3553 codex: [
36 { slug: "gpt-5.4", name: "GPT-5.4" },
37 { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" },
38 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },
39 { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },
40 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },
41 { slug: "gpt-5.2", name: "GPT-5.2" },
54 {
55 slug: "gpt-5.4",
56 name: "GPT-5.4",
57 capabilities: {
58 reasoningEffortLevels: [
59 { value: "xhigh", label: "Extra High" },
60 { value: "high", label: "High", isDefault: true },
61 { value: "medium", label: "Medium" },
62 { value: "low", label: "Low" },
63 ],
64 supportsFastMode: true,
65 supportsThinkingToggle: false,
66 promptInjectedEffortLevels: [],
67 },
68 },
69 {
70 slug: "gpt-5.4-mini",
71 name: "GPT-5.4 Mini",
72 capabilities: {
73 reasoningEffortLevels: [
74 { value: "xhigh", label: "Extra High" },
75 { value: "high", label: "High", isDefault: true },
76 { value: "medium", label: "Medium" },
77 { value: "low", label: "Low" },
78 ],
79 supportsFastMode: true,
80 supportsThinkingToggle: false,
81 promptInjectedEffortLevels: [],
82 },
83 },
84 {
85 slug: "gpt-5.3-codex",
86 name: "GPT-5.3 Codex",
87 capabilities: {
88 reasoningEffortLevels: [
89 { value: "xhigh", label: "Extra High" },
90 { value: "high", label: "High", isDefault: true },
91 { value: "medium", label: "Medium" },
92 { value: "low", label: "Low" },
93 ],
94 supportsFastMode: true,
95 supportsThinkingToggle: false,
96 promptInjectedEffortLevels: [],
97 },
98 },
99 {
100 slug: "gpt-5.3-codex-spark",
101 name: "GPT-5.3 Codex Spark",
102 capabilities: {
103 reasoningEffortLevels: [
104 { value: "xhigh", label: "Extra High" },
105 { value: "high", label: "High", isDefault: true },
106 { value: "medium", label: "Medium" },
107 { value: "low", label: "Low" },
108 ],
109 supportsFastMode: true,
110 supportsThinkingToggle: false,
111 promptInjectedEffortLevels: [],
112 },
113 },
114 {
115 slug: "gpt-5.2-codex",
116 name: "GPT-5.2 Codex",
117 capabilities: {
118 reasoningEffortLevels: [
119 { value: "xhigh", label: "Extra High" },
120 { value: "high", label: "High", isDefault: true },
121 { value: "medium", label: "Medium" },
122 { value: "low", label: "Low" },
123 ],
124 supportsFastMode: true,
125 supportsThinkingToggle: false,
126 promptInjectedEffortLevels: [],
127 },
128 },
129 {
130 slug: "gpt-5.2",
131 name: "GPT-5.2",
132 capabilities: {
133 reasoningEffortLevels: [
134 { value: "xhigh", label: "Extra High" },
135 { value: "high", label: "High", isDefault: true },
136 { value: "medium", label: "Medium" },
137 { value: "low", label: "Low" },
138 ],
139 supportsFastMode: true,
140 supportsThinkingToggle: false,
141 promptInjectedEffortLevels: [],
142 },
143 },
42144 ],
43145 claudeAgent: [
44 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },
45 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
46 { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" },
146 {
147 slug: "claude-opus-4-6",
148 name: "Claude Opus 4.6",
149 capabilities: {
150 reasoningEffortLevels: [
151 { value: "low", label: "Low" },
152 { value: "medium", label: "Medium" },
153 { value: "high", label: "High", isDefault: true },
154 { value: "max", label: "Max" },
155 { value: "ultrathink", label: "Ultrathink" },
156 ],
157 supportsFastMode: true,
158 supportsThinkingToggle: false,
159 promptInjectedEffortLevels: ["ultrathink"],
160 },
161 },
162 {
163 slug: "claude-sonnet-4-6",
164 name: "Claude Sonnet 4.6",
165 capabilities: {
166 reasoningEffortLevels: [
167 { value: "low", label: "Low" },
168 { value: "medium", label: "Medium" },
169 { value: "high", label: "High", isDefault: true },
170 { value: "ultrathink", label: "Ultrathink" },
171 ],
172 supportsFastMode: false,
173 supportsThinkingToggle: false,
174 promptInjectedEffortLevels: ["ultrathink"],
175 },
176 },
177 {
178 slug: "claude-haiku-4-5",
179 name: "Claude Haiku 4.5",
180 capabilities: {
181 reasoningEffortLevels: [],
182 supportsFastMode: false,
183 supportsThinkingToggle: true,
184 promptInjectedEffortLevels: [],
185 },
186 },
47187 ],
48} as const satisfies Record<ProviderKind, readonly ModelOption[]>;
188} as const satisfies Record<ProviderKind, readonly ModelDefinition[]>;
49189export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER;
50190
51191type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"];
......@@ -85,12 +225,18 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
85225 },
86226};
87227
88export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {
89 codex: CODEX_REASONING_EFFORT_OPTIONS,
90 claudeAgent: CLAUDE_CODE_EFFORT_OPTIONS,
91} as const satisfies Record<ProviderKind, readonly ProviderReasoningEffort[]>;
228// ── Model capabilities index ──────────────────────────────────────────
92229
93export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = {
94 codex: "high",
95 claudeAgent: "high",
96} as const satisfies Record<ProviderKind, ProviderReasoningEffort>;
230export const MODEL_CAPABILITIES_INDEX = Object.fromEntries(
231 Object.entries(MODEL_OPTIONS_BY_PROVIDER).map(([provider, models]) => [
232 provider,
233 Object.fromEntries(models.map((m) => [m.slug, m.capabilities])),
234 ]),
235) as unknown as Record<ProviderKind, Record<string, ModelCapabilities>>;
236
237// ── Provider display names ────────────────────────────────────────────
238
239export const PROVIDER_DISPLAY_NAMES: Record<ProviderKind, string> = {
240 codex: "Codex",
241 claudeAgent: "Claude",
242};
packages/contracts/src/orchestration.test.ts+79-14
......@@ -7,9 +7,12 @@ import {
77 DEFAULT_RUNTIME_MODE,
88 OrchestrationGetTurnDiffInput,
99 OrchestrationLatestTurn,
10 ProjectCreatedPayload,
11 ProjectMetaUpdatedPayload,
1012 OrchestrationProposedPlan,
1113 OrchestrationSession,
1214 ProjectCreateCommand,
15 ThreadMetaUpdatedPayload,
1316 ThreadTurnStartCommand,
1417 ThreadCreatedPayload,
1518 ThreadTurnDiff,
......@@ -19,6 +22,8 @@ import {
1922const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
2023const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
2124const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
25const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);
26const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
2227const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);
2328const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
2429 ThreadTurnStartRequestedPayload,
......@@ -27,6 +32,7 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa
2732const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan);
2833const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);
2934const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload);
35const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload);
3036
3137it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
3238 Effect.gen(function* () {
......@@ -75,14 +81,52 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()
7581 projectId: " project-1 ",
7682 title: " Project Title ",
7783 workspaceRoot: " /tmp/workspace ",
78 defaultModel: " gpt-5.2 ",
84 defaultModelSelection: {
85 provider: "codex",
86 model: " gpt-5.2 ",
87 },
7988 createdAt: "2026-01-01T00:00:00.000Z",
8089 });
8190 assert.strictEqual(parsed.commandId, "cmd-1");
8291 assert.strictEqual(parsed.projectId, "project-1");
8392 assert.strictEqual(parsed.title, "Project Title");
8493 assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace");
85 assert.strictEqual(parsed.defaultModel, "gpt-5.2");
94 assert.deepStrictEqual(parsed.defaultModelSelection, {
95 provider: "codex",
96 model: "gpt-5.2",
97 });
98 }),
99);
100
101it.effect("decodes historical project.created payloads with a default provider", () =>
102 Effect.gen(function* () {
103 const parsed = yield* decodeProjectCreatedPayload({
104 projectId: "project-1",
105 title: "Project Title",
106 workspaceRoot: "/tmp/workspace",
107 defaultModelSelection: {
108 provider: "codex",
109 model: "gpt-5.4",
110 },
111 scripts: [],
112 createdAt: "2026-01-01T00:00:00.000Z",
113 updatedAt: "2026-01-01T00:00:00.000Z",
114 });
115 assert.strictEqual(parsed.defaultModelSelection?.provider, "codex");
116 }),
117);
118
119it.effect("decodes project.meta-updated payloads with explicit default provider", () =>
120 Effect.gen(function* () {
121 const parsed = yield* decodeProjectMetaUpdatedPayload({
122 projectId: "project-1",
123 defaultModelSelection: {
124 provider: "claudeAgent",
125 model: "claude-opus-4-6",
126 },
127 updatedAt: "2026-01-01T00:00:00.000Z",
128 });
129 assert.strictEqual(parsed.defaultModelSelection?.provider, "claudeAgent");
86130 }),
87131);
88132
......@@ -116,7 +160,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()
116160 },
117161 createdAt: "2026-01-01T00:00:00.000Z",
118162 });
119 assert.strictEqual(parsed.provider, undefined);
163 assert.strictEqual(parsed.modelSelection, undefined);
120164 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
121165 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
122166 }),
......@@ -134,11 +178,14 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
134178 text: "hello",
135179 attachments: [],
136180 },
137 provider: "codex",
181 modelSelection: {
182 provider: "codex",
183 model: "gpt-5.4",
184 },
138185 runtimeMode: "full-access",
139186 createdAt: "2026-01-01T00:00:00.000Z",
140187 });
141 assert.strictEqual(parsed.provider, "codex");
188 assert.strictEqual(parsed.modelSelection?.provider, "codex");
142189 assert.strictEqual(parsed.runtimeMode, "full-access");
143190 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
144191 }),
......@@ -150,7 +197,10 @@ it.effect("decodes thread.created runtime mode for historical events", () =>
150197 threadId: "thread-1",
151198 projectId: "project-1",
152199 title: "Thread title",
153 model: "gpt-5.4",
200 modelSelection: {
201 provider: "codex",
202 model: "gpt-5.4",
203 },
154204 interactionMode: "default",
155205 branch: null,
156206 worktreePath: null,
......@@ -159,6 +209,21 @@ it.effect("decodes thread.created runtime mode for historical events", () =>
159209 });
160210
161211 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
212 assert.strictEqual(parsed.modelSelection.provider, "codex");
213 }),
214);
215
216it.effect("decodes thread.meta-updated payloads with explicit provider", () =>
217 Effect.gen(function* () {
218 const parsed = yield* decodeThreadMetaUpdatedPayload({
219 threadId: "thread-1",
220 modelSelection: {
221 provider: "claudeAgent",
222 model: "claude-opus-4-6",
223 },
224 updatedAt: "2026-01-01T00:00:00.000Z",
225 });
226 assert.strictEqual(parsed.modelSelection?.provider, "claudeAgent");
162227 }),
163228);
164229
......@@ -174,19 +239,19 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>
174239 text: "hello",
175240 attachments: [],
176241 },
177 provider: "codex",
178 model: "gpt-5.3-codex",
179 modelOptions: {
180 codex: {
242 modelSelection: {
243 provider: "codex",
244 model: "gpt-5.3-codex",
245 options: {
181246 reasoningEffort: "high",
182247 fastMode: true,
183248 },
184249 },
185250 createdAt: "2026-01-01T00:00:00.000Z",
186251 });
187 assert.strictEqual(parsed.provider, "codex");
188 assert.strictEqual(parsed.modelOptions?.codex?.reasoningEffort, "high");
189 assert.strictEqual(parsed.modelOptions?.codex?.fastMode, true);
252 assert.strictEqual(parsed.modelSelection?.provider, "codex");
253 assert.strictEqual(parsed.modelSelection?.options?.reasoningEffort, "high");
254 assert.strictEqual(parsed.modelSelection?.options?.fastMode, true);
190255 }),
191256);
192257
......@@ -224,7 +289,7 @@ it.effect(
224289 messageId: "msg-1",
225290 createdAt: "2026-01-01T00:00:00.000Z",
226291 });
227 assert.strictEqual(parsed.provider, undefined);
292 assert.strictEqual(parsed.modelSelection, undefined);
228293 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
229294 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
230295 assert.strictEqual(parsed.sourceProposedPlan, undefined);
packages/contracts/src/orchestration.ts+31-20
......@@ -1,5 +1,5 @@
11import { Option, Schema, SchemaIssue, Struct } from "effect";
2import { ProviderModelOptions } from "./model";
2import { ClaudeModelOptions, CodexModelOptions } from "./model";
33import {
44 ApprovalRequestId,
55 CheckpointRef,
......@@ -44,6 +44,23 @@ export const ProviderSandboxMode = Schema.Literals([
4444export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
4545export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";
4646
47export const CodexModelSelection = Schema.Struct({
48 provider: Schema.Literal("codex"),
49 model: TrimmedNonEmptyString,
50 options: Schema.optional(CodexModelOptions),
51});
52export type CodexModelSelection = typeof CodexModelSelection.Type;
53
54export const ClaudeModelSelection = Schema.Struct({
55 provider: Schema.Literal("claudeAgent"),
56 model: TrimmedNonEmptyString,
57 options: Schema.optional(ClaudeModelOptions),
58});
59export type ClaudeModelSelection = typeof ClaudeModelSelection.Type;
60
61export const ModelSelection = Schema.Union([CodexModelSelection, ClaudeModelSelection]);
62export type ModelSelection = typeof ModelSelection.Type;
63
4764export const CodexProviderStartOptions = Schema.Struct({
4865 binaryPath: Schema.optional(TrimmedNonEmptyString),
4966 homePath: Schema.optional(TrimmedNonEmptyString),
......@@ -144,7 +161,7 @@ export const OrchestrationProject = Schema.Struct({
144161 id: ProjectId,
145162 title: TrimmedNonEmptyString,
146163 workspaceRoot: TrimmedNonEmptyString,
147 defaultModel: Schema.NullOr(TrimmedNonEmptyString),
164 defaultModelSelection: Schema.NullOr(ModelSelection),
148165 scripts: Schema.Array(ProjectScript),
149166 createdAt: IsoDateTime,
150167 updatedAt: IsoDateTime,
......@@ -273,7 +290,7 @@ export const OrchestrationThread = Schema.Struct({
273290 id: ThreadId,
274291 projectId: ProjectId,
275292 title: TrimmedNonEmptyString,
276 model: TrimmedNonEmptyString,
293 modelSelection: ModelSelection,
277294 runtimeMode: RuntimeMode,
278295 interactionMode: ProviderInteractionMode.pipe(
279296 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
......@@ -306,7 +323,7 @@ export const ProjectCreateCommand = Schema.Struct({
306323 projectId: ProjectId,
307324 title: TrimmedNonEmptyString,
308325 workspaceRoot: TrimmedNonEmptyString,
309 defaultModel: Schema.optional(TrimmedNonEmptyString),
326 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
310327 createdAt: IsoDateTime,
311328});
312329
......@@ -316,7 +333,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({
316333 projectId: ProjectId,
317334 title: Schema.optional(TrimmedNonEmptyString),
318335 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
319 defaultModel: Schema.optional(TrimmedNonEmptyString),
336 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
320337 scripts: Schema.optional(Schema.Array(ProjectScript)),
321338});
322339
......@@ -332,7 +349,7 @@ const ThreadCreateCommand = Schema.Struct({
332349 threadId: ThreadId,
333350 projectId: ProjectId,
334351 title: TrimmedNonEmptyString,
335 model: TrimmedNonEmptyString,
352 modelSelection: ModelSelection,
336353 runtimeMode: RuntimeMode,
337354 interactionMode: ProviderInteractionMode.pipe(
338355 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
......@@ -353,7 +370,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({
353370 commandId: CommandId,
354371 threadId: ThreadId,
355372 title: Schema.optional(TrimmedNonEmptyString),
356 model: Schema.optional(TrimmedNonEmptyString),
373 modelSelection: Schema.optional(ModelSelection),
357374 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
358375 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
359376});
......@@ -384,9 +401,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
384401 text: Schema.String,
385402 attachments: Schema.Array(ChatAttachment),
386403 }),
387 provider: Schema.optional(ProviderKind),
388 model: Schema.optional(TrimmedNonEmptyString),
389 modelOptions: Schema.optional(ProviderModelOptions),
404 modelSelection: Schema.optional(ModelSelection),
390405 providerOptions: Schema.optional(ProviderStartOptions),
391406 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
392407 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
......@@ -407,9 +422,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
407422 text: Schema.String,
408423 attachments: Schema.Array(UploadChatAttachment),
409424 }),
410 provider: Schema.optional(ProviderKind),
411 model: Schema.optional(TrimmedNonEmptyString),
412 modelOptions: Schema.optional(ProviderModelOptions),
425 modelSelection: Schema.optional(ModelSelection),
413426 providerOptions: Schema.optional(ProviderStartOptions),
414427 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
415428 runtimeMode: RuntimeMode,
......@@ -610,7 +623,7 @@ export const ProjectCreatedPayload = Schema.Struct({
610623 projectId: ProjectId,
611624 title: TrimmedNonEmptyString,
612625 workspaceRoot: TrimmedNonEmptyString,
613 defaultModel: Schema.NullOr(TrimmedNonEmptyString),
626 defaultModelSelection: Schema.NullOr(ModelSelection),
614627 scripts: Schema.Array(ProjectScript),
615628 createdAt: IsoDateTime,
616629 updatedAt: IsoDateTime,
......@@ -620,7 +633,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
620633 projectId: ProjectId,
621634 title: Schema.optional(TrimmedNonEmptyString),
622635 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
623 defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
636 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
624637 scripts: Schema.optional(Schema.Array(ProjectScript)),
625638 updatedAt: IsoDateTime,
626639});
......@@ -634,7 +647,7 @@ export const ThreadCreatedPayload = Schema.Struct({
634647 threadId: ThreadId,
635648 projectId: ProjectId,
636649 title: TrimmedNonEmptyString,
637 model: TrimmedNonEmptyString,
650 modelSelection: ModelSelection,
638651 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
639652 interactionMode: ProviderInteractionMode.pipe(
640653 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
......@@ -653,7 +666,7 @@ export const ThreadDeletedPayload = Schema.Struct({
653666export const ThreadMetaUpdatedPayload = Schema.Struct({
654667 threadId: ThreadId,
655668 title: Schema.optional(TrimmedNonEmptyString),
656 model: Schema.optional(TrimmedNonEmptyString),
669 modelSelection: Schema.optional(ModelSelection),
657670 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
658671 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
659672 updatedAt: IsoDateTime,
......@@ -688,9 +701,7 @@ export const ThreadMessageSentPayload = Schema.Struct({
688701export const ThreadTurnStartRequestedPayload = Schema.Struct({
689702 threadId: ThreadId,
690703 messageId: MessageId,
691 provider: Schema.optional(ProviderKind),
692 model: Schema.optional(TrimmedNonEmptyString),
693 modelOptions: Schema.optional(ProviderModelOptions),
704 modelSelection: Schema.optional(ModelSelection),
694705 providerOptions: Schema.optional(ProviderStartOptions),
695706 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
696707 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
packages/contracts/src/provider.test.ts+46-24
......@@ -12,9 +12,10 @@ describe("ProviderSessionStartInput", () => {
1212 threadId: "thread-1",
1313 provider: "codex",
1414 cwd: "/tmp/workspace",
15 model: "gpt-5.3-codex",
16 modelOptions: {
17 codex: {
15 modelSelection: {
16 provider: "codex",
17 model: "gpt-5.3-codex",
18 options: {
1819 reasoningEffort: "high",
1920 fastMode: true,
2021 },
......@@ -28,8 +29,13 @@ describe("ProviderSessionStartInput", () => {
2829 },
2930 });
3031 expect(parsed.runtimeMode).toBe("full-access");
31 expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("high");
32 expect(parsed.modelOptions?.codex?.fastMode).toBe(true);
32 expect(parsed.modelSelection?.provider).toBe("codex");
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);
3339 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");
3440 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");
3541 });
......@@ -48,9 +54,10 @@ describe("ProviderSessionStartInput", () => {
4854 threadId: "thread-1",
4955 provider: "claudeAgent",
5056 cwd: "/tmp/workspace",
51 model: "claude-sonnet-4-6",
52 modelOptions: {
53 claudeAgent: {
57 modelSelection: {
58 provider: "claudeAgent",
59 model: "claude-sonnet-4-6",
60 options: {
5461 thinking: true,
5562 effort: "max",
5663 fastMode: true,
......@@ -66,9 +73,14 @@ describe("ProviderSessionStartInput", () => {
6673 runtimeMode: "full-access",
6774 });
6875 expect(parsed.provider).toBe("claudeAgent");
69 expect(parsed.modelOptions?.claudeAgent?.thinking).toBe(true);
70 expect(parsed.modelOptions?.claudeAgent?.effort).toBe("max");
71 expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true);
76 expect(parsed.modelSelection?.provider).toBe("claudeAgent");
77 expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6");
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);
7284 expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude");
7385 expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan");
7486 expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000);
......@@ -77,36 +89,46 @@ describe("ProviderSessionStartInput", () => {
7789});
7890
7991describe("ProviderSendTurnInput", () => {
80 it("accepts provider-scoped model options", () => {
92 it("accepts codex modelSelection", () => {
8193 const parsed = decodeProviderSendTurnInput({
8294 threadId: "thread-1",
83 model: "gpt-5.3-codex",
84 modelOptions: {
85 codex: {
95 modelSelection: {
96 provider: "codex",
97 model: "gpt-5.3-codex",
98 options: {
8699 reasoningEffort: "xhigh",
87100 fastMode: true,
88101 },
89102 },
90103 });
91104
92 expect(parsed.model).toBe("gpt-5.3-codex");
93 expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh");
94 expect(parsed.modelOptions?.codex?.fastMode).toBe(true);
105 expect(parsed.modelSelection?.provider).toBe("codex");
106 expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex");
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);
95112 });
96113
97 it("accepts claude provider effort options including ultrathink", () => {
114 it("accepts claude modelSelection including ultrathink", () => {
98115 const parsed = decodeProviderSendTurnInput({
99116 threadId: "thread-1",
100 model: "claude-sonnet-4-6",
101 modelOptions: {
102 claudeAgent: {
117 modelSelection: {
118 provider: "claudeAgent",
119 model: "claude-sonnet-4-6",
120 options: {
103121 effort: "ultrathink",
104122 fastMode: true,
105123 },
106124 },
107125 });
108126
109 expect(parsed.modelOptions?.claudeAgent?.effort).toBe("ultrathink");
110 expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true);
127 expect(parsed.modelSelection?.provider).toBe("claudeAgent");
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);
111133 });
112134});
packages/contracts/src/provider.ts+3-5
......@@ -1,6 +1,5 @@
11import { Schema } from "effect";
22import { TrimmedNonEmptyString } from "./baseSchemas";
3import { ProviderModelOptions } from "./model";
43import {
54 ApprovalRequestId,
65 EventId,
......@@ -11,6 +10,7 @@ import {
1110} from "./baseSchemas";
1211import {
1312 ChatAttachment,
13 ModelSelection,
1414 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1515 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
1616 ProviderApprovalDecision,
......@@ -51,8 +51,7 @@ export const ProviderSessionStartInput = Schema.Struct({
5151 threadId: ThreadId,
5252 provider: Schema.optional(ProviderKind),
5353 cwd: Schema.optional(TrimmedNonEmptyString),
54 model: Schema.optional(TrimmedNonEmptyString),
55 modelOptions: Schema.optional(ProviderModelOptions),
54 modelSelection: Schema.optional(ModelSelection),
5655 resumeCursor: Schema.optional(Schema.Unknown),
5756 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
5857 sandboxMode: Schema.optional(ProviderSandboxMode),
......@@ -69,8 +68,7 @@ export const ProviderSendTurnInput = Schema.Struct({
6968 attachments: Schema.optional(
7069 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
7170 ),
72 model: Schema.optional(TrimmedNonEmptyString),
73 modelOptions: Schema.optional(ProviderModelOptions),
71 modelSelection: Schema.optional(ModelSelection),
7472 interactionMode: Schema.optional(ProviderInteractionMode),
7573});
7674export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
packages/shared/src/model.test.ts+68-90
......@@ -2,33 +2,25 @@ import { describe, expect, it } from "vitest";
22import {
33 DEFAULT_MODEL,
44 DEFAULT_MODEL_BY_PROVIDER,
5 DEFAULT_REASONING_EFFORT_BY_PROVIDER,
65 MODEL_OPTIONS,
76 MODEL_OPTIONS_BY_PROVIDER,
8 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
7 CODEX_REASONING_EFFORT_OPTIONS,
98} from "@t3tools/contracts";
109
1110import {
1211 applyClaudePromptEffortPrefix,
13 getEffectiveClaudeCodeEffort,
1412 getDefaultModel,
15 getDefaultReasoningEffort,
13 getModelCapabilities,
1614 getModelOptions,
17 getReasoningEffortOptions,
18 inferProviderForModel,
1915 isClaudeUltrathinkPrompt,
2016 normalizeClaudeModelOptions,
2117 normalizeCodexModelOptions,
2218 normalizeModelSlug,
23 resolveReasoningEffortForProvider,
2419 resolveSelectableModel,
2520 resolveModelSlug,
2621 resolveModelSlugForProvider,
27 supportsClaudeAdaptiveReasoning,
28 supportsClaudeFastMode,
29 supportsClaudeMaxEffort,
30 supportsClaudeThinkingToggle,
31 supportsClaudeUltrathinkKeyword,
22 getDefaultEffort,
23 hasEffortLevel,
3224} from "./model";
3325
3426describe("normalizeModelSlug", () => {
......@@ -159,13 +151,16 @@ describe("resolveSelectableModel", () => {
159151 });
160152});
161153
162describe("getReasoningEffortOptions", () => {
154describe("getModelCapabilities reasoningEffortLevels", () => {
155 const values = (provider: "codex" | "claudeAgent", model: string | null) =>
156 getModelCapabilities(provider, model).reasoningEffortLevels.map((l) => l.value);
157
163158 it("returns codex reasoning options for codex", () => {
164 expect(getReasoningEffortOptions("codex")).toEqual(REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex);
159 expect(values("codex", "gpt-5.4")).toEqual([...CODEX_REASONING_EFFORT_OPTIONS]);
165160 });
166161
167162 it("returns claude effort options for Opus 4.6", () => {
168 expect(getReasoningEffortOptions("claudeAgent", "claude-opus-4-6")).toEqual([
163 expect(values("claudeAgent", "claude-opus-4-6")).toEqual([
169164 "low",
170165 "medium",
171166 "high",
......@@ -175,7 +170,7 @@ describe("getReasoningEffortOptions", () => {
175170 });
176171
177172 it("returns claude effort options for Sonnet 4.6", () => {
178 expect(getReasoningEffortOptions("claudeAgent", "claude-sonnet-4-6")).toEqual([
173 expect(values("claudeAgent", "claude-sonnet-4-6")).toEqual([
179174 "low",
180175 "medium",
181176 "high",
......@@ -184,45 +179,37 @@ describe("getReasoningEffortOptions", () => {
184179 });
185180
186181 it("returns no claude effort options for Haiku 4.5", () => {
187 expect(getReasoningEffortOptions("claudeAgent", "claude-haiku-4-5")).toEqual([]);
188 });
189});
190
191describe("inferProviderForModel", () => {
192 it("detects known provider model slugs", () => {
193 expect(inferProviderForModel("gpt-5.3-codex")).toBe("codex");
194 expect(inferProviderForModel("claude-sonnet-4-6")).toBe("claudeAgent");
195 expect(inferProviderForModel("sonnet")).toBe("claudeAgent");
196 });
197
198 it("falls back when the model is unknown", () => {
199 expect(inferProviderForModel("custom/internal-model")).toBe("codex");
200 expect(inferProviderForModel("custom/internal-model", "claudeAgent")).toBe("claudeAgent");
182 expect(values("claudeAgent", "claude-haiku-4-5")).toEqual([]);
201183 });
202184
203 it("treats claude-prefixed custom slugs as claude", () => {
204 expect(inferProviderForModel("claude-custom-internal")).toBe("claudeAgent");
185 it("co-locates labels with effort values", () => {
186 const levels = getModelCapabilities("claudeAgent", "claude-opus-4-6").reasoningEffortLevels;
187 const high = levels.find((l) => l.value === "high");
188 expect(high).toEqual({ value: "high", label: "High", isDefault: true });
189 const xhigh = getModelCapabilities("codex", "gpt-5.4").reasoningEffortLevels.find(
190 (l) => l.value === "xhigh",
191 );
192 expect(xhigh).toEqual({ value: "xhigh", label: "Extra High" });
205193 });
206194});
207195
208describe("getDefaultReasoningEffort", () => {
209 it("returns provider-scoped defaults", () => {
210 expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex);
211 expect(getDefaultReasoningEffort("claudeAgent")).toBe(
212 DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent,
213 );
196describe("getDefaultEffort", () => {
197 it("returns the default effort from capabilities", () => {
198 expect(getDefaultEffort(getModelCapabilities("codex", "gpt-5.4"))).toBe("high");
199 expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe("high");
200 expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBeNull();
214201 });
215202});
216203
217describe("resolveReasoningEffortForProvider", () => {
218 it("accepts provider-scoped effort values", () => {
219 expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh");
220 expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink");
221 });
204describe("hasEffortLevel", () => {
205 it("validates effort against model capabilities", () => {
206 const opusCaps = getModelCapabilities("claudeAgent", "claude-opus-4-6");
207 expect(hasEffortLevel(opusCaps, "max")).toBe(true);
208 expect(hasEffortLevel(opusCaps, "xhigh")).toBe(false);
222209
223 it("rejects effort values from the wrong provider", () => {
224 expect(resolveReasoningEffortForProvider("codex", "max")).toBeNull();
225 expect(resolveReasoningEffortForProvider("claudeAgent", "xhigh")).toBeNull();
210 const codexCaps = getModelCapabilities("codex", "gpt-5.4");
211 expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true);
212 expect(hasEffortLevel(codexCaps, "max")).toBe(false);
226213 });
227214});
228215
......@@ -241,27 +228,17 @@ describe("applyClaudePromptEffortPrefix", () => {
241228 });
242229});
243230
244describe("getEffectiveClaudeCodeEffort", () => {
245 it("does not persist ultrathink into Claude runtime configuration", () => {
246 expect(getEffectiveClaudeCodeEffort("ultrathink")).toBeNull();
247 expect(getEffectiveClaudeCodeEffort("high")).toBe("high");
248 });
249
250 it("returns null when no claude effort is selected", () => {
251 expect(getEffectiveClaudeCodeEffort(null)).toBeNull();
252 expect(getEffectiveClaudeCodeEffort(undefined)).toBeNull();
253 });
254});
255
256231describe("normalizeCodexModelOptions", () => {
257232 it("drops default-only codex options", () => {
258233 expect(
259 normalizeCodexModelOptions({ reasoningEffort: "high", fastMode: false }),
234 normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "high", fastMode: false }),
260235 ).toBeUndefined();
261236 });
262237
263238 it("preserves non-default codex options", () => {
264 expect(normalizeCodexModelOptions({ reasoningEffort: "xhigh", fastMode: true })).toEqual({
239 expect(
240 normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "xhigh", fastMode: true }),
241 ).toEqual({
265242 reasoningEffort: "xhigh",
266243 fastMode: true,
267244 });
......@@ -290,49 +267,50 @@ describe("normalizeClaudeModelOptions", () => {
290267 });
291268});
292269
293describe("supportsClaudeAdaptiveReasoning", () => {
270describe("getModelCapabilities Claude capability flags", () => {
294271 it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => {
295 expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true);
296 expect(supportsClaudeAdaptiveReasoning("claude-sonnet-4-6")).toBe(true);
297 expect(supportsClaudeAdaptiveReasoning("claude-haiku-4-5")).toBe(false);
298 expect(supportsClaudeAdaptiveReasoning(undefined)).toBe(false);
272 const has = (m: string | undefined) =>
273 getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0;
274 expect(has("claude-opus-4-6")).toBe(true);
275 expect(has("claude-sonnet-4-6")).toBe(true);
276 expect(has("claude-haiku-4-5")).toBe(false);
277 expect(has(undefined)).toBe(false);
299278 });
300});
301279
302describe("supportsClaudeMaxEffort", () => {
303280 it("only enables max effort for Opus 4.6", () => {
304 expect(supportsClaudeMaxEffort("claude-opus-4-6")).toBe(true);
305 expect(supportsClaudeMaxEffort("claude-sonnet-4-6")).toBe(false);
306 expect(supportsClaudeMaxEffort("claude-haiku-4-5")).toBe(false);
307 expect(supportsClaudeMaxEffort(undefined)).toBe(false);
281 const has = (m: string | undefined) =>
282 getModelCapabilities("claudeAgent", m).reasoningEffortLevels.some((l) => l.value === "max");
283 expect(has("claude-opus-4-6")).toBe(true);
284 expect(has("claude-sonnet-4-6")).toBe(false);
285 expect(has("claude-haiku-4-5")).toBe(false);
286 expect(has(undefined)).toBe(false);
308287 });
309});
310288
311describe("supportsClaudeFastMode", () => {
312289 it("only enables Claude fast mode for Opus 4.6", () => {
313 expect(supportsClaudeFastMode("claude-opus-4-6")).toBe(true);
314 expect(supportsClaudeFastMode("opus")).toBe(true);
315 expect(supportsClaudeFastMode("claude-sonnet-4-6")).toBe(false);
316 expect(supportsClaudeFastMode("claude-haiku-4-5")).toBe(false);
317 expect(supportsClaudeFastMode(undefined)).toBe(false);
290 const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).supportsFastMode;
291 expect(has("claude-opus-4-6")).toBe(true);
292 expect(has("opus")).toBe(true);
293 expect(has("claude-sonnet-4-6")).toBe(false);
294 expect(has("claude-haiku-4-5")).toBe(false);
295 expect(has(undefined)).toBe(false);
318296 });
319});
320297
321describe("supportsClaudeUltrathinkKeyword", () => {
322298 it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => {
323 expect(supportsClaudeUltrathinkKeyword("claude-opus-4-6")).toBe(true);
324 expect(supportsClaudeUltrathinkKeyword("claude-sonnet-4-6")).toBe(true);
325 expect(supportsClaudeUltrathinkKeyword("claude-haiku-4-5")).toBe(false);
299 const has = (m: string | undefined) =>
300 getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0;
301 expect(has("claude-opus-4-6")).toBe(true);
302 expect(has("claude-sonnet-4-6")).toBe(true);
303 expect(has("claude-haiku-4-5")).toBe(false);
326304 });
327});
328305
329describe("supportsClaudeThinkingToggle", () => {
330306 it("only enables the Claude thinking toggle for Haiku 4.5", () => {
331 expect(supportsClaudeThinkingToggle("claude-opus-4-6")).toBe(false);
332 expect(supportsClaudeThinkingToggle("claude-sonnet-4-6")).toBe(false);
333 expect(supportsClaudeThinkingToggle("claude-haiku-4-5")).toBe(true);
334 expect(supportsClaudeThinkingToggle("haiku")).toBe(true);
335 expect(supportsClaudeThinkingToggle(undefined)).toBe(false);
307 const has = (m: string | undefined) =>
308 getModelCapabilities("claudeAgent", m).supportsThinkingToggle;
309 expect(has("claude-opus-4-6")).toBe(false);
310 expect(has("claude-sonnet-4-6")).toBe(false);
311 expect(has("claude-haiku-4-5")).toBe(true);
312 expect(has("haiku")).toBe(true);
313 expect(has(undefined)).toBe(false);
336314 });
337315});
338316
packages/shared/src/model.ts+42-124
......@@ -1,18 +1,15 @@
11import {
2 CLAUDE_CODE_EFFORT_OPTIONS,
3 CODEX_REASONING_EFFORT_OPTIONS,
42 DEFAULT_MODEL_BY_PROVIDER,
5 DEFAULT_REASONING_EFFORT_BY_PROVIDER,
3 MODEL_CAPABILITIES_INDEX,
64 MODEL_OPTIONS_BY_PROVIDER,
75 MODEL_SLUG_ALIASES_BY_PROVIDER,
8 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
96 type ClaudeModelOptions,
107 type ClaudeCodeEffort,
118 type CodexModelOptions,
12 type CodexReasoningEffort,
9 type ModelCapabilities,
1310 type ModelSlug,
14 type ProviderReasoningEffort,
1511 type ProviderKind,
12 CodexReasoningEffort,
1613} from "@t3tools/contracts";
1714
1815const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = {
......@@ -20,10 +17,6 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> =
2017 codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)),
2118};
2219
23const CLAUDE_OPUS_4_6_MODEL = "claude-opus-4-6";
24const CLAUDE_SONNET_4_6_MODEL = "claude-sonnet-4-6";
25const CLAUDE_HAIKU_4_5_MODEL = "claude-haiku-4-5";
26
2720export interface SelectableModelOption {
2821 slug: string;
2922 name: string;
......@@ -37,25 +30,34 @@ export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
3730 return DEFAULT_MODEL_BY_PROVIDER[provider];
3831}
3932
40export function supportsClaudeFastMode(model: string | null | undefined): boolean {
41 return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL;
42}
33// ── Effort helpers ────────────────────────────────────────────────────
4334
44export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean {
45 const normalized = normalizeModelSlug(model, "claudeAgent");
46 return normalized === CLAUDE_OPUS_4_6_MODEL || normalized === CLAUDE_SONNET_4_6_MODEL;
35/** Check whether a capabilities object includes a given effort value. */
36export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean {
37 return caps.reasoningEffortLevels.some((l) => l.value === value);
4738}
4839
49export function supportsClaudeMaxEffort(model: string | null | undefined): boolean {
50 return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL;
40/** Return the default effort value for a capabilities object, or null if none. */
41export function getDefaultEffort(caps: ModelCapabilities): string | null {
42 return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null;
5143}
5244
53export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean {
54 return supportsClaudeAdaptiveReasoning(model);
55}
45// ── Data-driven capability resolver ───────────────────────────────────
5646
57export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean {
58 return normalizeModelSlug(model, "claudeAgent") === CLAUDE_HAIKU_4_5_MODEL;
47export function getModelCapabilities(
48 provider: ProviderKind,
49 model: string | null | undefined,
50): ModelCapabilities {
51 const slug = normalizeModelSlug(model, provider);
52 if (slug && MODEL_CAPABILITIES_INDEX[provider]?.[slug]) {
53 return MODEL_CAPABILITIES_INDEX[provider][slug];
54 }
55 return {
56 reasoningEffortLevels: [],
57 supportsFastMode: false,
58 supportsThinkingToggle: false,
59 promptInjectedEffortLevels: [],
60 };
5961}
6062
6163export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
......@@ -136,102 +138,20 @@ export function resolveModelSlugForProvider(
136138 return resolveModelSlug(model, provider);
137139}
138140
139export function inferProviderForModel(
140 model: string | null | undefined,
141 fallback: ProviderKind = "codex",
142): ProviderKind {
143 const normalizedClaude = normalizeModelSlug(model, "claudeAgent");
144 if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) {
145 return "claudeAgent";
146 }
147
148 const normalizedCodex = normalizeModelSlug(model, "codex");
149 if (normalizedCodex && MODEL_SLUG_SET_BY_PROVIDER.codex.has(normalizedCodex)) {
150 return "codex";
151 }
152
153 return typeof model === "string" && model.trim().startsWith("claude-") ? "claudeAgent" : fallback;
154}
155
156export function getReasoningEffortOptions(provider: "codex"): ReadonlyArray<CodexReasoningEffort>;
157export function getReasoningEffortOptions(
158 provider: "claudeAgent",
159 model?: string | null | undefined,
160): ReadonlyArray<ClaudeCodeEffort>;
161export function getReasoningEffortOptions(
162 provider?: ProviderKind,
163 model?: string | null | undefined,
164): ReadonlyArray<ProviderReasoningEffort>;
165export function getReasoningEffortOptions(
166 provider: ProviderKind = "codex",
167 model?: string | null | undefined,
168): ReadonlyArray<ProviderReasoningEffort> {
169 if (provider === "claudeAgent") {
170 if (supportsClaudeMaxEffort(model)) {
171 return ["low", "medium", "high", "max", "ultrathink"];
172 }
173 if (supportsClaudeAdaptiveReasoning(model)) {
174 return ["low", "medium", "high", "ultrathink"];
175 }
176 return [];
177 }
178 return REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider];
179}
180
181export function getDefaultReasoningEffort(provider: "codex"): CodexReasoningEffort;
182export function getDefaultReasoningEffort(provider: "claudeAgent"): ClaudeCodeEffort;
183export function getDefaultReasoningEffort(provider?: ProviderKind): ProviderReasoningEffort;
184export function getDefaultReasoningEffort(
185 provider: ProviderKind = "codex",
186): ProviderReasoningEffort {
187 return DEFAULT_REASONING_EFFORT_BY_PROVIDER[provider];
188}
189
190export function resolveReasoningEffortForProvider(
191 provider: "codex",
192 effort: string | null | undefined,
193): CodexReasoningEffort | null;
194export function resolveReasoningEffortForProvider(
195 provider: "claudeAgent",
196 effort: string | null | undefined,
197): ClaudeCodeEffort | null;
198export function resolveReasoningEffortForProvider(
199 provider: ProviderKind,
200 effort: string | null | undefined,
201): ProviderReasoningEffort | null;
202export function resolveReasoningEffortForProvider(
203 provider: ProviderKind,
204 effort: string | null | undefined,
205): ProviderReasoningEffort | null {
206 if (typeof effort !== "string") {
207 return null;
208 }
209
210 const trimmed = effort.trim();
211 if (!trimmed) {
212 return null;
213 }
214
215 const options = REASONING_EFFORT_OPTIONS_BY_PROVIDER[provider] as ReadonlyArray<string>;
216 return options.includes(trimmed) ? (trimmed as ProviderReasoningEffort) : null;
217}
218
219export function getEffectiveClaudeCodeEffort(
220 effort: ClaudeCodeEffort | null | undefined,
221): Exclude<ClaudeCodeEffort, "ultrathink"> | null {
222 if (!effort) {
223 return null;
224 }
225 return effort === "ultrathink" ? null : effort;
141/** Trim a string, returning null for empty/missing values. */
142export function trimOrNull<T extends string>(value: T | null | undefined): T | null {
143 if (typeof value !== "string") return null;
144 const trimmed = value.trim() as T;
145 return trimmed || null;
226146}
227147
228148export function normalizeCodexModelOptions(
149 model: string | null | undefined,
229150 modelOptions: CodexModelOptions | null | undefined,
230151): CodexModelOptions | undefined {
231 const defaultReasoningEffort = getDefaultReasoningEffort("codex");
232 const reasoningEffort =
233 resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ??
234 defaultReasoningEffort;
152 const caps = getModelCapabilities("codex", model);
153 const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort;
154 const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort;
235155 const fastModeEnabled = modelOptions?.fastMode === true;
236156 const nextOptions: CodexModelOptions = {
237157 ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}),
......@@ -244,20 +164,20 @@ export function normalizeClaudeModelOptions(
244164 model: string | null | undefined,
245165 modelOptions: ClaudeModelOptions | null | undefined,
246166): ClaudeModelOptions | undefined {
247 const reasoningOptions = getReasoningEffortOptions("claudeAgent", model);
248 const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent");
249 const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort);
167 const caps = getModelCapabilities("claudeAgent", model);
168 const defaultReasoningEffort = getDefaultEffort(caps);
169 const resolvedEffort = trimOrNull(modelOptions?.effort);
170 const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? "");
250171 const effort =
251172 resolvedEffort &&
252 resolvedEffort !== "ultrathink" &&
253 reasoningOptions.includes(resolvedEffort) &&
173 !isPromptInjected &&
174 hasEffortLevel(caps, resolvedEffort) &&
254175 resolvedEffort !== defaultReasoningEffort
255176 ? resolvedEffort
256177 : undefined;
257178 const thinking =
258 supportsClaudeThinkingToggle(model) && modelOptions?.thinking === false ? false : undefined;
259 const fastMode =
260 supportsClaudeFastMode(model) && modelOptions?.fastMode === true ? true : undefined;
179 caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;
180 const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;
261181 const nextOptions: ClaudeModelOptions = {
262182 ...(thinking === false ? { thinking: false } : {}),
263183 ...(effort ? { effort } : {}),
......@@ -282,5 +202,3 @@ export function applyClaudePromptEffortPrefix(
282202 }
283203 return `Ultrathink:\n${trimmed}`;
284204}
285
286export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS };