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 {...@@ -11,6 +11,7 @@ import {
11 ProjectId,11 ProjectId,
12 ProviderKind,12 ProviderKind,
13 ThreadId,13 ThreadId,
14 ModelSelection,
14} from "@t3tools/contracts";15} from "@t3tools/contracts";
15import { assert, it } from "@effect/vitest";16import { assert, it } from "@effect/vitest";
16import { Effect, Option, Schema } from "effect";17import { Effect, Option, Schema } from "effect";
...@@ -115,7 +116,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>...@@ -115,7 +116,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
115 projectId: PROJECT_ID,116 projectId: PROJECT_ID,
116 title: "Integration Project",117 title: "Integration Project",
117 workspaceRoot: harness.workspaceDir,118 workspaceRoot: harness.workspaceDir,
118 defaultModel,119 defaultModelSelection: {
120 provider,
121 model: defaultModel,
122 },
119 createdAt,123 createdAt,
120 });124 });
121125
...@@ -125,7 +129,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>...@@ -125,7 +129,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) =>
125 threadId: THREAD_ID,129 threadId: THREAD_ID,
126 projectId: PROJECT_ID,130 projectId: PROJECT_ID,
127 title: "Integration Thread",131 title: "Integration Thread",
128 model: defaultModel,132 modelSelection: {
133 provider,
134 model: defaultModel,
135 },
129 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,136 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
130 runtimeMode: "approval-required",137 runtimeMode: "approval-required",
131 branch: null,138 branch: null,
...@@ -139,7 +146,7 @@ const startTurn = (input: {...@@ -139,7 +146,7 @@ const startTurn = (input: {
139 readonly commandId: string;146 readonly commandId: string;
140 readonly messageId: string;147 readonly messageId: string;
141 readonly text: string;148 readonly text: string;
142 readonly provider?: IntegrationProvider;149 readonly modelSelection?: ModelSelection;
143}) =>150}) =>
144 input.harness.engine.dispatch({151 input.harness.engine.dispatch({
145 type: "thread.turn.start",152 type: "thread.turn.start",
...@@ -151,7 +158,11 @@ const startTurn = (input: {...@@ -151,7 +158,11 @@ const startTurn = (input: {
151 text: input.text,158 text: input.text,
152 attachments: [],159 attachments: [],
153 },160 },
154 ...(input.provider !== undefined ? { provider: input.provider } : {}),161 ...(input.modelSelection !== undefined
162 ? {
163 modelSelection: input.modelSelection,
164 }
165 : {}),
155 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,166 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
156 runtimeMode: "approval-required",167 runtimeMode: "approval-required",
157 createdAt: nowIso(),168 createdAt: nowIso(),
...@@ -254,7 +265,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(...@@ -254,7 +265,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
254 projectId: PROJECT_ID,265 projectId: PROJECT_ID,
255 title: "Integration Project",266 title: "Integration Project",
256 workspaceRoot: harness.workspaceDir,267 workspaceRoot: harness.workspaceDir,
257 defaultModel: "gpt-5.3-codex",268 defaultModelSelection: {
269 provider: "codex",
270 model: "gpt-5.3-codex",
271 },
258 createdAt,272 createdAt,
259 });273 });
260274
...@@ -264,7 +278,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(...@@ -264,7 +278,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)(
264 threadId: THREAD_ID,278 threadId: THREAD_ID,
265 projectId: PROJECT_ID,279 projectId: PROJECT_ID,
266 title: "Integration Thread",280 title: "Integration Thread",
267 model: "gpt-5.3-codex",281 modelSelection: {
282 provider: "codex",
283 model: "gpt-5.3-codex",
284 },
268 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,285 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
269 runtimeMode: "full-access",286 runtimeMode: "full-access",
270 branch: null,287 branch: null,
...@@ -922,7 +939,10 @@ it.live("starts a claudeAgent session on first turn when provider is requested",...@@ -922,7 +939,10 @@ it.live("starts a claudeAgent session on first turn when provider is requested",
922 commandId: "cmd-turn-start-claude-initial",939 commandId: "cmd-turn-start-claude-initial",
923 messageId: "msg-user-claude-initial",940 messageId: "msg-user-claude-initial",
924 text: "Use Claude",941 text: "Use Claude",
925 provider: "claudeAgent",942 modelSelection: {
943 provider: "claudeAgent",
944 model: "claude-sonnet-4-6",
945 },
926 });946 });
927947
928 const thread = yield* harness.waitForThread(948 const thread = yield* harness.waitForThread(
...@@ -976,7 +996,10 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re...@@ -976,7 +996,10 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re
976 commandId: "cmd-turn-start-claude-recover-1",996 commandId: "cmd-turn-start-claude-recover-1",
977 messageId: "msg-user-claude-recover-1",997 messageId: "msg-user-claude-recover-1",
978 text: "Before restart",998 text: "Before restart",
979 provider: "claudeAgent",999 modelSelection: {
1000 provider: "claudeAgent",
1001 model: "claude-sonnet-4-6",
1002 },
980 });1003 });
9811004
982 yield* harness.waitForThread(1005 yield* harness.waitForThread(
...@@ -1083,7 +1106,10 @@ it.live("forwards claudeAgent approval responses to the provider session", () =>...@@ -1083,7 +1106,10 @@ it.live("forwards claudeAgent approval responses to the provider session", () =>
1083 commandId: "cmd-turn-start-claude-approval",1106 commandId: "cmd-turn-start-claude-approval",
1084 messageId: "msg-user-claude-approval",1107 messageId: "msg-user-claude-approval",
1085 text: "Need approval",1108 text: "Need approval",
1086 provider: "claudeAgent",1109 modelSelection: {
1110 provider: "claudeAgent",
1111 model: "claude-sonnet-4-6",
1112 },
1087 });1113 });
10881114
1089 const thread = yield* harness.waitForThread(THREAD_ID, (entry) =>1115 const thread = yield* harness.waitForThread(THREAD_ID, (entry) =>
...@@ -1152,7 +1178,10 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () =>...@@ -1152,7 +1178,10 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () =>
1152 commandId: "cmd-turn-start-claude-interrupt",1178 commandId: "cmd-turn-start-claude-interrupt",
1153 messageId: "msg-user-claude-interrupt",1179 messageId: "msg-user-claude-interrupt",
1154 text: "Start long turn",1180 text: "Start long turn",
1155 provider: "claudeAgent",1181 modelSelection: {
1182 provider: "claudeAgent",
1183 model: "claude-sonnet-4-6",
1184 },
1156 });1185 });
11571186
1158 const thread = yield* harness.waitForThread(1187 const thread = yield* harness.waitForThread(
...@@ -1222,7 +1251,10 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state",...@@ -1222,7 +1251,10 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state",
1222 commandId: "cmd-turn-start-claude-revert-1",1251 commandId: "cmd-turn-start-claude-revert-1",
1223 messageId: "msg-user-claude-revert-1",1252 messageId: "msg-user-claude-revert-1",
1224 text: "First Claude edit",1253 text: "First Claude edit",
1225 provider: "claudeAgent",1254 modelSelection: {
1255 provider: "claudeAgent",
1256 model: "claude-sonnet-4-6",
1257 },
1226 });1258 });
12271259
1228 yield* harness.waitForThread(1260 yield* harness.waitForThread(
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+5-2
...@@ -31,7 +31,7 @@ function makeSnapshot(input: {...@@ -31,7 +31,7 @@ function makeSnapshot(input: {
31 id: input.projectId,31 id: input.projectId,
32 title: "Project",32 title: "Project",
33 workspaceRoot: input.workspaceRoot,33 workspaceRoot: input.workspaceRoot,
34 defaultModel: null,34 defaultModelSelection: null,
35 scripts: [],35 scripts: [],
36 createdAt: "2026-01-01T00:00:00.000Z",36 createdAt: "2026-01-01T00:00:00.000Z",
37 updatedAt: "2026-01-01T00:00:00.000Z",37 updatedAt: "2026-01-01T00:00:00.000Z",
...@@ -43,7 +43,10 @@ function makeSnapshot(input: {...@@ -43,7 +43,10 @@ function makeSnapshot(input: {
43 id: input.threadId,43 id: input.threadId,
44 projectId: input.projectId,44 projectId: input.projectId,
45 title: "Thread",45 title: "Thread",
46 model: "gpt-5-codex",46 modelSelection: {
47 provider: "codex",
48 model: "gpt-5-codex",
49 },
47 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,50 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
48 runtimeMode: "full-access",51 runtimeMode: "full-access",
49 branch: null,52 branch: null,
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+8-2
...@@ -282,7 +282,10 @@ describe("CheckpointReactor", () => {...@@ -282,7 +282,10 @@ describe("CheckpointReactor", () => {
282 projectId: asProjectId("project-1"),282 projectId: asProjectId("project-1"),
283 title: "Test Project",283 title: "Test Project",
284 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,284 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,
285 defaultModel: "gpt-5-codex",285 defaultModelSelection: {
286 provider: "codex",
287 model: "gpt-5-codex",
288 },
286 createdAt,289 createdAt,
287 }),290 }),
288 );291 );
...@@ -293,7 +296,10 @@ describe("CheckpointReactor", () => {...@@ -293,7 +296,10 @@ describe("CheckpointReactor", () => {
293 threadId: ThreadId.makeUnsafe("thread-1"),296 threadId: ThreadId.makeUnsafe("thread-1"),
294 projectId: asProjectId("project-1"),297 projectId: asProjectId("project-1"),
295 title: "Thread",298 title: "Thread",
296 model: "gpt-5-codex",299 modelSelection: {
300 provider: "codex",
301 model: "gpt-5-codex",
302 },
297 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,303 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
298 runtimeMode: "approval-required",304 runtimeMode: "approval-required",
299 branch: null,305 branch: null,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+72-18
...@@ -72,7 +72,10 @@ describe("OrchestrationEngine", () => {...@@ -72,7 +72,10 @@ describe("OrchestrationEngine", () => {
72 projectId: asProjectId("project-1"),72 projectId: asProjectId("project-1"),
73 title: "Project 1",73 title: "Project 1",
74 workspaceRoot: "/tmp/project-1",74 workspaceRoot: "/tmp/project-1",
75 defaultModel: "gpt-5-codex",75 defaultModelSelection: {
76 provider: "codex",
77 model: "gpt-5-codex",
78 },
76 createdAt,79 createdAt,
77 }),80 }),
78 );81 );
...@@ -83,7 +86,10 @@ describe("OrchestrationEngine", () => {...@@ -83,7 +86,10 @@ describe("OrchestrationEngine", () => {
83 threadId: ThreadId.makeUnsafe("thread-1"),86 threadId: ThreadId.makeUnsafe("thread-1"),
84 projectId: asProjectId("project-1"),87 projectId: asProjectId("project-1"),
85 title: "Thread",88 title: "Thread",
86 model: "gpt-5-codex",89 modelSelection: {
90 provider: "codex",
91 model: "gpt-5-codex",
92 },
87 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,93 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
88 runtimeMode: "approval-required",94 runtimeMode: "approval-required",
89 branch: null,95 branch: null,
...@@ -126,7 +132,10 @@ describe("OrchestrationEngine", () => {...@@ -126,7 +132,10 @@ describe("OrchestrationEngine", () => {
126 projectId: asProjectId("project-replay"),132 projectId: asProjectId("project-replay"),
127 title: "Replay Project",133 title: "Replay Project",
128 workspaceRoot: "/tmp/project-replay",134 workspaceRoot: "/tmp/project-replay",
129 defaultModel: "gpt-5-codex",135 defaultModelSelection: {
136 provider: "codex",
137 model: "gpt-5-codex",
138 },
130 createdAt,139 createdAt,
131 }),140 }),
132 );141 );
...@@ -137,7 +146,10 @@ describe("OrchestrationEngine", () => {...@@ -137,7 +146,10 @@ describe("OrchestrationEngine", () => {
137 threadId: ThreadId.makeUnsafe("thread-replay"),146 threadId: ThreadId.makeUnsafe("thread-replay"),
138 projectId: asProjectId("project-replay"),147 projectId: asProjectId("project-replay"),
139 title: "replay",148 title: "replay",
140 model: "gpt-5-codex",149 modelSelection: {
150 provider: "codex",
151 model: "gpt-5-codex",
152 },
141 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,153 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
142 runtimeMode: "approval-required",154 runtimeMode: "approval-required",
143 branch: null,155 branch: null,
...@@ -178,7 +190,10 @@ describe("OrchestrationEngine", () => {...@@ -178,7 +190,10 @@ describe("OrchestrationEngine", () => {
178 projectId: asProjectId("project-stream"),190 projectId: asProjectId("project-stream"),
179 title: "Stream Project",191 title: "Stream Project",
180 workspaceRoot: "/tmp/project-stream",192 workspaceRoot: "/tmp/project-stream",
181 defaultModel: "gpt-5-codex",193 defaultModelSelection: {
194 provider: "codex",
195 model: "gpt-5-codex",
196 },
182 createdAt,197 createdAt,
183 }),198 }),
184 );199 );
...@@ -199,7 +214,10 @@ describe("OrchestrationEngine", () => {...@@ -199,7 +214,10 @@ describe("OrchestrationEngine", () => {
199 threadId: ThreadId.makeUnsafe("thread-stream"),214 threadId: ThreadId.makeUnsafe("thread-stream"),
200 projectId: asProjectId("project-stream"),215 projectId: asProjectId("project-stream"),
201 title: "domain-stream",216 title: "domain-stream",
202 model: "gpt-5-codex",217 modelSelection: {
218 provider: "codex",
219 model: "gpt-5-codex",
220 },
203 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,221 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
204 runtimeMode: "approval-required",222 runtimeMode: "approval-required",
205 branch: null,223 branch: null,
...@@ -233,7 +251,10 @@ describe("OrchestrationEngine", () => {...@@ -233,7 +251,10 @@ describe("OrchestrationEngine", () => {
233 projectId: asProjectId("project-turn-diff"),251 projectId: asProjectId("project-turn-diff"),
234 title: "Turn Diff Project",252 title: "Turn Diff Project",
235 workspaceRoot: "/tmp/project-turn-diff",253 workspaceRoot: "/tmp/project-turn-diff",
236 defaultModel: "gpt-5-codex",254 defaultModelSelection: {
255 provider: "codex",
256 model: "gpt-5-codex",
257 },
237 createdAt,258 createdAt,
238 }),259 }),
239 );260 );
...@@ -244,7 +265,10 @@ describe("OrchestrationEngine", () => {...@@ -244,7 +265,10 @@ describe("OrchestrationEngine", () => {
244 threadId: ThreadId.makeUnsafe("thread-turn-diff"),265 threadId: ThreadId.makeUnsafe("thread-turn-diff"),
245 projectId: asProjectId("project-turn-diff"),266 projectId: asProjectId("project-turn-diff"),
246 title: "Turn diff thread",267 title: "Turn diff thread",
247 model: "gpt-5-codex",268 modelSelection: {
269 provider: "codex",
270 model: "gpt-5-codex",
271 },
248 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,272 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
249 runtimeMode: "approval-required",273 runtimeMode: "approval-required",
250 branch: null,274 branch: null,
...@@ -344,7 +368,10 @@ describe("OrchestrationEngine", () => {...@@ -344,7 +368,10 @@ describe("OrchestrationEngine", () => {
344 projectId: asProjectId("project-flaky"),368 projectId: asProjectId("project-flaky"),
345 title: "Flaky Project",369 title: "Flaky Project",
346 workspaceRoot: "/tmp/project-flaky",370 workspaceRoot: "/tmp/project-flaky",
347 defaultModel: "gpt-5-codex",371 defaultModelSelection: {
372 provider: "codex",
373 model: "gpt-5-codex",
374 },
348 createdAt,375 createdAt,
349 }),376 }),
350 );377 );
...@@ -357,7 +384,10 @@ describe("OrchestrationEngine", () => {...@@ -357,7 +384,10 @@ describe("OrchestrationEngine", () => {
357 threadId: ThreadId.makeUnsafe("thread-flaky-fail"),384 threadId: ThreadId.makeUnsafe("thread-flaky-fail"),
358 projectId: asProjectId("project-flaky"),385 projectId: asProjectId("project-flaky"),
359 title: "flaky-fail",386 title: "flaky-fail",
360 model: "gpt-5-codex",387 modelSelection: {
388 provider: "codex",
389 model: "gpt-5-codex",
390 },
361 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,391 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
362 runtimeMode: "approval-required",392 runtimeMode: "approval-required",
363 branch: null,393 branch: null,
...@@ -374,7 +404,10 @@ describe("OrchestrationEngine", () => {...@@ -374,7 +404,10 @@ describe("OrchestrationEngine", () => {
374 threadId: ThreadId.makeUnsafe("thread-flaky-ok"),404 threadId: ThreadId.makeUnsafe("thread-flaky-ok"),
375 projectId: asProjectId("project-flaky"),405 projectId: asProjectId("project-flaky"),
376 title: "flaky-ok",406 title: "flaky-ok",
377 model: "gpt-5-codex",407 modelSelection: {
408 provider: "codex",
409 model: "gpt-5-codex",
410 },
378 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,411 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
379 runtimeMode: "approval-required",412 runtimeMode: "approval-required",
380 branch: null,413 branch: null,
...@@ -428,7 +461,10 @@ describe("OrchestrationEngine", () => {...@@ -428,7 +461,10 @@ describe("OrchestrationEngine", () => {
428 projectId: asProjectId("project-atomic"),461 projectId: asProjectId("project-atomic"),
429 title: "Atomic Project",462 title: "Atomic Project",
430 workspaceRoot: "/tmp/project-atomic",463 workspaceRoot: "/tmp/project-atomic",
431 defaultModel: "gpt-5-codex",464 defaultModelSelection: {
465 provider: "codex",
466 model: "gpt-5-codex",
467 },
432 createdAt,468 createdAt,
433 }),469 }),
434 );470 );
...@@ -439,7 +475,10 @@ describe("OrchestrationEngine", () => {...@@ -439,7 +475,10 @@ describe("OrchestrationEngine", () => {
439 threadId: ThreadId.makeUnsafe("thread-atomic"),475 threadId: ThreadId.makeUnsafe("thread-atomic"),
440 projectId: asProjectId("project-atomic"),476 projectId: asProjectId("project-atomic"),
441 title: "atomic",477 title: "atomic",
442 model: "gpt-5-codex",478 modelSelection: {
479 provider: "codex",
480 model: "gpt-5-codex",
481 },
443 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,482 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
444 runtimeMode: "approval-required",483 runtimeMode: "approval-required",
445 branch: null,484 branch: null,
...@@ -563,7 +602,10 @@ describe("OrchestrationEngine", () => {...@@ -563,7 +602,10 @@ describe("OrchestrationEngine", () => {
563 projectId: asProjectId("project-sync"),602 projectId: asProjectId("project-sync"),
564 title: "Sync Project",603 title: "Sync Project",
565 workspaceRoot: "/tmp/project-sync",604 workspaceRoot: "/tmp/project-sync",
566 defaultModel: "gpt-5-codex",605 defaultModelSelection: {
606 provider: "codex",
607 model: "gpt-5-codex",
608 },
567 createdAt,609 createdAt,
568 }),610 }),
569 );611 );
...@@ -574,7 +616,10 @@ describe("OrchestrationEngine", () => {...@@ -574,7 +616,10 @@ describe("OrchestrationEngine", () => {
574 threadId: ThreadId.makeUnsafe("thread-sync"),616 threadId: ThreadId.makeUnsafe("thread-sync"),
575 projectId: asProjectId("project-sync"),617 projectId: asProjectId("project-sync"),
576 title: "sync-before",618 title: "sync-before",
577 model: "gpt-5-codex",619 modelSelection: {
620 provider: "codex",
621 model: "gpt-5-codex",
622 },
578 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,623 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
579 runtimeMode: "approval-required",624 runtimeMode: "approval-required",
580 branch: null,625 branch: null,
...@@ -642,7 +687,10 @@ describe("OrchestrationEngine", () => {...@@ -642,7 +687,10 @@ describe("OrchestrationEngine", () => {
642 projectId: asProjectId("project-duplicate"),687 projectId: asProjectId("project-duplicate"),
643 title: "Duplicate Project",688 title: "Duplicate Project",
644 workspaceRoot: "/tmp/project-duplicate",689 workspaceRoot: "/tmp/project-duplicate",
645 defaultModel: "gpt-5-codex",690 defaultModelSelection: {
691 provider: "codex",
692 model: "gpt-5-codex",
693 },
646 createdAt,694 createdAt,
647 }),695 }),
648 );696 );
...@@ -654,7 +702,10 @@ describe("OrchestrationEngine", () => {...@@ -654,7 +702,10 @@ describe("OrchestrationEngine", () => {
654 threadId: ThreadId.makeUnsafe("thread-duplicate"),702 threadId: ThreadId.makeUnsafe("thread-duplicate"),
655 projectId: asProjectId("project-duplicate"),703 projectId: asProjectId("project-duplicate"),
656 title: "duplicate",704 title: "duplicate",
657 model: "gpt-5-codex",705 modelSelection: {
706 provider: "codex",
707 model: "gpt-5-codex",
708 },
658 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,709 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
659 runtimeMode: "approval-required",710 runtimeMode: "approval-required",
660 branch: null,711 branch: null,
...@@ -671,7 +722,10 @@ describe("OrchestrationEngine", () => {...@@ -671,7 +722,10 @@ describe("OrchestrationEngine", () => {
671 threadId: ThreadId.makeUnsafe("thread-duplicate"),722 threadId: ThreadId.makeUnsafe("thread-duplicate"),
672 projectId: asProjectId("project-duplicate"),723 projectId: asProjectId("project-duplicate"),
673 title: "duplicate",724 title: "duplicate",
674 model: "gpt-5-codex",725 modelSelection: {
726 provider: "codex",
727 model: "gpt-5-codex",
728 },
675 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,729 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
676 runtimeMode: "approval-required",730 runtimeMode: "approval-required",
677 branch: null,731 branch: null,
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+65-26
...@@ -68,7 +68,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -68,7 +68,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
68 projectId: ProjectId.makeUnsafe("project-1"),68 projectId: ProjectId.makeUnsafe("project-1"),
69 title: "Project 1",69 title: "Project 1",
70 workspaceRoot: "/tmp/project-1",70 workspaceRoot: "/tmp/project-1",
71 defaultModel: null,71 defaultModelSelection: null,
72 scripts: [],72 scripts: [],
73 createdAt: now,73 createdAt: now,
74 updatedAt: now,74 updatedAt: now,
...@@ -89,7 +89,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -89,7 +89,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
89 threadId: ThreadId.makeUnsafe("thread-1"),89 threadId: ThreadId.makeUnsafe("thread-1"),
90 projectId: ProjectId.makeUnsafe("project-1"),90 projectId: ProjectId.makeUnsafe("project-1"),
91 title: "Thread 1",91 title: "Thread 1",
92 model: "gpt-5-codex",92 modelSelection: {
93 provider: "codex",
94 model: "gpt-5-codex",
95 },
93 runtimeMode: "full-access",96 runtimeMode: "full-access",
94 branch: null,97 branch: null,
95 worktreePath: null,98 worktreePath: null,
...@@ -337,7 +340,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -337,7 +340,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
337 projectId: ProjectId.makeUnsafe("project-clear-attachments"),340 projectId: ProjectId.makeUnsafe("project-clear-attachments"),
338 title: "Project Clear Attachments",341 title: "Project Clear Attachments",
339 workspaceRoot: "/tmp/project-clear-attachments",342 workspaceRoot: "/tmp/project-clear-attachments",
340 defaultModel: null,343 defaultModelSelection: null,
341 scripts: [],344 scripts: [],
342 createdAt: now,345 createdAt: now,
343 updatedAt: now,346 updatedAt: now,
...@@ -358,7 +361,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -358,7 +361,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
358 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),361 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),
359 projectId: ProjectId.makeUnsafe("project-clear-attachments"),362 projectId: ProjectId.makeUnsafe("project-clear-attachments"),
360 title: "Thread Clear Attachments",363 title: "Thread Clear Attachments",
361 model: "gpt-5-codex",364 modelSelection: {
365 provider: "codex",
366 model: "gpt-5-codex",
367 },
362 runtimeMode: "full-access",368 runtimeMode: "full-access",
363 branch: null,369 branch: null,
364 worktreePath: null,370 worktreePath: null,
...@@ -462,7 +468,7 @@ it.layer(...@@ -462,7 +468,7 @@ it.layer(
462 projectId: ProjectId.makeUnsafe("project-overwrite"),468 projectId: ProjectId.makeUnsafe("project-overwrite"),
463 title: "Project Overwrite",469 title: "Project Overwrite",
464 workspaceRoot: "/tmp/project-overwrite",470 workspaceRoot: "/tmp/project-overwrite",
465 defaultModel: null,471 defaultModelSelection: null,
466 scripts: [],472 scripts: [],
467 createdAt: now,473 createdAt: now,
468 updatedAt: now,474 updatedAt: now,
...@@ -483,7 +489,10 @@ it.layer(...@@ -483,7 +489,10 @@ it.layer(
483 threadId: ThreadId.makeUnsafe("thread-overwrite"),489 threadId: ThreadId.makeUnsafe("thread-overwrite"),
484 projectId: ProjectId.makeUnsafe("project-overwrite"),490 projectId: ProjectId.makeUnsafe("project-overwrite"),
485 title: "Thread Overwrite",491 title: "Thread Overwrite",
486 model: "gpt-5-codex",492 modelSelection: {
493 provider: "codex",
494 model: "gpt-5-codex",
495 },
487 runtimeMode: "full-access",496 runtimeMode: "full-access",
488 branch: null,497 branch: null,
489 worktreePath: null,498 worktreePath: null,
...@@ -607,7 +616,7 @@ it.layer(...@@ -607,7 +616,7 @@ it.layer(
607 projectId: ProjectId.makeUnsafe("project-rollback"),616 projectId: ProjectId.makeUnsafe("project-rollback"),
608 title: "Project Rollback",617 title: "Project Rollback",
609 workspaceRoot: "/tmp/project-rollback",618 workspaceRoot: "/tmp/project-rollback",
610 defaultModel: null,619 defaultModelSelection: null,
611 scripts: [],620 scripts: [],
612 createdAt: now,621 createdAt: now,
613 updatedAt: now,622 updatedAt: now,
...@@ -628,7 +637,10 @@ it.layer(...@@ -628,7 +637,10 @@ it.layer(
628 threadId: ThreadId.makeUnsafe("thread-rollback"),637 threadId: ThreadId.makeUnsafe("thread-rollback"),
629 projectId: ProjectId.makeUnsafe("project-rollback"),638 projectId: ProjectId.makeUnsafe("project-rollback"),
630 title: "Thread Rollback",639 title: "Thread Rollback",
631 model: "gpt-5-codex",640 modelSelection: {
641 provider: "codex",
642 model: "gpt-5-codex",
643 },
632 runtimeMode: "full-access",644 runtimeMode: "full-access",
633 branch: null,645 branch: null,
634 worktreePath: null,646 worktreePath: null,
...@@ -733,7 +745,7 @@ it.layer(...@@ -733,7 +745,7 @@ it.layer(
733 projectId: ProjectId.makeUnsafe("project-revert-files"),745 projectId: ProjectId.makeUnsafe("project-revert-files"),
734 title: "Project Revert Files",746 title: "Project Revert Files",
735 workspaceRoot: "/tmp/project-revert-files",747 workspaceRoot: "/tmp/project-revert-files",
736 defaultModel: null,748 defaultModelSelection: null,
737 scripts: [],749 scripts: [],
738 createdAt: now,750 createdAt: now,
739 updatedAt: now,751 updatedAt: now,
...@@ -754,7 +766,10 @@ it.layer(...@@ -754,7 +766,10 @@ it.layer(
754 threadId,766 threadId,
755 projectId: ProjectId.makeUnsafe("project-revert-files"),767 projectId: ProjectId.makeUnsafe("project-revert-files"),
756 title: "Thread Revert Files",768 title: "Thread Revert Files",
757 model: "gpt-5-codex",769 modelSelection: {
770 provider: "codex",
771 model: "gpt-5-codex",
772 },
758 runtimeMode: "full-access",773 runtimeMode: "full-access",
759 branch: null,774 branch: null,
760 worktreePath: null,775 worktreePath: null,
...@@ -938,7 +953,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta...@@ -938,7 +953,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
938 projectId: ProjectId.makeUnsafe("project-delete-files"),953 projectId: ProjectId.makeUnsafe("project-delete-files"),
939 title: "Project Delete Files",954 title: "Project Delete Files",
940 workspaceRoot: "/tmp/project-delete-files",955 workspaceRoot: "/tmp/project-delete-files",
941 defaultModel: null,956 defaultModelSelection: null,
942 scripts: [],957 scripts: [],
943 createdAt: now,958 createdAt: now,
944 updatedAt: now,959 updatedAt: now,
...@@ -959,7 +974,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta...@@ -959,7 +974,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta
959 threadId,974 threadId,
960 projectId: ProjectId.makeUnsafe("project-delete-files"),975 projectId: ProjectId.makeUnsafe("project-delete-files"),
961 title: "Thread Delete Files",976 title: "Thread Delete Files",
962 model: "gpt-5-codex",977 modelSelection: {
978 provider: "codex",
979 model: "gpt-5-codex",
980 },
963 runtimeMode: "full-access",981 runtimeMode: "full-access",
964 branch: null,982 branch: null,
965 worktreePath: null,983 worktreePath: null,
...@@ -1098,7 +1116,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -1098,7 +1116,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
1098 projectId: ProjectId.makeUnsafe("project-a"),1116 projectId: ProjectId.makeUnsafe("project-a"),
1099 title: "Project A",1117 title: "Project A",
1100 workspaceRoot: "/tmp/project-a",1118 workspaceRoot: "/tmp/project-a",
1101 defaultModel: null,1119 defaultModelSelection: null,
1102 scripts: [],1120 scripts: [],
1103 createdAt: now,1121 createdAt: now,
1104 updatedAt: now,1122 updatedAt: now,
...@@ -1119,7 +1137,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -1119,7 +1137,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
1119 threadId: ThreadId.makeUnsafe("thread-a"),1137 threadId: ThreadId.makeUnsafe("thread-a"),
1120 projectId: ProjectId.makeUnsafe("project-a"),1138 projectId: ProjectId.makeUnsafe("project-a"),
1121 title: "Thread A",1139 title: "Thread A",
1122 model: "gpt-5-codex",1140 modelSelection: {
1141 provider: "codex",
1142 model: "gpt-5-codex",
1143 },
1123 runtimeMode: "full-access",1144 runtimeMode: "full-access",
1124 branch: null,1145 branch: null,
1125 worktreePath: null,1146 worktreePath: null,
...@@ -1222,7 +1243,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -1222,7 +1243,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
1222 projectId: ProjectId.makeUnsafe("project-empty"),1243 projectId: ProjectId.makeUnsafe("project-empty"),
1223 title: "Project Empty",1244 title: "Project Empty",
1224 workspaceRoot: "/tmp/project-empty",1245 workspaceRoot: "/tmp/project-empty",
1225 defaultModel: null,1246 defaultModelSelection: null,
1226 scripts: [],1247 scripts: [],
1227 createdAt: now,1248 createdAt: now,
1228 updatedAt: now,1249 updatedAt: now,
...@@ -1243,7 +1264,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -1243,7 +1264,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
1243 threadId: ThreadId.makeUnsafe("thread-empty"),1264 threadId: ThreadId.makeUnsafe("thread-empty"),
1244 projectId: ProjectId.makeUnsafe("project-empty"),1265 projectId: ProjectId.makeUnsafe("project-empty"),
1245 title: "Thread Empty",1266 title: "Thread Empty",
1246 model: "gpt-5-codex",1267 modelSelection: {
1268 provider: "codex",
1269 model: "gpt-5-codex",
1270 },
1247 runtimeMode: "full-access",1271 runtimeMode: "full-access",
1248 branch: null,1272 branch: null,
1249 worktreePath: null,1273 worktreePath: null,
...@@ -1359,7 +1383,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -1359,7 +1383,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
1359 projectId: ProjectId.makeUnsafe("project-conflict"),1383 projectId: ProjectId.makeUnsafe("project-conflict"),
1360 title: "Project Conflict",1384 title: "Project Conflict",
1361 workspaceRoot: "/tmp/project-conflict",1385 workspaceRoot: "/tmp/project-conflict",
1362 defaultModel: null,1386 defaultModelSelection: null,
1363 scripts: [],1387 scripts: [],
1364 createdAt: "2026-02-26T13:00:00.000Z",1388 createdAt: "2026-02-26T13:00:00.000Z",
1365 updatedAt: "2026-02-26T13:00:00.000Z",1389 updatedAt: "2026-02-26T13:00:00.000Z",
...@@ -1380,7 +1404,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -1380,7 +1404,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
1380 threadId: ThreadId.makeUnsafe("thread-conflict"),1404 threadId: ThreadId.makeUnsafe("thread-conflict"),
1381 projectId: ProjectId.makeUnsafe("project-conflict"),1405 projectId: ProjectId.makeUnsafe("project-conflict"),
1382 title: "Thread Conflict",1406 title: "Thread Conflict",
1383 model: "gpt-5-codex",1407 modelSelection: {
1408 provider: "codex",
1409 model: "gpt-5-codex",
1410 },
1384 runtimeMode: "full-access",1411 runtimeMode: "full-access",
1385 branch: null,1412 branch: null,
1386 worktreePath: null,1413 worktreePath: null,
...@@ -1500,7 +1527,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -1500,7 +1527,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
1500 projectId: ProjectId.makeUnsafe("project-revert"),1527 projectId: ProjectId.makeUnsafe("project-revert"),
1501 title: "Project Revert",1528 title: "Project Revert",
1502 workspaceRoot: "/tmp/project-revert",1529 workspaceRoot: "/tmp/project-revert",
1503 defaultModel: null,1530 defaultModelSelection: null,
1504 scripts: [],1531 scripts: [],
1505 createdAt: "2026-02-26T12:00:00.000Z",1532 createdAt: "2026-02-26T12:00:00.000Z",
1506 updatedAt: "2026-02-26T12:00:00.000Z",1533 updatedAt: "2026-02-26T12:00:00.000Z",
...@@ -1521,7 +1548,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {...@@ -1521,7 +1548,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => {
1521 threadId: ThreadId.makeUnsafe("thread-revert"),1548 threadId: ThreadId.makeUnsafe("thread-revert"),
1522 projectId: ProjectId.makeUnsafe("project-revert"),1549 projectId: ProjectId.makeUnsafe("project-revert"),
1523 title: "Thread Revert",1550 title: "Thread Revert",
1524 model: "gpt-5-codex",1551 modelSelection: {
1552 provider: "codex",
1553 model: "gpt-5-codex",
1554 },
1525 runtimeMode: "full-access",1555 runtimeMode: "full-access",
1526 branch: null,1556 branch: null,
1527 worktreePath: null,1557 worktreePath: null,
...@@ -1837,7 +1867,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -1837,7 +1867,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
1837 projectId: ProjectId.makeUnsafe("project-live"),1867 projectId: ProjectId.makeUnsafe("project-live"),
1838 title: "Live Project",1868 title: "Live Project",
1839 workspaceRoot: "/tmp/project-live",1869 workspaceRoot: "/tmp/project-live",
1840 defaultModel: "gpt-5-codex",1870 defaultModelSelection: {
1871 provider: "codex",
1872 model: "gpt-5-codex",
1873 },
1841 createdAt,1874 createdAt,
1842 });1875 });
18431876
...@@ -1872,7 +1905,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -1872,7 +1905,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
1872 projectId: ProjectId.makeUnsafe("project-scripts"),1905 projectId: ProjectId.makeUnsafe("project-scripts"),
1873 title: "Scripts Project",1906 title: "Scripts Project",
1874 workspaceRoot: "/tmp/project-scripts",1907 workspaceRoot: "/tmp/project-scripts",
1875 defaultModel: "gpt-5-codex",1908 defaultModelSelection: {
1909 provider: "codex",
1910 model: "gpt-5-codex",
1911 },
1876 createdAt,1912 createdAt,
1877 });1913 });
18781914
...@@ -1889,16 +1925,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -1889,16 +1925,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
1889 runOnWorktreeCreate: false,1925 runOnWorktreeCreate: false,
1890 },1926 },
1891 ],1927 ],
1892 defaultModel: "gpt-5",1928 defaultModelSelection: {
1929 provider: "codex",
1930 model: "gpt-5",
1931 },
1893 });1932 });
18941933
1895 const projectRows = yield* sql<{1934 const projectRows = yield* sql<{
1896 readonly scriptsJson: string;1935 readonly scriptsJson: string;
1897 readonly defaultModel: string;1936 readonly defaultModelSelection: string;
1898 }>`1937 }>`
1899 SELECT1938 SELECT
1900 scripts_json AS "scriptsJson",1939 scripts_json AS "scriptsJson",
1901 default_model AS "defaultModel"1940 default_model_selection_json AS "defaultModelSelection"
1902 FROM projection_projects1941 FROM projection_projects
1903 WHERE project_id = 'project-scripts'1942 WHERE project_id = 'project-scripts'
1904 `;1943 `;
...@@ -1906,7 +1945,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {...@@ -1906,7 +1945,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => {
1906 {1945 {
1907 scriptsJson:1946 scriptsJson:
1908 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',1947 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
1909 defaultModel: "gpt-5",1948 defaultModelSelection: '{"provider":"codex","model":"gpt-5"}',
1910 },1949 },
1911 ]);1950 ]);
1912 }),1951 }),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+7-5
...@@ -362,7 +362,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -362,7 +362,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
362 projectId: event.payload.projectId,362 projectId: event.payload.projectId,
363 title: event.payload.title,363 title: event.payload.title,
364 workspaceRoot: event.payload.workspaceRoot,364 workspaceRoot: event.payload.workspaceRoot,
365 defaultModel: event.payload.defaultModel,365 defaultModelSelection: event.payload.defaultModelSelection,
366 scripts: event.payload.scripts,366 scripts: event.payload.scripts,
367 createdAt: event.payload.createdAt,367 createdAt: event.payload.createdAt,
368 updatedAt: event.payload.updatedAt,368 updatedAt: event.payload.updatedAt,
...@@ -383,8 +383,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -383,8 +383,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
383 ...(event.payload.workspaceRoot !== undefined383 ...(event.payload.workspaceRoot !== undefined
384 ? { workspaceRoot: event.payload.workspaceRoot }384 ? { workspaceRoot: event.payload.workspaceRoot }
385 : {}),385 : {}),
386 ...(event.payload.defaultModel !== undefined386 ...(event.payload.defaultModelSelection !== undefined
387 ? { defaultModel: event.payload.defaultModel }387 ? { defaultModelSelection: event.payload.defaultModelSelection }
388 : {}),388 : {}),
389 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),389 ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}),
390 updatedAt: event.payload.updatedAt,390 updatedAt: event.payload.updatedAt,
...@@ -420,7 +420,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -420,7 +420,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
420 threadId: event.payload.threadId,420 threadId: event.payload.threadId,
421 projectId: event.payload.projectId,421 projectId: event.payload.projectId,
422 title: event.payload.title,422 title: event.payload.title,
423 model: event.payload.model,423 modelSelection: event.payload.modelSelection,
424 runtimeMode: event.payload.runtimeMode,424 runtimeMode: event.payload.runtimeMode,
425 interactionMode: event.payload.interactionMode,425 interactionMode: event.payload.interactionMode,
426 branch: event.payload.branch,426 branch: event.payload.branch,
...@@ -442,7 +442,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {...@@ -442,7 +442,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
442 yield* projectionThreadRepository.upsert({442 yield* projectionThreadRepository.upsert({
443 ...existingRow.value,443 ...existingRow.value,
444 ...(event.payload.title !== undefined ? { title: event.payload.title } : {}),444 ...(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 : {}),
446 ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}),448 ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}),
447 ...(event.payload.worktreePath !== undefined449 ...(event.payload.worktreePath !== undefined
448 ? { worktreePath: event.payload.worktreePath }450 ? { worktreePath: event.payload.worktreePath }
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+12-6
...@@ -34,7 +34,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -34,7 +34,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
34 project_id,34 project_id,
35 title,35 title,
36 workspace_root,36 workspace_root,
37 default_model,37 default_model_selection_json,
38 scripts_json,38 scripts_json,
39 created_at,39 created_at,
40 updated_at,40 updated_at,
...@@ -44,7 +44,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -44,7 +44,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
44 'project-1',44 'project-1',
45 'Project 1',45 'Project 1',
46 '/tmp/project-1',46 '/tmp/project-1',
47 'gpt-5-codex',47 '{"provider":"codex","model":"gpt-5-codex"}',
48 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',48 '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]',
49 '2026-02-24T00:00:00.000Z',49 '2026-02-24T00:00:00.000Z',
50 '2026-02-24T00:00:01.000Z',50 '2026-02-24T00:00:01.000Z',
...@@ -57,7 +57,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -57,7 +57,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
57 thread_id,57 thread_id,
58 project_id,58 project_id,
59 title,59 title,
60 model,60 model_selection_json,
61 branch,61 branch,
62 worktree_path,62 worktree_path,
63 latest_turn_id,63 latest_turn_id,
...@@ -69,7 +69,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -69,7 +69,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
69 'thread-1',69 'thread-1',
70 'project-1',70 'project-1',
71 'Thread 1',71 'Thread 1',
72 'gpt-5-codex',72 '{"provider":"codex","model":"gpt-5-codex"}',
73 NULL,73 NULL,
74 NULL,74 NULL,
75 'turn-1',75 'turn-1',
...@@ -234,7 +234,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -234,7 +234,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
234 id: asProjectId("project-1"),234 id: asProjectId("project-1"),
235 title: "Project 1",235 title: "Project 1",
236 workspaceRoot: "/tmp/project-1",236 workspaceRoot: "/tmp/project-1",
237 defaultModel: "gpt-5-codex",237 defaultModelSelection: {
238 provider: "codex",
239 model: "gpt-5-codex",
240 },
238 scripts: [241 scripts: [
239 {242 {
240 id: "script-1",243 id: "script-1",
...@@ -254,7 +257,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -254,7 +257,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
254 id: ThreadId.makeUnsafe("thread-1"),257 id: ThreadId.makeUnsafe("thread-1"),
255 projectId: asProjectId("project-1"),258 projectId: asProjectId("project-1"),
256 title: "Thread 1",259 title: "Thread 1",
257 model: "gpt-5-codex",260 modelSelection: {
261 provider: "codex",
262 model: "gpt-5-codex",
263 },
258 interactionMode: "default",264 interactionMode: "default",
259 runtimeMode: "full-access",265 runtimeMode: "full-access",
260 branch: null,266 branch: null,
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+13-7
...@@ -17,6 +17,7 @@ import {...@@ -17,6 +17,7 @@ import {
17 type OrchestrationSession,17 type OrchestrationSession,
18 type OrchestrationThread,18 type OrchestrationThread,
19 type OrchestrationThreadActivity,19 type OrchestrationThreadActivity,
20 ModelSelection,
20} from "@t3tools/contracts";21} from "@t3tools/contracts";
21import { Effect, Layer, Schema, Struct } from "effect";22import { Effect, Layer, Schema, Struct } from "effect";
22import * as SqlClient from "effect/unstable/sql/SqlClient";23import * as SqlClient from "effect/unstable/sql/SqlClient";
...@@ -45,6 +46,7 @@ import {...@@ -45,6 +46,7 @@ import {
45const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);46const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel);
46const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(47const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(
47 Struct.assign({48 Struct.assign({
49 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
48 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),50 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
49 }),51 }),
50);52);
...@@ -55,7 +57,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(...@@ -55,7 +57,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields(
55 }),57 }),
56);58);
57const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;59const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan;
58const ProjectionThreadDbRowSchema = ProjectionThread;60const ProjectionThreadDbRowSchema = ProjectionThread.mapFields(
61 Struct.assign({
62 modelSelection: Schema.fromJsonString(ModelSelection),
63 }),
64);
59const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(65const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields(
60 Struct.assign({66 Struct.assign({
61 payload: Schema.fromJsonString(Schema.Unknown),67 payload: Schema.fromJsonString(Schema.Unknown),
...@@ -141,7 +147,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -141,7 +147,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
141 project_id AS "projectId",147 project_id AS "projectId",
142 title,148 title,
143 workspace_root AS "workspaceRoot",149 workspace_root AS "workspaceRoot",
144 default_model AS "defaultModel",150 default_model_selection_json AS "defaultModelSelection",
145 scripts_json AS "scripts",151 scripts_json AS "scripts",
146 created_at AS "createdAt",152 created_at AS "createdAt",
147 updated_at AS "updatedAt",153 updated_at AS "updatedAt",
...@@ -160,7 +166,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -160,7 +166,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
160 thread_id AS "threadId",166 thread_id AS "threadId",
161 project_id AS "projectId",167 project_id AS "projectId",
162 title,168 title,
163 model,169 model_selection_json AS "modelSelection",
164 runtime_mode AS "runtimeMode",170 runtime_mode AS "runtimeMode",
165 interaction_mode AS "interactionMode",171 interaction_mode AS "interactionMode",
166 branch,172 branch,
...@@ -531,22 +537,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -531,22 +537,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
531 });537 });
532 }538 }
533539
534 const projects: Array<OrchestrationProject> = projectRows.map((row) => ({540 const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
535 id: row.projectId,541 id: row.projectId,
536 title: row.title,542 title: row.title,
537 workspaceRoot: row.workspaceRoot,543 workspaceRoot: row.workspaceRoot,
538 defaultModel: row.defaultModel,544 defaultModelSelection: row.defaultModelSelection,
539 scripts: row.scripts,545 scripts: row.scripts,
540 createdAt: row.createdAt,546 createdAt: row.createdAt,
541 updatedAt: row.updatedAt,547 updatedAt: row.updatedAt,
542 deletedAt: row.deletedAt,548 deletedAt: row.deletedAt,
543 }));549 }));
544550
545 const threads: Array<OrchestrationThread> = threadRows.map((row) => ({551 const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
546 id: row.threadId,552 id: row.threadId,
547 projectId: row.projectId,553 projectId: row.projectId,
548 title: row.title,554 title: row.title,
549 model: row.model,555 modelSelection: row.modelSelection,
550 runtimeMode: row.runtimeMode,556 runtimeMode: row.runtimeMode,
551 interactionMode: row.interactionMode,557 interactionMode: row.interactionMode,
552 branch: row.branch,558 branch: row.branch,
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+159-98
...@@ -2,7 +2,7 @@ import fs from "node:fs";...@@ -2,7 +2,7 @@ import fs from "node:fs";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
44
5import type { ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";5import type { ModelSelection, ProviderRuntimeEvent, ProviderSession } from "@t3tools/contracts";
6import {6import {
7 ApprovalRequestId,7 ApprovalRequestId,
8 CommandId,8 CommandId,
...@@ -93,37 +93,27 @@ describe("ProviderCommandReactor", () => {...@@ -93,37 +93,27 @@ describe("ProviderCommandReactor", () => {
9393
94 async function createHarness(input?: {94 async function createHarness(input?: {
95 readonly baseDir?: string;95 readonly baseDir?: string;
96 readonly threadModel?: string;96 readonly threadModelSelection?: ModelSelection;
97 readonly sessionModelSwitch?: "unsupported" | "in-session";
97 }) {98 }) {
98 const now = new Date().toISOString();99 const now = new Date().toISOString();
99 const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));100 const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-"));
100 createdBaseDirs.add(baseDir);101 createdBaseDirs.add(baseDir);
101 const { stateDir } = deriveServerPathsSync(baseDir, undefined);102 const { stateDir } = deriveServerPathsSync(baseDir, undefined);
102 createdStateDirs.add(stateDir);103 createdStateDirs.add(stateDir);
103 const threadModel = input?.threadModel ?? "gpt-5-codex";
104 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());104 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
105 let nextSessionIndex = 1;105 let nextSessionIndex = 1;
106 const runtimeSessions: Array<ProviderSession> = [];106 const runtimeSessions: Array<ProviderSession> = [];
107 const modelSelection = input?.threadModelSelection ?? {
108 provider: "codex",
109 model: "gpt-5-codex",
110 };
107 const startSession = vi.fn((_: unknown, input: unknown) => {111 const startSession = vi.fn((_: unknown, input: unknown) => {
108 const sessionIndex = nextSessionIndex++;112 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";
116 const resumeCursor =113 const resumeCursor =
117 typeof input === "object" && input !== null && "resumeCursor" in input114 typeof input === "object" && input !== null && "resumeCursor" in input
118 ? input.resumeCursor115 ? input.resumeCursor
119 : undefined;116 : 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;
127 const threadId =117 const threadId =
128 typeof input === "object" &&118 typeof input === "object" &&
129 input !== null &&119 input !== null &&
...@@ -132,7 +122,7 @@ describe("ProviderCommandReactor", () => {...@@ -132,7 +122,7 @@ describe("ProviderCommandReactor", () => {
132 ? ThreadId.makeUnsafe(input.threadId)122 ? ThreadId.makeUnsafe(input.threadId)
133 : ThreadId.makeUnsafe(`thread-${sessionIndex}`);123 : ThreadId.makeUnsafe(`thread-${sessionIndex}`);
134 const session: ProviderSession = {124 const session: ProviderSession = {
135 provider,125 provider: modelSelection.provider,
136 status: "ready" as const,126 status: "ready" as const,
137 runtimeMode:127 runtimeMode:
138 typeof input === "object" &&128 typeof input === "object" &&
...@@ -141,7 +131,7 @@ describe("ProviderCommandReactor", () => {...@@ -141,7 +131,7 @@ describe("ProviderCommandReactor", () => {
141 (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access")131 (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access")
142 ? input.runtimeMode132 ? input.runtimeMode
143 : "full-access",133 : "full-access",
144 ...(model !== undefined ? { model } : {}),134 ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}),
145 threadId,135 threadId,
146 resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },136 resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },
147 createdAt: now,137 createdAt: now,
...@@ -203,9 +193,9 @@ describe("ProviderCommandReactor", () => {...@@ -203,9 +193,9 @@ describe("ProviderCommandReactor", () => {
203 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],193 respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"],
204 stopSession: stopSession as ProviderServiceShape["stopSession"],194 stopSession: stopSession as ProviderServiceShape["stopSession"],
205 listSessions: () => Effect.succeed(runtimeSessions),195 listSessions: () => Effect.succeed(runtimeSessions),
206 getCapabilities: (provider) =>196 getCapabilities: (_provider) =>
207 Effect.succeed({197 Effect.succeed({
208 sessionModelSwitch: provider === "codex" ? "in-session" : "in-session",198 sessionModelSwitch: input?.sessionModelSwitch ?? "in-session",
209 }),199 }),
210 rollbackConversation: () => unsupported(),200 rollbackConversation: () => unsupported(),
211 streamEvents: Stream.fromPubSub(runtimeEventPubSub),201 streamEvents: Stream.fromPubSub(runtimeEventPubSub),
...@@ -242,7 +232,7 @@ describe("ProviderCommandReactor", () => {...@@ -242,7 +232,7 @@ describe("ProviderCommandReactor", () => {
242 projectId: asProjectId("project-1"),232 projectId: asProjectId("project-1"),
243 title: "Provider Project",233 title: "Provider Project",
244 workspaceRoot: "/tmp/provider-project",234 workspaceRoot: "/tmp/provider-project",
245 defaultModel: threadModel,235 defaultModelSelection: modelSelection,
246 createdAt: now,236 createdAt: now,
247 }),237 }),
248 );238 );
...@@ -253,7 +243,7 @@ describe("ProviderCommandReactor", () => {...@@ -253,7 +243,7 @@ describe("ProviderCommandReactor", () => {
253 threadId: ThreadId.makeUnsafe("thread-1"),243 threadId: ThreadId.makeUnsafe("thread-1"),
254 projectId: asProjectId("project-1"),244 projectId: asProjectId("project-1"),
255 title: "Thread",245 title: "Thread",
256 model: threadModel,246 modelSelection: modelSelection,
257 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,247 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
258 runtimeMode: "approval-required",248 runtimeMode: "approval-required",
259 branch: null,249 branch: null,
...@@ -303,7 +293,10 @@ describe("ProviderCommandReactor", () => {...@@ -303,7 +293,10 @@ describe("ProviderCommandReactor", () => {
303 expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1"));293 expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1"));
304 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({294 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
305 cwd: "/tmp/provider-project",295 cwd: "/tmp/provider-project",
306 model: "gpt-5-codex",296 modelSelection: {
297 provider: "codex",
298 model: "gpt-5-codex",
299 },
307 runtimeMode: "approval-required",300 runtimeMode: "approval-required",
308 });301 });
309302
...@@ -328,10 +321,10 @@ describe("ProviderCommandReactor", () => {...@@ -328,10 +321,10 @@ describe("ProviderCommandReactor", () => {
328 text: "hello fast mode",321 text: "hello fast mode",
329 attachments: [],322 attachments: [],
330 },323 },
331 provider: "codex",324 modelSelection: {
332 model: "gpt-5.3-codex",325 provider: "codex",
333 modelOptions: {326 model: "gpt-5.3-codex",
334 codex: {327 options: {
335 reasoningEffort: "high",328 reasoningEffort: "high",
336 fastMode: true,329 fastMode: true,
337 },330 },
...@@ -345,9 +338,10 @@ describe("ProviderCommandReactor", () => {...@@ -345,9 +338,10 @@ describe("ProviderCommandReactor", () => {
345 await waitFor(() => harness.startSession.mock.calls.length === 1);338 await waitFor(() => harness.startSession.mock.calls.length === 1);
346 await waitFor(() => harness.sendTurn.mock.calls.length === 1);339 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
347 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({340 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
348 model: "gpt-5.3-codex",341 modelSelection: {
349 modelOptions: {342 provider: "codex",
350 codex: {343 model: "gpt-5.3-codex",
344 options: {
351 reasoningEffort: "high",345 reasoningEffort: "high",
352 fastMode: true,346 fastMode: true,
353 },347 },
...@@ -355,9 +349,10 @@ describe("ProviderCommandReactor", () => {...@@ -355,9 +349,10 @@ describe("ProviderCommandReactor", () => {
355 });349 });
356 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({350 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
357 threadId: ThreadId.makeUnsafe("thread-1"),351 threadId: ThreadId.makeUnsafe("thread-1"),
358 model: "gpt-5.3-codex",352 modelSelection: {
359 modelOptions: {353 provider: "codex",
360 codex: {354 model: "gpt-5.3-codex",
355 options: {
361 reasoningEffort: "high",356 reasoningEffort: "high",
362 fastMode: true,357 fastMode: true,
363 },358 },
...@@ -366,7 +361,9 @@ describe("ProviderCommandReactor", () => {...@@ -366,7 +361,9 @@ describe("ProviderCommandReactor", () => {
366 });361 });
367362
368 it("forwards claude effort options through session start and turn send", async () => {363 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 });
370 const now = new Date().toISOString();367 const now = new Date().toISOString();
371368
372 await Effect.runPromise(369 await Effect.runPromise(
...@@ -380,10 +377,10 @@ describe("ProviderCommandReactor", () => {...@@ -380,10 +377,10 @@ describe("ProviderCommandReactor", () => {
380 text: "hello with effort",377 text: "hello with effort",
381 attachments: [],378 attachments: [],
382 },379 },
383 provider: "claudeAgent",380 modelSelection: {
384 model: "claude-sonnet-4-6",381 provider: "claudeAgent",
385 modelOptions: {382 model: "claude-sonnet-4-6",
386 claudeAgent: {383 options: {
387 effort: "max",384 effort: "max",
388 },385 },
389 },386 },
...@@ -396,19 +393,20 @@ describe("ProviderCommandReactor", () => {...@@ -396,19 +393,20 @@ describe("ProviderCommandReactor", () => {
396 await waitFor(() => harness.startSession.mock.calls.length === 1);393 await waitFor(() => harness.startSession.mock.calls.length === 1);
397 await waitFor(() => harness.sendTurn.mock.calls.length === 1);394 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
398 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({395 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
399 provider: "claudeAgent",396 modelSelection: {
400 model: "claude-sonnet-4-6",397 provider: "claudeAgent",
401 modelOptions: {398 model: "claude-sonnet-4-6",
402 claudeAgent: {399 options: {
403 effort: "max",400 effort: "max",
404 },401 },
405 },402 },
406 });403 });
407 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({404 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
408 threadId: ThreadId.makeUnsafe("thread-1"),405 threadId: ThreadId.makeUnsafe("thread-1"),
409 model: "claude-sonnet-4-6",406 modelSelection: {
410 modelOptions: {407 provider: "claudeAgent",
411 claudeAgent: {408 model: "claude-sonnet-4-6",
409 options: {
412 effort: "max",410 effort: "max",
413 },411 },
414 },412 },
...@@ -416,7 +414,9 @@ describe("ProviderCommandReactor", () => {...@@ -416,7 +414,9 @@ describe("ProviderCommandReactor", () => {
416 });414 });
417415
418 it("forwards claude fast mode options through session start and turn send", async () => {416 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 });
420 const now = new Date().toISOString();420 const now = new Date().toISOString();
421421
422 await Effect.runPromise(422 await Effect.runPromise(
...@@ -430,10 +430,10 @@ describe("ProviderCommandReactor", () => {...@@ -430,10 +430,10 @@ describe("ProviderCommandReactor", () => {
430 text: "hello with fast mode",430 text: "hello with fast mode",
431 attachments: [],431 attachments: [],
432 },432 },
433 provider: "claudeAgent",433 modelSelection: {
434 model: "claude-opus-4-6",434 provider: "claudeAgent",
435 modelOptions: {435 model: "claude-opus-4-6",
436 claudeAgent: {436 options: {
437 fastMode: true,437 fastMode: true,
438 },438 },
439 },439 },
...@@ -446,19 +446,20 @@ describe("ProviderCommandReactor", () => {...@@ -446,19 +446,20 @@ describe("ProviderCommandReactor", () => {
446 await waitFor(() => harness.startSession.mock.calls.length === 1);446 await waitFor(() => harness.startSession.mock.calls.length === 1);
447 await waitFor(() => harness.sendTurn.mock.calls.length === 1);447 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
448 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({448 expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({
449 provider: "claudeAgent",449 modelSelection: {
450 model: "claude-opus-4-6",450 provider: "claudeAgent",
451 modelOptions: {451 model: "claude-opus-4-6",
452 claudeAgent: {452 options: {
453 fastMode: true,453 fastMode: true,
454 },454 },
455 },455 },
456 });456 });
457 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({457 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
458 threadId: ThreadId.makeUnsafe("thread-1"),458 threadId: ThreadId.makeUnsafe("thread-1"),
459 model: "claude-opus-4-6",459 modelSelection: {
460 modelOptions: {460 provider: "claudeAgent",
461 claudeAgent: {461 model: "claude-opus-4-6",
462 options: {
462 fastMode: true,463 fastMode: true,
463 },464 },
464 },465 },
...@@ -504,7 +505,9 @@ describe("ProviderCommandReactor", () => {...@@ -504,7 +505,9 @@ describe("ProviderCommandReactor", () => {
504 });505 });
505506
506 it("rejects a first turn when requested provider conflicts with the thread model", async () => {507 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 });
508 const now = new Date().toISOString();511 const now = new Date().toISOString();
509512
510 await Effect.runPromise(513 await Effect.runPromise(
...@@ -518,7 +521,10 @@ describe("ProviderCommandReactor", () => {...@@ -518,7 +521,10 @@ describe("ProviderCommandReactor", () => {
518 text: "hello claude",521 text: "hello claude",
519 attachments: [],522 attachments: [],
520 },523 },
521 provider: "claudeAgent",524 modelSelection: {
525 provider: "claudeAgent",
526 model: "claude-opus-4-6",
527 },
522 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,528 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
523 runtimeMode: "approval-required",529 runtimeMode: "approval-required",
524 createdAt: now,530 createdAt: now,
...@@ -552,49 +558,53 @@ describe("ProviderCommandReactor", () => {...@@ -552,49 +558,53 @@ describe("ProviderCommandReactor", () => {
552 });558 });
553 });559 });
554560
555 it("rejects a turn when the requested model belongs to a different provider", async () => {561 it("preserves the active session model when in-session model switching is unsupported", async () => {
556 const harness = await createHarness();562 const harness = await createHarness({ sessionModelSwitch: "unsupported" });
557 const now = new Date().toISOString();563 const now = new Date().toISOString();
558564
559 await Effect.runPromise(565 await Effect.runPromise(
560 harness.engine.dispatch({566 harness.engine.dispatch({
561 type: "thread.turn.start",567 type: "thread.turn.start",
562 commandId: CommandId.makeUnsafe("cmd-turn-start-model-provider-mismatch"),568 commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-1"),
563 threadId: ThreadId.makeUnsafe("thread-1"),569 threadId: ThreadId.makeUnsafe("thread-1"),
564 message: {570 message: {
565 messageId: asMessageId("user-message-model-provider-mismatch"),571 messageId: asMessageId("user-message-unsupported-1"),
566 role: "user",572 role: "user",
567 text: "hello",573 text: "first",
568 attachments: [],574 attachments: [],
569 },575 },
570 model: "claude-sonnet-4-6",
571 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,576 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
572 runtimeMode: "approval-required",577 runtimeMode: "approval-required",
573 createdAt: now,578 createdAt: now,
574 }),579 }),
575 );580 );
576581
577 await waitFor(async () => {582 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
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 });
587583
588 expect(harness.startSession).not.toHaveBeenCalled();584 await Effect.runPromise(
589 expect(harness.sendTurn).not.toHaveBeenCalled();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());601 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
592 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));602
593 expect(603 expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({
594 thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"),604 threadId: ThreadId.makeUnsafe("thread-1"),
595 ).toMatchObject({605 modelSelection: {
596 payload: {606 provider: "codex",
597 detail: expect.stringContaining("does not belong to provider 'codex'"),607 model: "gpt-5-codex",
598 },608 },
599 });609 });
600 });610 });
...@@ -646,7 +656,9 @@ describe("ProviderCommandReactor", () => {...@@ -646,7 +656,9 @@ describe("ProviderCommandReactor", () => {
646 });656 });
647657
648 it("restarts claude sessions when claude effort changes", async () => {658 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 });
650 const now = new Date().toISOString();662 const now = new Date().toISOString();
651663
652 await Effect.runPromise(664 await Effect.runPromise(
...@@ -660,10 +672,10 @@ describe("ProviderCommandReactor", () => {...@@ -660,10 +672,10 @@ describe("ProviderCommandReactor", () => {
660 text: "first claude turn",672 text: "first claude turn",
661 attachments: [],673 attachments: [],
662 },674 },
663 provider: "claudeAgent",675 modelSelection: {
664 model: "claude-sonnet-4-6",676 provider: "claudeAgent",
665 modelOptions: {677 model: "claude-sonnet-4-6",
666 claudeAgent: {678 options: {
667 effort: "medium",679 effort: "medium",
668 },680 },
669 },681 },
...@@ -687,10 +699,10 @@ describe("ProviderCommandReactor", () => {...@@ -687,10 +699,10 @@ describe("ProviderCommandReactor", () => {
687 text: "second claude turn",699 text: "second claude turn",
688 attachments: [],700 attachments: [],
689 },701 },
690 provider: "claudeAgent",702 modelSelection: {
691 model: "claude-sonnet-4-6",703 provider: "claudeAgent",
692 modelOptions: {704 model: "claude-sonnet-4-6",
693 claudeAgent: {705 options: {
694 effort: "max",706 effort: "max",
695 },707 },
696 },708 },
...@@ -703,10 +715,11 @@ describe("ProviderCommandReactor", () => {...@@ -703,10 +715,11 @@ describe("ProviderCommandReactor", () => {
703 await waitFor(() => harness.startSession.mock.calls.length === 2);715 await waitFor(() => harness.startSession.mock.calls.length === 2);
704 await waitFor(() => harness.sendTurn.mock.calls.length === 2);716 await waitFor(() => harness.sendTurn.mock.calls.length === 2);
705 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({717 expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
706 provider: "claudeAgent",
707 resumeCursor: { opaque: "resume-1" },718 resumeCursor: { opaque: "resume-1" },
708 modelOptions: {719 modelSelection: {
709 claudeAgent: {720 provider: "claudeAgent",
721 model: "claude-sonnet-4-6",
722 options: {
710 effort: "max",723 effort: "max",
711 },724 },
712 },725 },
...@@ -800,6 +813,51 @@ describe("ProviderCommandReactor", () => {...@@ -800,6 +813,51 @@ describe("ProviderCommandReactor", () => {
800 expect(thread?.session?.runtimeMode).toBe("approval-required");813 expect(thread?.session?.runtimeMode).toBe("approval-required");
801 });814 });
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
803 it("rejects provider changes after a thread is already bound to a session provider", async () => {861 it("rejects provider changes after a thread is already bound to a session provider", async () => {
804 const harness = await createHarness();862 const harness = await createHarness();
805 const now = new Date().toISOString();863 const now = new Date().toISOString();
...@@ -835,7 +893,10 @@ describe("ProviderCommandReactor", () => {...@@ -835,7 +893,10 @@ describe("ProviderCommandReactor", () => {
835 text: "second",893 text: "second",
836 attachments: [],894 attachments: [],
837 },895 },
838 provider: "claudeAgent",896 modelSelection: {
897 provider: "claudeAgent",
898 model: "claude-opus-4-6",
899 },
839 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,900 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
840 runtimeMode: "approval-required",901 runtimeMode: "approval-required",
841 createdAt: now,902 createdAt: now,
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+48-62
...@@ -3,8 +3,8 @@ import {...@@ -3,8 +3,8 @@ import {
3 CommandId,3 CommandId,
4 DEFAULT_GIT_TEXT_GENERATION_MODEL,4 DEFAULT_GIT_TEXT_GENERATION_MODEL,
5 EventId,5 EventId,
6 type ModelSelection,
6 type OrchestrationEvent,7 type OrchestrationEvent,
7 type ProviderModelOptions,
8 ProviderKind,8 ProviderKind,
9 type ProviderStartOptions,9 type ProviderStartOptions,
10 type OrchestrationSession,10 type OrchestrationSession,
...@@ -13,7 +13,7 @@ import {...@@ -13,7 +13,7 @@ import {
13 type RuntimeMode,13 type RuntimeMode,
14 type TurnId,14 type TurnId,
15} from "@t3tools/contracts";15} 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";
17import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";17import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1818
19import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";19import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
...@@ -26,7 +26,6 @@ import {...@@ -26,7 +26,6 @@ import {
26 ProviderCommandReactor,26 ProviderCommandReactor,
27 type ProviderCommandReactorShape,27 type ProviderCommandReactorShape,
28} from "../Services/ProviderCommandReactor.ts";28} from "../Services/ProviderCommandReactor.ts";
29import { inferProviderForModel } from "@t3tools/shared/model";
3029
31type ProviderIntentEvent = Extract<30type ProviderIntentEvent = Extract<
32 OrchestrationEvent,31 OrchestrationEvent,
...@@ -76,11 +75,6 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";...@@ -76,11 +75,6 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access";
76const WORKTREE_BRANCH_PREFIX = "t3code";75const WORKTREE_BRANCH_PREFIX = "t3code";
77const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);76const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`);
7877
79const sameModelOptions = (
80 left: ProviderModelOptions | undefined,
81 right: ProviderModelOptions | undefined,
82): boolean => JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
83
84function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {78function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean {
85 const error = Cause.squash(cause);79 const error = Cause.squash(cause);
86 if (Schema.is(ProviderAdapterRequestError)(error)) {80 if (Schema.is(ProviderAdapterRequestError)(error)) {
...@@ -158,7 +152,7 @@ const make = Effect.gen(function* () {...@@ -158,7 +152,7 @@ const make = Effect.gen(function* () {
158 );152 );
159153
160 const threadProviderOptions = new Map<string, ProviderStartOptions>();154 const threadProviderOptions = new Map<string, ProviderStartOptions>();
161 const threadModelOptions = new Map<string, ProviderModelOptions>();155 const threadModelSelections = new Map<string, ModelSelection>();
162156
163 const appendProviderFailureActivity = (input: {157 const appendProviderFailureActivity = (input: {
164 readonly threadId: ThreadId;158 readonly threadId: ThreadId;
...@@ -215,9 +209,7 @@ const make = Effect.gen(function* () {...@@ -215,9 +209,7 @@ const make = Effect.gen(function* () {
215 threadId: ThreadId,209 threadId: ThreadId,
216 createdAt: string,210 createdAt: string,
217 options?: {211 options?: {
218 readonly provider?: ProviderKind;212 readonly modelSelection?: ModelSelection;
219 readonly model?: string;
220 readonly modelOptions?: ProviderModelOptions;
221 readonly providerOptions?: ProviderStartOptions;213 readonly providerOptions?: ProviderStartOptions;
222 },214 },
223 ) {215 ) {
...@@ -233,26 +225,20 @@ const make = Effect.gen(function* () {...@@ -233,26 +225,20 @@ const make = Effect.gen(function* () {
233 )225 )
234 ? thread.session.providerName226 ? thread.session.providerName
235 : undefined;227 : undefined;
236 const threadProvider: ProviderKind = currentProvider ?? inferProviderForModel(thread.model);228 const requestedModelSelection = options?.modelSelection;
237 if (options?.provider !== undefined && options.provider !== threadProvider) {229 const threadProvider: ProviderKind = currentProvider ?? thread.modelSelection.provider;
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 }
244 if (230 if (
245 options?.model !== undefined &&231 requestedModelSelection !== undefined &&
246 inferProviderForModel(options.model, threadProvider) !== threadProvider232 requestedModelSelection.provider !== threadProvider
247 ) {233 ) {
248 return yield* new ProviderAdapterRequestError({234 return yield* new ProviderAdapterRequestError({
249 provider: threadProvider,235 provider: threadProvider,
250 method: "thread.turn.start",236 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}'.`,
252 });238 });
253 }239 }
254 const preferredProvider: ProviderKind = currentProvider ?? threadProvider;240 const preferredProvider: ProviderKind = currentProvider ?? threadProvider;
255 const desiredModel = options?.model ?? thread.model;241 const desiredModelSelection = requestedModelSelection ?? thread.modelSelection;
256 const effectiveCwd = resolveThreadWorkspaceCwd({242 const effectiveCwd = resolveThreadWorkspaceCwd({
257 thread,243 thread,
258 projects: readModel.projects,244 projects: readModel.projects,
...@@ -269,12 +255,9 @@ const make = Effect.gen(function* () {...@@ -269,12 +255,9 @@ const make = Effect.gen(function* () {
269 }) =>255 }) =>
270 providerService.startSession(threadId, {256 providerService.startSession(threadId, {
271 threadId,257 threadId,
272 ...((input?.provider ?? preferredProvider)258 ...(preferredProvider ? { provider: preferredProvider } : {}),
273 ? { provider: input?.provider ?? preferredProvider }
274 : {}),
275 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),259 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
276 ...(desiredModel ? { model: desiredModel } : {}),260 modelSelection: desiredModelSelection,
277 ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}),
278 ...(options?.providerOptions !== undefined261 ...(options?.providerOptions !== undefined
279 ? { providerOptions: options.providerOptions }262 ? { providerOptions: options.providerOptions }
280 : {}),263 : {}),
...@@ -303,25 +286,28 @@ const make = Effect.gen(function* () {...@@ -303,25 +286,28 @@ const make = Effect.gen(function* () {
303 if (existingSessionThreadId) {286 if (existingSessionThreadId) {
304 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;287 const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode;
305 const providerChanged =288 const providerChanged =
306 options?.provider !== undefined && options.provider !== currentProvider;289 requestedModelSelection !== undefined &&
290 requestedModelSelection.provider !== currentProvider;
307 const activeSession = yield* resolveActiveSession(existingSessionThreadId);291 const activeSession = yield* resolveActiveSession(existingSessionThreadId);
308 const sessionModelSwitch =292 const sessionModelSwitch =
309 currentProvider === undefined293 currentProvider === undefined
310 ? "in-session"294 ? "in-session"
311 : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch;295 : (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;
313 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";299 const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session";
314 const previousModelOptions = threadModelOptions.get(threadId);300 const previousModelSelection = threadModelSelections.get(threadId);
315 const shouldRestartForModelOptionsChange =301 const shouldRestartForModelSelectionChange =
316 currentProvider === "claudeAgent" &&302 currentProvider === "claudeAgent" &&
317 options?.modelOptions !== undefined &&303 requestedModelSelection !== undefined &&
318 !sameModelOptions(previousModelOptions, options.modelOptions);304 !Equal.equals(previousModelSelection, requestedModelSelection);
319305
320 if (306 if (
321 !runtimeModeChanged &&307 !runtimeModeChanged &&
322 !providerChanged &&308 !providerChanged &&
323 !shouldRestartForModelChange &&309 !shouldRestartForModelChange &&
324 !shouldRestartForModelOptionsChange310 !shouldRestartForModelSelectionChange
325 ) {311 ) {
326 return existingSessionThreadId;312 return existingSessionThreadId;
327 }313 }
...@@ -334,20 +320,19 @@ const make = Effect.gen(function* () {...@@ -334,20 +320,19 @@ const make = Effect.gen(function* () {
334 threadId,320 threadId,
335 existingSessionThreadId,321 existingSessionThreadId,
336 currentProvider,322 currentProvider,
337 desiredProvider: options?.provider ?? currentProvider,323 desiredProvider: desiredModelSelection.provider,
338 currentRuntimeMode: thread.session?.runtimeMode,324 currentRuntimeMode: thread.session?.runtimeMode,
339 desiredRuntimeMode: thread.runtimeMode,325 desiredRuntimeMode: thread.runtimeMode,
340 runtimeModeChanged,326 runtimeModeChanged,
341 providerChanged,327 providerChanged,
342 modelChanged,328 modelChanged,
343 shouldRestartForModelChange,329 shouldRestartForModelChange,
344 shouldRestartForModelOptionsChange,330 shouldRestartForModelSelectionChange,
345 hasResumeCursor: resumeCursor !== undefined,331 hasResumeCursor: resumeCursor !== undefined,
346 });332 });
347 const restartedSession = yield* startProviderSession({333 const restartedSession = yield* startProviderSession(
348 ...(resumeCursor !== undefined ? { resumeCursor } : {}),334 resumeCursor !== undefined ? { resumeCursor } : undefined,
349 ...(options?.provider !== undefined ? { provider: options.provider } : {}),335 );
350 });
351 yield* Effect.logInfo("provider command reactor restarted provider session", {336 yield* Effect.logInfo("provider command reactor restarted provider session", {
352 threadId,337 threadId,
353 previousSessionId: existingSessionThreadId,338 previousSessionId: existingSessionThreadId,
...@@ -359,9 +344,7 @@ const make = Effect.gen(function* () {...@@ -359,9 +344,7 @@ const make = Effect.gen(function* () {
359 return restartedSession.threadId;344 return restartedSession.threadId;
360 }345 }
361346
362 const startedSession = yield* startProviderSession(347 const startedSession = yield* startProviderSession(undefined);
363 options?.provider !== undefined ? { provider: options.provider } : undefined,
364 );
365 yield* bindSessionToThread(startedSession);348 yield* bindSessionToThread(startedSession);
366 return startedSession.threadId;349 return startedSession.threadId;
367 });350 });
...@@ -370,9 +353,7 @@ const make = Effect.gen(function* () {...@@ -370,9 +353,7 @@ const make = Effect.gen(function* () {
370 readonly threadId: ThreadId;353 readonly threadId: ThreadId;
371 readonly messageText: string;354 readonly messageText: string;
372 readonly attachments?: ReadonlyArray<ChatAttachment>;355 readonly attachments?: ReadonlyArray<ChatAttachment>;
373 readonly provider?: ProviderKind;356 readonly modelSelection?: ModelSelection;
374 readonly model?: string;
375 readonly modelOptions?: ProviderModelOptions;
376 readonly providerOptions?: ProviderStartOptions;357 readonly providerOptions?: ProviderStartOptions;
377 readonly interactionMode?: "default" | "plan";358 readonly interactionMode?: "default" | "plan";
378 readonly createdAt: string;359 readonly createdAt: string;
...@@ -382,16 +363,14 @@ const make = Effect.gen(function* () {...@@ -382,16 +363,14 @@ const make = Effect.gen(function* () {
382 return;363 return;
383 }364 }
384 yield* ensureSessionForThread(input.threadId, input.createdAt, {365 yield* ensureSessionForThread(input.threadId, input.createdAt, {
385 ...(input.provider !== undefined ? { provider: input.provider } : {}),366 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
386 ...(input.model !== undefined ? { model: input.model } : {}),
387 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
388 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),367 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
389 });368 });
390 if (input.providerOptions !== undefined) {369 if (input.providerOptions !== undefined) {
391 threadProviderOptions.set(input.threadId, input.providerOptions);370 threadProviderOptions.set(input.threadId, input.providerOptions);
392 }371 }
393 if (input.modelOptions !== undefined) {372 if (input.modelSelection !== undefined) {
394 threadModelOptions.set(input.threadId, input.modelOptions);373 threadModelSelections.set(input.threadId, input.modelSelection);
395 }374 }
396 const normalizedInput = toNonEmptyProviderInput(input.messageText);375 const normalizedInput = toNonEmptyProviderInput(input.messageText);
397 const normalizedAttachments = input.attachments ?? [];376 const normalizedAttachments = input.attachments ?? [];
...@@ -404,14 +383,23 @@ const make = Effect.gen(function* () {...@@ -404,14 +383,23 @@ const make = Effect.gen(function* () {
404 activeSession === undefined383 activeSession === undefined
405 ? "in-session"384 ? "in-session"
406 : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch;385 : (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
409 yield* providerService.sendTurn({398 yield* providerService.sendTurn({
410 threadId: input.threadId,399 threadId: input.threadId,
411 ...(normalizedInput ? { input: normalizedInput } : {}),400 ...(normalizedInput ? { input: normalizedInput } : {}),
412 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),401 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
413 ...(modelForTurn !== undefined ? { model: modelForTurn } : {}),402 ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}),
414 ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}),
415 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),403 ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}),
416 });404 });
417 });405 });
...@@ -524,10 +512,8 @@ const make = Effect.gen(function* () {...@@ -524,10 +512,8 @@ const make = Effect.gen(function* () {
524 threadId: event.payload.threadId,512 threadId: event.payload.threadId,
525 messageText: message.text,513 messageText: message.text,
526 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),514 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
527 ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}),515 ...(event.payload.modelSelection !== undefined
528 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),516 ? { modelSelection: event.payload.modelSelection }
529 ...(event.payload.modelOptions !== undefined
530 ? { modelOptions: event.payload.modelOptions }
531 : {}),517 : {}),
532 ...(event.payload.providerOptions !== undefined518 ...(event.payload.providerOptions !== undefined
533 ? { providerOptions: event.payload.providerOptions }519 ? { providerOptions: event.payload.providerOptions }
...@@ -698,12 +684,12 @@ const make = Effect.gen(function* () {...@@ -698,12 +684,12 @@ const make = Effect.gen(function* () {
698 return;684 return;
699 }685 }
700 const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);686 const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId);
701 const cachedModelOptions = threadModelOptions.get(event.payload.threadId);687 const cachedModelSelection = threadModelSelections.get(event.payload.threadId);
702 yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {688 yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, {
703 ...(cachedProviderOptions !== undefined689 ...(cachedProviderOptions !== undefined
704 ? { providerOptions: cachedProviderOptions }690 ? { providerOptions: cachedProviderOptions }
705 : {}),691 : {}),
706 ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}),692 ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}),
707 });693 });
708 return;694 return;
709 }695 }
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+28-7
...@@ -187,7 +187,10 @@ describe("ProviderRuntimeIngestion", () => {...@@ -187,7 +187,10 @@ describe("ProviderRuntimeIngestion", () => {
187 projectId: asProjectId("project-1"),187 projectId: asProjectId("project-1"),
188 title: "Provider Project",188 title: "Provider Project",
189 workspaceRoot,189 workspaceRoot,
190 defaultModel: "gpt-5-codex",190 defaultModelSelection: {
191 provider: "codex",
192 model: "gpt-5-codex",
193 },
191 createdAt,194 createdAt,
192 }),195 }),
193 );196 );
...@@ -198,7 +201,10 @@ describe("ProviderRuntimeIngestion", () => {...@@ -198,7 +201,10 @@ describe("ProviderRuntimeIngestion", () => {
198 threadId: ThreadId.makeUnsafe("thread-1"),201 threadId: ThreadId.makeUnsafe("thread-1"),
199 projectId: asProjectId("project-1"),202 projectId: asProjectId("project-1"),
200 title: "Thread",203 title: "Thread",
201 model: "gpt-5-codex",204 modelSelection: {
205 provider: "codex",
206 model: "gpt-5-codex",
207 },
202 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,208 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
203 runtimeMode: "approval-required",209 runtimeMode: "approval-required",
204 branch: null,210 branch: null,
...@@ -724,7 +730,10 @@ describe("ProviderRuntimeIngestion", () => {...@@ -724,7 +730,10 @@ describe("ProviderRuntimeIngestion", () => {
724 threadId: sourceThreadId,730 threadId: sourceThreadId,
725 projectId: asProjectId("project-1"),731 projectId: asProjectId("project-1"),
726 title: "Plan Source",732 title: "Plan Source",
727 model: "gpt-5-codex",733 modelSelection: {
734 provider: "codex",
735 model: "gpt-5-codex",
736 },
728 interactionMode: "plan",737 interactionMode: "plan",
729 runtimeMode: "approval-required",738 runtimeMode: "approval-required",
730 branch: null,739 branch: null,
...@@ -756,7 +765,10 @@ describe("ProviderRuntimeIngestion", () => {...@@ -756,7 +765,10 @@ describe("ProviderRuntimeIngestion", () => {
756 threadId: targetThreadId,765 threadId: targetThreadId,
757 projectId: asProjectId("project-1"),766 projectId: asProjectId("project-1"),
758 title: "Plan Target",767 title: "Plan Target",
759 model: "gpt-5-codex",768 modelSelection: {
769 provider: "codex",
770 model: "gpt-5-codex",
771 },
760 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,772 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
761 runtimeMode: "approval-required",773 runtimeMode: "approval-required",
762 branch: null,774 branch: null,
...@@ -905,7 +917,10 @@ describe("ProviderRuntimeIngestion", () => {...@@ -905,7 +917,10 @@ describe("ProviderRuntimeIngestion", () => {
905 threadId: sourceThreadId,917 threadId: sourceThreadId,
906 projectId: asProjectId("project-1"),918 projectId: asProjectId("project-1"),
907 title: "Plan Source",919 title: "Plan Source",
908 model: "gpt-5-codex",920 modelSelection: {
921 provider: "codex",
922 model: "gpt-5-codex",
923 },
909 interactionMode: "plan",924 interactionMode: "plan",
910 runtimeMode: "approval-required",925 runtimeMode: "approval-required",
911 branch: null,926 branch: null,
...@@ -1055,7 +1070,10 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1055,7 +1070,10 @@ describe("ProviderRuntimeIngestion", () => {
1055 threadId: sourceThreadId,1070 threadId: sourceThreadId,
1056 projectId: asProjectId("project-1"),1071 projectId: asProjectId("project-1"),
1057 title: "Plan Source",1072 title: "Plan Source",
1058 model: "gpt-5-codex",1073 modelSelection: {
1074 provider: "codex",
1075 model: "gpt-5-codex",
1076 },
1059 interactionMode: "plan",1077 interactionMode: "plan",
1060 runtimeMode: "approval-required",1078 runtimeMode: "approval-required",
1061 branch: null,1079 branch: null,
...@@ -1087,7 +1105,10 @@ describe("ProviderRuntimeIngestion", () => {...@@ -1087,7 +1105,10 @@ describe("ProviderRuntimeIngestion", () => {
1087 threadId: targetThreadId,1105 threadId: targetThreadId,
1088 projectId: asProjectId("project-1"),1106 projectId: asProjectId("project-1"),
1089 title: "Plan Target",1107 title: "Plan Target",
1090 model: "gpt-5-codex",1108 modelSelection: {
1109 provider: "codex",
1110 model: "gpt-5-codex",
1111 },
1091 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,1112 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
1092 runtimeMode: "approval-required",1113 runtimeMode: "approval-required",
1093 branch: null,1114 branch: null,
apps/server/src/orchestration/commandInvariants.test.ts+24-6
...@@ -28,7 +28,10 @@ const readModel: OrchestrationReadModel = {...@@ -28,7 +28,10 @@ const readModel: OrchestrationReadModel = {
28 id: ProjectId.makeUnsafe("project-a"),28 id: ProjectId.makeUnsafe("project-a"),
29 title: "Project A",29 title: "Project A",
30 workspaceRoot: "/tmp/project-a",30 workspaceRoot: "/tmp/project-a",
31 defaultModel: "gpt-5-codex",31 defaultModelSelection: {
32 provider: "codex",
33 model: "gpt-5-codex",
34 },
32 scripts: [],35 scripts: [],
33 createdAt: now,36 createdAt: now,
34 updatedAt: now,37 updatedAt: now,
...@@ -38,7 +41,10 @@ const readModel: OrchestrationReadModel = {...@@ -38,7 +41,10 @@ const readModel: OrchestrationReadModel = {
38 id: ProjectId.makeUnsafe("project-b"),41 id: ProjectId.makeUnsafe("project-b"),
39 title: "Project B",42 title: "Project B",
40 workspaceRoot: "/tmp/project-b",43 workspaceRoot: "/tmp/project-b",
41 defaultModel: "gpt-5-codex",44 defaultModelSelection: {
45 provider: "codex",
46 model: "gpt-5-codex",
47 },
42 scripts: [],48 scripts: [],
43 createdAt: now,49 createdAt: now,
44 updatedAt: now,50 updatedAt: now,
...@@ -50,7 +56,10 @@ const readModel: OrchestrationReadModel = {...@@ -50,7 +56,10 @@ const readModel: OrchestrationReadModel = {
50 id: ThreadId.makeUnsafe("thread-1"),56 id: ThreadId.makeUnsafe("thread-1"),
51 projectId: ProjectId.makeUnsafe("project-a"),57 projectId: ProjectId.makeUnsafe("project-a"),
52 title: "Thread A",58 title: "Thread A",
53 model: "gpt-5-codex",59 modelSelection: {
60 provider: "codex",
61 model: "gpt-5-codex",
62 },
54 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,63 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
55 runtimeMode: "full-access",64 runtimeMode: "full-access",
56 branch: null,65 branch: null,
...@@ -69,7 +78,10 @@ const readModel: OrchestrationReadModel = {...@@ -69,7 +78,10 @@ const readModel: OrchestrationReadModel = {
69 id: ThreadId.makeUnsafe("thread-2"),78 id: ThreadId.makeUnsafe("thread-2"),
70 projectId: ProjectId.makeUnsafe("project-b"),79 projectId: ProjectId.makeUnsafe("project-b"),
71 title: "Thread B",80 title: "Thread B",
72 model: "gpt-5-codex",81 modelSelection: {
82 provider: "codex",
83 model: "gpt-5-codex",
84 },
73 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,85 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
74 runtimeMode: "full-access",86 runtimeMode: "full-access",
75 branch: null,87 branch: null,
...@@ -144,7 +156,10 @@ describe("commandInvariants", () => {...@@ -144,7 +156,10 @@ describe("commandInvariants", () => {
144 threadId: ThreadId.makeUnsafe("thread-3"),156 threadId: ThreadId.makeUnsafe("thread-3"),
145 projectId: ProjectId.makeUnsafe("project-a"),157 projectId: ProjectId.makeUnsafe("project-a"),
146 title: "new",158 title: "new",
147 model: "gpt-5-codex",159 modelSelection: {
160 provider: "codex",
161 model: "gpt-5-codex",
162 },
148 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,163 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
149 runtimeMode: "full-access",164 runtimeMode: "full-access",
150 branch: null,165 branch: null,
...@@ -165,7 +180,10 @@ describe("commandInvariants", () => {...@@ -165,7 +180,10 @@ describe("commandInvariants", () => {
165 threadId: ThreadId.makeUnsafe("thread-1"),180 threadId: ThreadId.makeUnsafe("thread-1"),
166 projectId: ProjectId.makeUnsafe("project-a"),181 projectId: ProjectId.makeUnsafe("project-a"),
167 title: "dup",182 title: "dup",
168 model: "gpt-5-codex",183 modelSelection: {
184 provider: "codex",
185 model: "gpt-5-codex",
186 },
169 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,187 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
170 runtimeMode: "full-access",188 runtimeMode: "full-access",
171 branch: null,189 branch: null,
apps/server/src/orchestration/decider.projectScripts.test.ts+24-15
...@@ -59,7 +59,7 @@ describe("decider project scripts", () => {...@@ -59,7 +59,7 @@ describe("decider project scripts", () => {
59 projectId: asProjectId("project-scripts"),59 projectId: asProjectId("project-scripts"),
60 title: "Scripts",60 title: "Scripts",
61 workspaceRoot: "/tmp/scripts",61 workspaceRoot: "/tmp/scripts",
62 defaultModel: null,62 defaultModelSelection: null,
63 scripts: [],63 scripts: [],
64 createdAt: now,64 createdAt: now,
65 updatedAt: now,65 updatedAt: now,
...@@ -113,7 +113,7 @@ describe("decider project scripts", () => {...@@ -113,7 +113,7 @@ describe("decider project scripts", () => {
113 projectId: asProjectId("project-1"),113 projectId: asProjectId("project-1"),
114 title: "Project",114 title: "Project",
115 workspaceRoot: "/tmp/project",115 workspaceRoot: "/tmp/project",
116 defaultModel: null,116 defaultModelSelection: null,
117 scripts: [],117 scripts: [],
118 createdAt: now,118 createdAt: now,
119 updatedAt: now,119 updatedAt: now,
...@@ -136,7 +136,10 @@ describe("decider project scripts", () => {...@@ -136,7 +136,10 @@ describe("decider project scripts", () => {
136 threadId: ThreadId.makeUnsafe("thread-1"),136 threadId: ThreadId.makeUnsafe("thread-1"),
137 projectId: asProjectId("project-1"),137 projectId: asProjectId("project-1"),
138 title: "Thread",138 title: "Thread",
139 model: "gpt-5-codex",139 modelSelection: {
140 provider: "codex",
141 model: "gpt-5-codex",
142 },
140 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,143 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
141 runtimeMode: "approval-required",144 runtimeMode: "approval-required",
142 branch: null,145 branch: null,
...@@ -159,10 +162,10 @@ describe("decider project scripts", () => {...@@ -159,10 +162,10 @@ describe("decider project scripts", () => {
159 text: "hello",162 text: "hello",
160 attachments: [],163 attachments: [],
161 },164 },
162 provider: "codex",165 modelSelection: {
163 model: "gpt-5.3-codex",166 provider: "codex",
164 modelOptions: {167 model: "gpt-5.3-codex",
165 codex: {168 options: {
166 reasoningEffort: "high",169 reasoningEffort: "high",
167 fastMode: true,170 fastMode: true,
168 },171 },
...@@ -189,10 +192,10 @@ describe("decider project scripts", () => {...@@ -189,10 +192,10 @@ describe("decider project scripts", () => {
189 expect(turnStartEvent.payload).toMatchObject({192 expect(turnStartEvent.payload).toMatchObject({
190 threadId: ThreadId.makeUnsafe("thread-1"),193 threadId: ThreadId.makeUnsafe("thread-1"),
191 messageId: asMessageId("message-user-1"),194 messageId: asMessageId("message-user-1"),
192 provider: "codex",195 modelSelection: {
193 model: "gpt-5.3-codex",196 provider: "codex",
194 modelOptions: {197 model: "gpt-5.3-codex",
195 codex: {198 options: {
196 reasoningEffort: "high",199 reasoningEffort: "high",
197 fastMode: true,200 fastMode: true,
198 },201 },
...@@ -220,7 +223,7 @@ describe("decider project scripts", () => {...@@ -220,7 +223,7 @@ describe("decider project scripts", () => {
220 projectId: asProjectId("project-1"),223 projectId: asProjectId("project-1"),
221 title: "Project",224 title: "Project",
222 workspaceRoot: "/tmp/project",225 workspaceRoot: "/tmp/project",
223 defaultModel: null,226 defaultModelSelection: null,
224 scripts: [],227 scripts: [],
225 createdAt: now,228 createdAt: now,
226 updatedAt: now,229 updatedAt: now,
...@@ -243,7 +246,10 @@ describe("decider project scripts", () => {...@@ -243,7 +246,10 @@ describe("decider project scripts", () => {
243 threadId: ThreadId.makeUnsafe("thread-1"),246 threadId: ThreadId.makeUnsafe("thread-1"),
244 projectId: asProjectId("project-1"),247 projectId: asProjectId("project-1"),
245 title: "Thread",248 title: "Thread",
246 model: "gpt-5-codex",249 modelSelection: {
250 provider: "codex",
251 model: "gpt-5-codex",
252 },
247 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,253 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
248 runtimeMode: "full-access",254 runtimeMode: "full-access",
249 branch: null,255 branch: null,
...@@ -299,7 +305,7 @@ describe("decider project scripts", () => {...@@ -299,7 +305,7 @@ describe("decider project scripts", () => {
299 projectId: asProjectId("project-1"),305 projectId: asProjectId("project-1"),
300 title: "Project",306 title: "Project",
301 workspaceRoot: "/tmp/project",307 workspaceRoot: "/tmp/project",
302 defaultModel: null,308 defaultModelSelection: null,
303 scripts: [],309 scripts: [],
304 createdAt: now,310 createdAt: now,
305 updatedAt: now,311 updatedAt: now,
...@@ -322,7 +328,10 @@ describe("decider project scripts", () => {...@@ -322,7 +328,10 @@ describe("decider project scripts", () => {
322 threadId: ThreadId.makeUnsafe("thread-1"),328 threadId: ThreadId.makeUnsafe("thread-1"),
323 projectId: asProjectId("project-1"),329 projectId: asProjectId("project-1"),
324 title: "Thread",330 title: "Thread",
325 model: "gpt-5-codex",331 modelSelection: {
332 provider: "codex",
333 model: "gpt-5-codex",
334 },
326 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,335 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
327 runtimeMode: "approval-required",336 runtimeMode: "approval-required",
328 branch: null,337 branch: null,
apps/server/src/orchestration/decider.ts+11-7
...@@ -77,7 +77,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -77,7 +77,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
77 projectId: command.projectId,77 projectId: command.projectId,
78 title: command.title,78 title: command.title,
79 workspaceRoot: command.workspaceRoot,79 workspaceRoot: command.workspaceRoot,
80 defaultModel: command.defaultModel ?? null,80 defaultModelSelection: command.defaultModelSelection ?? null,
81 scripts: [],81 scripts: [],
82 createdAt: command.createdAt,82 createdAt: command.createdAt,
83 updatedAt: command.createdAt,83 updatedAt: command.createdAt,
...@@ -104,7 +104,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -104,7 +104,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
104 projectId: command.projectId,104 projectId: command.projectId,
105 ...(command.title !== undefined ? { title: command.title } : {}),105 ...(command.title !== undefined ? { title: command.title } : {}),
106 ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}),106 ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}),
107 ...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}),107 ...(command.defaultModelSelection !== undefined
108 ? { defaultModelSelection: command.defaultModelSelection }
109 : {}),
108 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),110 ...(command.scripts !== undefined ? { scripts: command.scripts } : {}),
109 updatedAt: occurredAt,111 updatedAt: occurredAt,
110 },112 },
...@@ -156,7 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -156,7 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
156 threadId: command.threadId,158 threadId: command.threadId,
157 projectId: command.projectId,159 projectId: command.projectId,
158 title: command.title,160 title: command.title,
159 model: command.model,161 modelSelection: command.modelSelection,
160 runtimeMode: command.runtimeMode,162 runtimeMode: command.runtimeMode,
161 interactionMode: command.interactionMode,163 interactionMode: command.interactionMode,
162 branch: command.branch,164 branch: command.branch,
...@@ -207,7 +209,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -207,7 +209,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
207 payload: {209 payload: {
208 threadId: command.threadId,210 threadId: command.threadId,
209 ...(command.title !== undefined ? { title: command.title } : {}),211 ...(command.title !== undefined ? { title: command.title } : {}),
210 ...(command.model !== undefined ? { model: command.model } : {}),212 ...(command.modelSelection !== undefined
213 ? { modelSelection: command.modelSelection }
214 : {}),
211 ...(command.branch !== undefined ? { branch: command.branch } : {}),215 ...(command.branch !== undefined ? { branch: command.branch } : {}),
212 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),216 ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}),
213 updatedAt: occurredAt,217 updatedAt: occurredAt,
...@@ -323,9 +327,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"...@@ -323,9 +327,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
323 payload: {327 payload: {
324 threadId: command.threadId,328 threadId: command.threadId,
325 messageId: command.message.messageId,329 messageId: command.message.messageId,
326 ...(command.provider !== undefined ? { provider: command.provider } : {}),330 ...(command.modelSelection !== undefined
327 ...(command.model !== undefined ? { model: command.model } : {}),331 ? { modelSelection: command.modelSelection }
328 ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}),332 : {}),
329 ...(command.providerOptions !== undefined333 ...(command.providerOptions !== undefined
330 ? { providerOptions: command.providerOptions }334 ? { providerOptions: command.providerOptions }
331 : {}),335 : {}),
apps/server/src/orchestration/projector.test.ts+36-9
...@@ -56,7 +56,10 @@ describe("orchestration projector", () => {...@@ -56,7 +56,10 @@ describe("orchestration projector", () => {
56 threadId: "thread-1",56 threadId: "thread-1",
57 projectId: "project-1",57 projectId: "project-1",
58 title: "demo",58 title: "demo",
59 model: "gpt-5-codex",59 modelSelection: {
60 provider: "codex",
61 model: "gpt-5-codex",
62 },
60 runtimeMode: "full-access",63 runtimeMode: "full-access",
61 branch: null,64 branch: null,
62 worktreePath: null,65 worktreePath: null,
...@@ -73,7 +76,10 @@ describe("orchestration projector", () => {...@@ -73,7 +76,10 @@ describe("orchestration projector", () => {
73 id: "thread-1",76 id: "thread-1",
74 projectId: "project-1",77 projectId: "project-1",
75 title: "demo",78 title: "demo",
76 model: "gpt-5-codex",79 modelSelection: {
80 provider: "codex",
81 model: "gpt-5-codex",
82 },
77 runtimeMode: "full-access",83 runtimeMode: "full-access",
78 interactionMode: "default",84 interactionMode: "default",
79 branch: null,85 branch: null,
...@@ -110,7 +116,10 @@ describe("orchestration projector", () => {...@@ -110,7 +116,10 @@ describe("orchestration projector", () => {
110 // missing required threadId116 // missing required threadId
111 projectId: "project-1",117 projectId: "project-1",
112 title: "demo",118 title: "demo",
113 model: "gpt-5-codex",119 modelSelection: {
120 provider: "codex",
121 model: "gpt-5-codex",
122 },
114 branch: null,123 branch: null,
115 worktreePath: null,124 worktreePath: null,
116 createdAt: now,125 createdAt: now,
...@@ -170,7 +179,10 @@ describe("orchestration projector", () => {...@@ -170,7 +179,10 @@ describe("orchestration projector", () => {
170 threadId: "thread-1",179 threadId: "thread-1",
171 projectId: "project-1",180 projectId: "project-1",
172 title: "demo",181 title: "demo",
173 model: "gpt-5.3-codex",182 modelSelection: {
183 provider: "codex",
184 model: "gpt-5.3-codex",
185 },
174 runtimeMode: "full-access",186 runtimeMode: "full-access",
175 branch: null,187 branch: null,
176 worktreePath: null,188 worktreePath: null,
...@@ -233,7 +245,10 @@ describe("orchestration projector", () => {...@@ -233,7 +245,10 @@ describe("orchestration projector", () => {
233 threadId: "thread-1",245 threadId: "thread-1",
234 projectId: "project-1",246 projectId: "project-1",
235 title: "demo",247 title: "demo",
236 model: "gpt-5.3-codex",248 modelSelection: {
249 provider: "codex",
250 model: "gpt-5.3-codex",
251 },
237 runtimeMode: "full-access",252 runtimeMode: "full-access",
238 branch: null,253 branch: null,
239 worktreePath: null,254 worktreePath: null,
...@@ -287,7 +302,10 @@ describe("orchestration projector", () => {...@@ -287,7 +302,10 @@ describe("orchestration projector", () => {
287 threadId: "thread-1",302 threadId: "thread-1",
288 projectId: "project-1",303 projectId: "project-1",
289 title: "demo",304 title: "demo",
290 model: "gpt-5.3-codex",305 modelSelection: {
306 provider: "codex",
307 model: "gpt-5.3-codex",
308 },
291 runtimeMode: "full-access",309 runtimeMode: "full-access",
292 branch: null,310 branch: null,
293 worktreePath: null,311 worktreePath: null,
...@@ -371,7 +389,10 @@ describe("orchestration projector", () => {...@@ -371,7 +389,10 @@ describe("orchestration projector", () => {
371 threadId: "thread-1",389 threadId: "thread-1",
372 projectId: "project-1",390 projectId: "project-1",
373 title: "demo",391 title: "demo",
374 model: "gpt-5.3-codex",392 modelSelection: {
393 provider: "codex",
394 model: "gpt-5.3-codex",
395 },
375 runtimeMode: "full-access",396 runtimeMode: "full-access",
376 branch: null,397 branch: null,
377 worktreePath: null,398 worktreePath: null,
...@@ -583,7 +604,10 @@ describe("orchestration projector", () => {...@@ -583,7 +604,10 @@ describe("orchestration projector", () => {
583 threadId: "thread-revert",604 threadId: "thread-revert",
584 projectId: "project-1",605 projectId: "project-1",
585 title: "demo",606 title: "demo",
586 model: "gpt-5.3-codex",607 modelSelection: {
608 provider: "codex",
609 model: "gpt-5.3-codex",
610 },
587 runtimeMode: "full-access",611 runtimeMode: "full-access",
588 branch: null,612 branch: null,
589 worktreePath: null,613 worktreePath: null,
...@@ -733,7 +757,10 @@ describe("orchestration projector", () => {...@@ -733,7 +757,10 @@ describe("orchestration projector", () => {
733 threadId: "thread-capped",757 threadId: "thread-capped",
734 projectId: "project-1",758 projectId: "project-1",
735 title: "capped",759 title: "capped",
736 model: "gpt-5-codex",760 modelSelection: {
761 provider: "codex",
762 model: "gpt-5-codex",
763 },
737 runtimeMode: "full-access",764 runtimeMode: "full-access",
738 branch: null,765 branch: null,
739 worktreePath: null,766 worktreePath: null,
apps/server/src/orchestration/projector.ts+7-5
...@@ -181,7 +181,7 @@ export function projectEvent(...@@ -181,7 +181,7 @@ export function projectEvent(
181 id: payload.projectId,181 id: payload.projectId,
182 title: payload.title,182 title: payload.title,
183 workspaceRoot: payload.workspaceRoot,183 workspaceRoot: payload.workspaceRoot,
184 defaultModel: payload.defaultModel,184 defaultModelSelection: payload.defaultModelSelection,
185 scripts: payload.scripts,185 scripts: payload.scripts,
186 createdAt: payload.createdAt,186 createdAt: payload.createdAt,
187 updatedAt: payload.updatedAt,187 updatedAt: payload.updatedAt,
...@@ -211,8 +211,8 @@ export function projectEvent(...@@ -211,8 +211,8 @@ export function projectEvent(
211 ...(payload.workspaceRoot !== undefined211 ...(payload.workspaceRoot !== undefined
212 ? { workspaceRoot: payload.workspaceRoot }212 ? { workspaceRoot: payload.workspaceRoot }
213 : {}),213 : {}),
214 ...(payload.defaultModel !== undefined214 ...(payload.defaultModelSelection !== undefined
215 ? { defaultModel: payload.defaultModel }215 ? { defaultModelSelection: payload.defaultModelSelection }
216 : {}),216 : {}),
217 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),217 ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}),
218 updatedAt: payload.updatedAt,218 updatedAt: payload.updatedAt,
...@@ -252,7 +252,7 @@ export function projectEvent(...@@ -252,7 +252,7 @@ export function projectEvent(
252 id: payload.threadId,252 id: payload.threadId,
253 projectId: payload.projectId,253 projectId: payload.projectId,
254 title: payload.title,254 title: payload.title,
255 model: payload.model,255 modelSelection: payload.modelSelection,
256 runtimeMode: payload.runtimeMode,256 runtimeMode: payload.runtimeMode,
257 interactionMode: payload.interactionMode,257 interactionMode: payload.interactionMode,
258 branch: payload.branch,258 branch: payload.branch,
...@@ -295,7 +295,9 @@ export function projectEvent(...@@ -295,7 +295,9 @@ export function projectEvent(
295 ...nextBase,295 ...nextBase,
296 threads: updateThread(nextBase.threads, payload.threadId, {296 threads: updateThread(nextBase.threads, payload.threadId, {
297 ...(payload.title !== undefined ? { title: payload.title } : {}),297 ...(payload.title !== undefined ? { title: payload.title } : {}),
298 ...(payload.model !== undefined ? { model: payload.model } : {}),298 ...(payload.modelSelection !== undefined
299 ? { modelSelection: payload.modelSelection }
300 : {}),
299 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),301 ...(payload.branch !== undefined ? { branch: payload.branch } : {}),
300 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),302 ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}),
301 updatedAt: payload.updatedAt,303 updatedAt: payload.updatedAt,
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+1-1
...@@ -35,7 +35,7 @@ layer("OrchestrationEventStore", (it) => {...@@ -35,7 +35,7 @@ layer("OrchestrationEventStore", (it) => {
35 projectId: ProjectId.makeUnsafe("project-roundtrip"),35 projectId: ProjectId.makeUnsafe("project-roundtrip"),
36 title: "Roundtrip Project",36 title: "Roundtrip Project",
37 workspaceRoot: "/tmp/project-roundtrip",37 workspaceRoot: "/tmp/project-roundtrip",
38 defaultModel: null,38 defaultModelSelection: null,
39 scripts: [],39 scripts: [],
40 createdAt: now,40 createdAt: now,
41 updatedAt: now,41 updatedAt: now,
apps/server/src/persistence/Layers/ProjectionProjects.ts+47-75
...@@ -1,9 +1,9 @@...@@ -1,9 +1,9 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer, Option, Schema, Struct } from "effect";3import { Effect, Layer, Schema, Struct } from "effect";
4
5import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts";
64
5import { ModelSelection, ProjectScript } from "@t3tools/contracts";
6import { toPersistenceSqlError } from "../Errors.ts";
7import {7import {
8 DeleteProjectionProjectInput,8 DeleteProjectionProjectInput,
9 GetProjectionProjectInput,9 GetProjectionProjectInput,
...@@ -11,69 +11,64 @@ import {...@@ -11,69 +11,64 @@ import {
11 ProjectionProjectRepository,11 ProjectionProjectRepository,
12 type ProjectionProjectRepositoryShape,12 type ProjectionProjectRepositoryShape,
13} from "../Services/ProjectionProjects.ts";13} from "../Services/ProjectionProjects.ts";
14import { ProjectScript } from "@t3tools/contracts";
1514
16// Makes sure that the scripts are parsed from the JSON string the DB returns15const ProjectionProjectDbRow = ProjectionProject.mapFields(
17const ProjectionProjectDbRowSchema = ProjectionProject.mapFields(16 Struct.assign({
18 Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }),17 defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)),
18 scripts: Schema.fromJsonString(Schema.Array(ProjectScript)),
19 }),
19);20);
2021type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type;
21function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) {
22 return (cause: unknown) =>
23 Schema.isSchemaError(cause)
24 ? toPersistenceDecodeError(decodeOperation)(cause)
25 : toPersistenceSqlError(sqlOperation)(cause);
26}
2722
28const makeProjectionProjectRepository = Effect.gen(function* () {23const makeProjectionProjectRepository = Effect.gen(function* () {
29 const sql = yield* SqlClient.SqlClient;24 const sql = yield* SqlClient.SqlClient;
3025
31 const upsertProjectionProjectRow = SqlSchema.void({26 const upsertProjectionProjectRow = SqlSchema.void({
32 Request: ProjectionProjectDbRowSchema,27 Request: ProjectionProject,
33 execute: (row) =>28 execute: (row) =>
34 sql`29 sql`
35 INSERT INTO projection_projects (30 INSERT INTO projection_projects (
36 project_id,31 project_id,
37 title,32 title,
38 workspace_root,33 workspace_root,
39 default_model,34 default_model_selection_json,
40 scripts_json,35 scripts_json,
41 created_at,36 created_at,
42 updated_at,37 updated_at,
43 deleted_at38 deleted_at
44 )39 )
45 VALUES (40 VALUES (
46 ${row.projectId},41 ${row.projectId},
47 ${row.title},42 ${row.title},
48 ${row.workspaceRoot},43 ${row.workspaceRoot},
49 ${row.defaultModel},44 ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null},
50 ${row.scripts},45 ${JSON.stringify(row.scripts)},
51 ${row.createdAt},46 ${row.createdAt},
52 ${row.updatedAt},47 ${row.updatedAt},
53 ${row.deletedAt}48 ${row.deletedAt}
54 )49 )
55 ON CONFLICT (project_id)50 ON CONFLICT (project_id)
56 DO UPDATE SET51 DO UPDATE SET
57 title = excluded.title,52 title = excluded.title,
58 workspace_root = excluded.workspace_root,53 workspace_root = excluded.workspace_root,
59 default_model = excluded.default_model,54 default_model_selection_json = excluded.default_model_selection_json,
60 scripts_json = excluded.scripts_json,55 scripts_json = excluded.scripts_json,
61 created_at = excluded.created_at,56 created_at = excluded.created_at,
62 updated_at = excluded.updated_at,57 updated_at = excluded.updated_at,
63 deleted_at = excluded.deleted_at58 deleted_at = excluded.deleted_at
64 `,59 `,
65 });60 });
6661
67 const getProjectionProjectRow = SqlSchema.findOneOption({62 const getProjectionProjectRow = SqlSchema.findOneOption({
68 Request: GetProjectionProjectInput,63 Request: GetProjectionProjectInput,
69 Result: ProjectionProjectDbRowSchema,64 Result: ProjectionProjectDbRow,
70 execute: ({ projectId }) =>65 execute: ({ projectId }) =>
71 sql`66 sql`
72 SELECT67 SELECT
73 project_id AS "projectId",68 project_id AS "projectId",
74 title,69 title,
75 workspace_root AS "workspaceRoot",70 workspace_root AS "workspaceRoot",
76 default_model AS "defaultModel",71 default_model_selection_json AS "defaultModelSelection",
77 scripts_json AS "scripts",72 scripts_json AS "scripts",
78 created_at AS "createdAt",73 created_at AS "createdAt",
79 updated_at AS "updatedAt",74 updated_at AS "updatedAt",
...@@ -85,14 +80,14 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -85,14 +80,14 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
8580
86 const listProjectionProjectRows = SqlSchema.findAll({81 const listProjectionProjectRows = SqlSchema.findAll({
87 Request: Schema.Void,82 Request: Schema.Void,
88 Result: ProjectionProjectDbRowSchema,83 Result: ProjectionProjectDbRow,
89 execute: () =>84 execute: () =>
90 sql`85 sql`
91 SELECT86 SELECT
92 project_id AS "projectId",87 project_id AS "projectId",
93 title,88 title,
94 workspace_root AS "workspaceRoot",89 workspace_root AS "workspaceRoot",
95 default_model AS "defaultModel",90 default_model_selection_json AS "defaultModelSelection",
96 scripts_json AS "scripts",91 scripts_json AS "scripts",
97 created_at AS "createdAt",92 created_at AS "createdAt",
98 updated_at AS "updatedAt",93 updated_at AS "updatedAt",
...@@ -113,40 +108,17 @@ const makeProjectionProjectRepository = Effect.gen(function* () {...@@ -113,40 +108,17 @@ const makeProjectionProjectRepository = Effect.gen(function* () {
113108
114 const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) =>109 const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) =>
115 upsertProjectionProjectRow(row).pipe(110 upsertProjectionProjectRow(row).pipe(
116 Effect.mapError(111 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")),
117 toPersistenceSqlOrDecodeError(
118 "ProjectionProjectRepository.upsert:query",
119 "ProjectionProjectRepository.upsert:encodeRequest",
120 ),
121 ),
122 );112 );
123113
124 const getById: ProjectionProjectRepositoryShape["getById"] = (input) =>114 const getById: ProjectionProjectRepositoryShape["getById"] = (input) =>
125 getProjectionProjectRow(input).pipe(115 getProjectionProjectRow(input).pipe(
126 Effect.mapError(116 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")),
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 ),
139 );117 );
140118
141 const listAll: ProjectionProjectRepositoryShape["listAll"] = () =>119 const listAll: ProjectionProjectRepositoryShape["listAll"] = () =>
142 listProjectionProjectRows().pipe(120 listProjectionProjectRows().pipe(
143 Effect.mapError(121 Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")),
144 toPersistenceSqlOrDecodeError(
145 "ProjectionProjectRepository.listAll:query",
146 "ProjectionProjectRepository.listAll:decodeRows",
147 ),
148 ),
149 Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionProject>>),
150 );122 );
151123
152 const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) =>124 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 @@...@@ -1,6 +1,6 @@
1import * as SqlClient from "effect/unstable/sql/SqlClient";1import * as SqlClient from "effect/unstable/sql/SqlClient";
2import * as SqlSchema from "effect/unstable/sql/SqlSchema";2import * as SqlSchema from "effect/unstable/sql/SqlSchema";
3import { Effect, Layer } from "effect";3import { Effect, Layer, Schema, Struct } from "effect";
44
5import { toPersistenceSqlError } from "../Errors.ts";5import { toPersistenceSqlError } from "../Errors.ts";
6import {6import {
...@@ -11,6 +11,14 @@ import {...@@ -11,6 +11,14 @@ import {
11 ProjectionThreadRepository,11 ProjectionThreadRepository,
12 type ProjectionThreadRepositoryShape,12 type ProjectionThreadRepositoryShape,
13} from "../Services/ProjectionThreads.ts";13} 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
15const makeProjectionThreadRepository = Effect.gen(function* () {23const makeProjectionThreadRepository = Effect.gen(function* () {
16 const sql = yield* SqlClient.SqlClient;24 const sql = yield* SqlClient.SqlClient;
...@@ -23,7 +31,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -23,7 +31,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
23 thread_id,31 thread_id,
24 project_id,32 project_id,
25 title,33 title,
26 model,34 model_selection_json,
27 runtime_mode,35 runtime_mode,
28 interaction_mode,36 interaction_mode,
29 branch,37 branch,
...@@ -37,7 +45,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -37,7 +45,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
37 ${row.threadId},45 ${row.threadId},
38 ${row.projectId},46 ${row.projectId},
39 ${row.title},47 ${row.title},
40 ${row.model},48 ${JSON.stringify(row.modelSelection)},
41 ${row.runtimeMode},49 ${row.runtimeMode},
42 ${row.interactionMode},50 ${row.interactionMode},
43 ${row.branch},51 ${row.branch},
...@@ -51,7 +59,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -51,7 +59,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
51 DO UPDATE SET59 DO UPDATE SET
52 project_id = excluded.project_id,60 project_id = excluded.project_id,
53 title = excluded.title,61 title = excluded.title,
54 model = excluded.model,62 model_selection_json = excluded.model_selection_json,
55 runtime_mode = excluded.runtime_mode,63 runtime_mode = excluded.runtime_mode,
56 interaction_mode = excluded.interaction_mode,64 interaction_mode = excluded.interaction_mode,
57 branch = excluded.branch,65 branch = excluded.branch,
...@@ -65,14 +73,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -65,14 +73,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
6573
66 const getProjectionThreadRow = SqlSchema.findOneOption({74 const getProjectionThreadRow = SqlSchema.findOneOption({
67 Request: GetProjectionThreadInput,75 Request: GetProjectionThreadInput,
68 Result: ProjectionThread,76 Result: ProjectionThreadDbRow,
69 execute: ({ threadId }) =>77 execute: ({ threadId }) =>
70 sql`78 sql`
71 SELECT79 SELECT
72 thread_id AS "threadId",80 thread_id AS "threadId",
73 project_id AS "projectId",81 project_id AS "projectId",
74 title,82 title,
75 model,83 model_selection_json AS "modelSelection",
76 runtime_mode AS "runtimeMode",84 runtime_mode AS "runtimeMode",
77 interaction_mode AS "interactionMode",85 interaction_mode AS "interactionMode",
78 branch,86 branch,
...@@ -88,14 +96,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () {...@@ -88,14 +96,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () {
8896
89 const listProjectionThreadRows = SqlSchema.findAll({97 const listProjectionThreadRows = SqlSchema.findAll({
90 Request: ListProjectionThreadsByProjectInput,98 Request: ListProjectionThreadsByProjectInput,
91 Result: ProjectionThread,99 Result: ProjectionThreadDbRow,
92 execute: ({ projectId }) =>100 execute: ({ projectId }) =>
93 sql`101 sql`
94 SELECT102 SELECT
95 thread_id AS "threadId",103 thread_id AS "threadId",
96 project_id AS "projectId",104 project_id AS "projectId",
97 title,105 title,
98 model,106 model_selection_json AS "modelSelection",
99 runtime_mode AS "runtimeMode",107 runtime_mode AS "runtimeMode",
100 interaction_mode AS "interactionMode",108 interaction_mode AS "interactionMode",
101 branch,109 branch,
apps/server/src/persistence/Layers/Sqlite.ts+1-1
...@@ -31,7 +31,7 @@ const setup = Layer.effectDiscard(...@@ -31,7 +31,7 @@ const setup = Layer.effectDiscard(
31 const sql = yield* SqlClient.SqlClient;31 const sql = yield* SqlClient.SqlClient;
32 yield* sql`PRAGMA journal_mode = WAL;`;32 yield* sql`PRAGMA journal_mode = WAL;`;
33 yield* sql`PRAGMA foreign_keys = ON;`;33 yield* sql`PRAGMA foreign_keys = ON;`;
34 yield* runMigrations;34 yield* runMigrations();
35 }),35 }),
36);36);
3737
apps/server/src/persistence/Migrations.ts+47-24
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
1010
11import * as Migrator from "effect/unstable/sql/Migrator";11import * as Migrator from "effect/unstable/sql/Migrator";
12import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
13import * as Effect from "effect/Effect";
1314
14// Import all migrations statically15// Import all migrations statically
15import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";16import Migration0001 from "./Migrations/001_OrchestrationEvents.ts";
...@@ -27,7 +28,7 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"...@@ -27,7 +28,7 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts"
27import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";28import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts";
28import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts";29import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts";
29import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts";30import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts";
30import { Effect } from "effect";31import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts";
3132
32/**33/**
33 * Migration loader with all migrations defined inline.34 * Migration loader with all migrations defined inline.
...@@ -39,23 +40,33 @@ import { Effect } from "effect";...@@ -39,23 +40,33 @@ import { Effect } from "effect";
39 * Uses Migrator.fromRecord which parses the key format and40 * Uses Migrator.fromRecord which parses the key format and
40 * returns migrations sorted by ID.41 * returns migrations sorted by ID.
41 */42 */
42const loader = Migrator.fromRecord({43export const migrationEntries = [
43 "1_OrchestrationEvents": Migration0001,44 [1, "OrchestrationEvents", Migration0001],
44 "2_OrchestrationCommandReceipts": Migration0002,45 [2, "OrchestrationCommandReceipts", Migration0002],
45 "3_CheckpointDiffBlobs": Migration0003,46 [3, "CheckpointDiffBlobs", Migration0003],
46 "4_ProviderSessionRuntime": Migration0004,47 [4, "ProviderSessionRuntime", Migration0004],
47 "5_Projections": Migration0005,48 [5, "Projections", Migration0005],
48 "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006,49 [6, "ProjectionThreadSessionRuntimeModeColumns", Migration0006],
49 "7_ProjectionThreadMessageAttachments": Migration0007,50 [7, "ProjectionThreadMessageAttachments", Migration0007],
50 "8_ProjectionThreadActivitySequence": Migration0008,51 [8, "ProjectionThreadActivitySequence", Migration0008],
51 "9_ProviderSessionRuntimeMode": Migration0009,52 [9, "ProviderSessionRuntimeMode", Migration0009],
52 "10_ProjectionThreadsRuntimeMode": Migration0010,53 [10, "ProjectionThreadsRuntimeMode", Migration0010],
53 "11_OrchestrationThreadCreatedRuntimeMode": Migration0011,54 [11, "OrchestrationThreadCreatedRuntimeMode", Migration0011],
54 "12_ProjectionThreadsInteractionMode": Migration0012,55 [12, "ProjectionThreadsInteractionMode", Migration0012],
55 "13_ProjectionThreadProposedPlans": Migration0013,56 [13, "ProjectionThreadProposedPlans", Migration0013],
56 "14_ProjectionThreadProposedPlanImplementation": Migration0014,57 [14, "ProjectionThreadProposedPlanImplementation", Migration0014],
57 "15_ProjectionTurnsSourceProposedPlan": Migration0015,58 [15, "ProjectionTurnsSourceProposedPlan", Migration0015],
58});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
60/**71/**
61 * Migrator run function - no schema dumping needed72 * Migrator run function - no schema dumping needed
...@@ -63,6 +74,10 @@ const loader = Migrator.fromRecord({...@@ -63,6 +74,10 @@ const loader = Migrator.fromRecord({
63 */74 */
64const run = Migrator.make({});75const run = Migrator.make({});
6576
77export interface RunMigrationsOptions {
78 readonly toMigrationInclusive?: number | undefined;
79}
80
66/**81/**
67 * Run all pending migrations.82 * Run all pending migrations.
68 *83 *
...@@ -73,11 +88,19 @@ const run = Migrator.make({});...@@ -73,11 +88,19 @@ const run = Migrator.make({});
73 *88 *
74 * @returns Effect containing array of executed migrations89 * @returns Effect containing array of executed migrations
75 */90 */
76export const runMigrations = Effect.gen(function* () {91export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) =>
77 yield* Effect.log("Running migrations...");92 Effect.gen(function* () {
78 yield* run({ loader });93 yield* Effect.log(
79 yield* Effect.log("Migrations ran successfully");94 toMigrationInclusive === undefined
80});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
82/**105/**
83 * Layer that runs migrations when the layer is built.106 * Layer that runs migrations when the layer is built.
...@@ -96,4 +119,4 @@ export const runMigrations = Effect.gen(function* () {...@@ -96,4 +119,4 @@ export const runMigrations = Effect.gen(function* () {
96 * )119 * )
97 * ```120 * ```
98 */121 */
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 @@...@@ -6,7 +6,7 @@
6 *6 *
7 * @module ProjectionProjectRepository7 * @module ProjectionProjectRepository
8 */8 */
9import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts";9import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts";
10import { Option, Schema, ServiceMap } from "effect";10import { Option, Schema, ServiceMap } from "effect";
11import type { Effect } from "effect";11import type { Effect } from "effect";
1212
...@@ -16,7 +16,7 @@ export const ProjectionProject = Schema.Struct({...@@ -16,7 +16,7 @@ export const ProjectionProject = Schema.Struct({
16 projectId: ProjectId,16 projectId: ProjectId,
17 title: Schema.String,17 title: Schema.String,
18 workspaceRoot: Schema.String,18 workspaceRoot: Schema.String,
19 defaultModel: Schema.NullOr(Schema.String),19 defaultModelSelection: Schema.NullOr(ModelSelection),
20 scripts: Schema.Array(ProjectScript),20 scripts: Schema.Array(ProjectScript),
21 createdAt: IsoDateTime,21 createdAt: IsoDateTime,
22 updatedAt: IsoDateTime,22 updatedAt: IsoDateTime,
apps/server/src/persistence/Services/ProjectionThreads.ts+2-1
...@@ -8,6 +8,7 @@...@@ -8,6 +8,7 @@
8 */8 */
9import {9import {
10 IsoDateTime,10 IsoDateTime,
11 ModelSelection,
11 ProjectId,12 ProjectId,
12 ProviderInteractionMode,13 ProviderInteractionMode,
13 RuntimeMode,14 RuntimeMode,
...@@ -23,7 +24,7 @@ export const ProjectionThread = Schema.Struct({...@@ -23,7 +24,7 @@ export const ProjectionThread = Schema.Struct({
23 threadId: ThreadId,24 threadId: ThreadId,
24 projectId: ProjectId,25 projectId: ProjectId,
25 title: Schema.String,26 title: Schema.String,
26 model: Schema.String,27 modelSelection: ModelSelection,
27 runtimeMode: RuntimeMode,28 runtimeMode: RuntimeMode,
28 interactionMode: ProviderInteractionMode,29 interactionMode: ProviderInteractionMode,
29 branch: Schema.NullOr(Schema.String),30 branch: Schema.NullOr(Schema.String),
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+52-37
...@@ -332,13 +332,14 @@ describe("ClaudeAdapterLive", () => {...@@ -332,13 +332,14 @@ describe("ClaudeAdapterLive", () => {
332 yield* adapter.startSession({332 yield* adapter.startSession({
333 threadId: THREAD_ID,333 threadId: THREAD_ID,
334 provider: "claudeAgent",334 provider: "claudeAgent",
335 model: "claude-opus-4-6",335 modelSelection: {
336 runtimeMode: "full-access",336 provider: "claudeAgent",
337 modelOptions: {337 model: "claude-opus-4-6",
338 claudeAgent: {338 options: {
339 effort: "max",339 effort: "max",
340 },340 },
341 },341 },
342 runtimeMode: "full-access",
342 });343 });
343344
344 const createInput = harness.getLastCreateQueryInput();345 const createInput = harness.getLastCreateQueryInput();
...@@ -356,13 +357,14 @@ describe("ClaudeAdapterLive", () => {...@@ -356,13 +357,14 @@ describe("ClaudeAdapterLive", () => {
356 yield* adapter.startSession({357 yield* adapter.startSession({
357 threadId: THREAD_ID,358 threadId: THREAD_ID,
358 provider: "claudeAgent",359 provider: "claudeAgent",
359 model: "claude-sonnet-4-6",360 modelSelection: {
360 runtimeMode: "full-access",361 provider: "claudeAgent",
361 modelOptions: {362 model: "claude-sonnet-4-6",
362 claudeAgent: {363 options: {
363 effort: "max",364 effort: "max",
364 },365 },
365 },366 },
367 runtimeMode: "full-access",
366 });368 });
367369
368 const createInput = harness.getLastCreateQueryInput();370 const createInput = harness.getLastCreateQueryInput();
...@@ -380,13 +382,14 @@ describe("ClaudeAdapterLive", () => {...@@ -380,13 +382,14 @@ describe("ClaudeAdapterLive", () => {
380 yield* adapter.startSession({382 yield* adapter.startSession({
381 threadId: THREAD_ID,383 threadId: THREAD_ID,
382 provider: "claudeAgent",384 provider: "claudeAgent",
383 model: "claude-haiku-4-5",385 modelSelection: {
384 runtimeMode: "full-access",386 provider: "claudeAgent",
385 modelOptions: {387 model: "claude-haiku-4-5",
386 claudeAgent: {388 options: {
387 effort: "high",389 effort: "high",
388 },390 },
389 },391 },
392 runtimeMode: "full-access",
390 });393 });
391394
392 const createInput = harness.getLastCreateQueryInput();395 const createInput = harness.getLastCreateQueryInput();
...@@ -404,13 +407,14 @@ describe("ClaudeAdapterLive", () => {...@@ -404,13 +407,14 @@ describe("ClaudeAdapterLive", () => {
404 yield* adapter.startSession({407 yield* adapter.startSession({
405 threadId: THREAD_ID,408 threadId: THREAD_ID,
406 provider: "claudeAgent",409 provider: "claudeAgent",
407 model: "claude-haiku-4-5",410 modelSelection: {
408 runtimeMode: "full-access",411 provider: "claudeAgent",
409 modelOptions: {412 model: "claude-haiku-4-5",
410 claudeAgent: {413 options: {
411 thinking: false,414 thinking: false,
412 },415 },
413 },416 },
417 runtimeMode: "full-access",
414 });418 });
415419
416 const createInput = harness.getLastCreateQueryInput();420 const createInput = harness.getLastCreateQueryInput();
...@@ -430,13 +434,14 @@ describe("ClaudeAdapterLive", () => {...@@ -430,13 +434,14 @@ describe("ClaudeAdapterLive", () => {
430 yield* adapter.startSession({434 yield* adapter.startSession({
431 threadId: THREAD_ID,435 threadId: THREAD_ID,
432 provider: "claudeAgent",436 provider: "claudeAgent",
433 model: "claude-sonnet-4-6",437 modelSelection: {
434 runtimeMode: "full-access",438 provider: "claudeAgent",
435 modelOptions: {439 model: "claude-sonnet-4-6",
436 claudeAgent: {440 options: {
437 thinking: false,441 thinking: false,
438 },442 },
439 },443 },
444 runtimeMode: "full-access",
440 });445 });
441446
442 const createInput = harness.getLastCreateQueryInput();447 const createInput = harness.getLastCreateQueryInput();
...@@ -454,13 +459,14 @@ describe("ClaudeAdapterLive", () => {...@@ -454,13 +459,14 @@ describe("ClaudeAdapterLive", () => {
454 yield* adapter.startSession({459 yield* adapter.startSession({
455 threadId: THREAD_ID,460 threadId: THREAD_ID,
456 provider: "claudeAgent",461 provider: "claudeAgent",
457 model: "claude-opus-4-6",462 modelSelection: {
458 runtimeMode: "full-access",463 provider: "claudeAgent",
459 modelOptions: {464 model: "claude-opus-4-6",
460 claudeAgent: {465 options: {
461 fastMode: true,466 fastMode: true,
462 },467 },
463 },468 },
469 runtimeMode: "full-access",
464 });470 });
465471
466 const createInput = harness.getLastCreateQueryInput();472 const createInput = harness.getLastCreateQueryInput();
...@@ -480,13 +486,14 @@ describe("ClaudeAdapterLive", () => {...@@ -480,13 +486,14 @@ describe("ClaudeAdapterLive", () => {
480 yield* adapter.startSession({486 yield* adapter.startSession({
481 threadId: THREAD_ID,487 threadId: THREAD_ID,
482 provider: "claudeAgent",488 provider: "claudeAgent",
483 model: "claude-sonnet-4-6",489 modelSelection: {
484 runtimeMode: "full-access",490 provider: "claudeAgent",
485 modelOptions: {491 model: "claude-sonnet-4-6",
486 claudeAgent: {492 options: {
487 fastMode: true,493 fastMode: true,
488 },494 },
489 },495 },
496 runtimeMode: "full-access",
490 });497 });
491498
492 const createInput = harness.getLastCreateQueryInput();499 const createInput = harness.getLastCreateQueryInput();
...@@ -504,22 +511,24 @@ describe("ClaudeAdapterLive", () => {...@@ -504,22 +511,24 @@ describe("ClaudeAdapterLive", () => {
504 const session = yield* adapter.startSession({511 const session = yield* adapter.startSession({
505 threadId: THREAD_ID,512 threadId: THREAD_ID,
506 provider: "claudeAgent",513 provider: "claudeAgent",
507 model: "claude-sonnet-4-6",514 modelSelection: {
508 runtimeMode: "full-access",515 provider: "claudeAgent",
509 modelOptions: {516 model: "claude-sonnet-4-6",
510 claudeAgent: {517 options: {
511 effort: "ultrathink",518 effort: "ultrathink",
512 },519 },
513 },520 },
521 runtimeMode: "full-access",
514 });522 });
515523
516 yield* adapter.sendTurn({524 yield* adapter.sendTurn({
517 threadId: session.threadId,525 threadId: session.threadId,
518 input: "Investigate the edge cases",526 input: "Investigate the edge cases",
519 attachments: [],527 attachments: [],
520 model: "claude-sonnet-4-6",528 modelSelection: {
521 modelOptions: {529 provider: "claudeAgent",
522 claudeAgent: {530 model: "claude-sonnet-4-6",
531 options: {
523 effort: "ultrathink",532 effort: "ultrathink",
524 },533 },
525 },534 },
...@@ -613,7 +622,10 @@ describe("ClaudeAdapterLive", () => {...@@ -613,7 +622,10 @@ describe("ClaudeAdapterLive", () => {
613 const session = yield* adapter.startSession({622 const session = yield* adapter.startSession({
614 threadId: THREAD_ID,623 threadId: THREAD_ID,
615 provider: "claudeAgent",624 provider: "claudeAgent",
616 model: "claude-sonnet-4-5",625 modelSelection: {
626 provider: "claudeAgent",
627 model: "claude-sonnet-4-5",
628 },
617 runtimeMode: "full-access",629 runtimeMode: "full-access",
618 });630 });
619631
...@@ -2360,7 +2372,10 @@ describe("ClaudeAdapterLive", () => {...@@ -2360,7 +2372,10 @@ describe("ClaudeAdapterLive", () => {
2360 yield* adapter.sendTurn({2372 yield* adapter.sendTurn({
2361 threadId: session.threadId,2373 threadId: session.threadId,
2362 input: "hello",2374 input: "hello",
2363 model: "claude-opus-4-6",2375 modelSelection: {
2376 provider: "claudeAgent",
2377 model: "claude-opus-4-6",
2378 },
2364 attachments: [],2379 attachments: [],
2365 });2380 });
23662381
apps/server/src/provider/Layers/ClaudeAdapter.ts+64-36
...@@ -38,15 +38,13 @@ import {...@@ -38,15 +38,13 @@ import {
38 ThreadId,38 ThreadId,
39 TurnId,39 TurnId,
40 type UserInputQuestion,40 type UserInputQuestion,
41 ClaudeCodeEffort,
41} from "@t3tools/contracts";42} from "@t3tools/contracts";
42import {43import {
44 hasEffortLevel,
43 applyClaudePromptEffortPrefix,45 applyClaudePromptEffortPrefix,
44 getEffectiveClaudeCodeEffort,46 getModelCapabilities,
45 getReasoningEffortOptions,47 trimOrNull,
46 resolveReasoningEffortForProvider,
47 supportsClaudeFastMode,
48 supportsClaudeThinkingToggle,
49 supportsClaudeUltrathinkKeyword,
50} from "@t3tools/shared/model";48} from "@t3tools/shared/model";
51import {49import {
52 Cause,50 Cause,
...@@ -158,6 +156,7 @@ interface ClaudeSessionContext {...@@ -158,6 +156,7 @@ interface ClaudeSessionContext {
158 readonly inFlightTools: Map<number, ToolInFlight>;156 readonly inFlightTools: Map<number, ToolInFlight>;
159 turnState: ClaudeTurnState | undefined;157 turnState: ClaudeTurnState | undefined;
160 lastKnownContextWindow: number | undefined;158 lastKnownContextWindow: number | undefined;
159 lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined;
161 lastAssistantUuid: string | undefined;160 lastAssistantUuid: string | undefined;
162 lastThreadStartedId: string | undefined;161 lastThreadStartedId: string | undefined;
163 stopped: boolean;162 stopped: boolean;
...@@ -211,6 +210,15 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray...@@ -211,6 +210,15 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray
211 return squashed.length > 0 ? [squashed] : [];210 return squashed.length > 0 ? [squashed] : [];
212}211}
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
214function isClaudeInterruptedMessage(message: string): boolean {222function isClaudeInterruptedMessage(message: string): boolean {
215 const normalized = message.toLowerCase();223 const normalized = message.toLowerCase();
216 return (224 return (
...@@ -512,15 +520,16 @@ const CLAUDE_SETTING_SOURCES = [...@@ -512,15 +520,16 @@ const CLAUDE_SETTING_SOURCES = [
512] as const satisfies ReadonlyArray<SettingSource>;520] as const satisfies ReadonlyArray<SettingSource>;
513521
514function buildPromptText(input: ProviderSendTurnInput): string {522function buildPromptText(input: ProviderSendTurnInput): string {
515 const requestedEffort = resolveReasoningEffortForProvider(523 const rawEffort =
516 "claudeAgent",524 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null;
517 input.modelOptions?.claudeAgent?.effort ?? null,525 const requestedEffort = trimOrNull(rawEffort);
518 );526 const claudeModel =
519 const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model);527 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined;
528 const caps = getModelCapabilities("claudeAgent", claudeModel);
520 const promptEffort =529 const promptEffort =
521 requestedEffort === "ultrathink" && supportsClaudeUltrathinkKeyword(input.model)530 requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0
522 ? "ultrathink"531 ? "ultrathink"
523 : requestedEffort && supportedEffortOptions.includes(requestedEffort)532 : requestedEffort && hasEffortLevel(caps, requestedEffort)
524 ? requestedEffort533 ? requestedEffort
525 : null;534 : null;
526 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);535 return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort);
...@@ -1368,14 +1377,33 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -1368,14 +1377,33 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
1368 const resultUsage =1377 const resultUsage =
1369 result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined;1378 result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined;
1370 const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage);1379 const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage);
1371 const usageSnapshot = normalizeClaudeTokenUsage(
1372 resultUsage,
1373 resultContextWindow ?? context.lastKnownContextWindow,
1374 );
1375 if (resultContextWindow !== undefined) {1380 if (resultContextWindow !== undefined) {
1376 context.lastKnownContextWindow = resultContextWindow;1381 context.lastKnownContextWindow = resultContextWindow;
1377 }1382 }
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
1379 const turnState = context.turnState;1407 const turnState = context.turnState;
1380 if (!turnState) {1408 if (!turnState) {
1381 if (usageSnapshot) {1409 if (usageSnapshot) {
...@@ -2051,6 +2079,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2051,6 +2079,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2051 context.lastKnownContextWindow,2079 context.lastKnownContextWindow,
2052 );2080 );
2053 if (normalizedUsage) {2081 if (normalizedUsage) {
2082 context.lastKnownTokenUsage = normalizedUsage;
2054 const usageStamp = yield* makeEventStamp();2083 const usageStamp = yield* makeEventStamp();
2055 yield* offerRuntimeEvent({2084 yield* offerRuntimeEvent({
2056 ...base,2085 ...base,
...@@ -2082,6 +2111,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2082,6 +2111,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2082 context.lastKnownContextWindow,2111 context.lastKnownContextWindow,
2083 );2112 );
2084 if (normalizedUsage) {2113 if (normalizedUsage) {
2114 context.lastKnownTokenUsage = normalizedUsage;
2085 const usageStamp = yield* makeEventStamp();2115 const usageStamp = yield* makeEventStamp();
2086 yield* offerRuntimeEvent({2116 yield* offerRuntimeEvent({
2087 ...base,2117 ...base,
...@@ -2698,21 +2728,16 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2698,21 +2728,16 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2698 );2728 );
26992729
2700 const providerOptions = input.providerOptions?.claudeAgent;2730 const providerOptions = input.providerOptions?.claudeAgent;
2701 const requestedEffort = resolveReasoningEffortForProvider(2731 const modelSelection =
2702 "claudeAgent",2732 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
2703 input.modelOptions?.claudeAgent?.effort ?? null,2733 const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null);
2704 );2734 const caps = getModelCapabilities("claudeAgent", modelSelection?.model);
2705 const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model);
2706 const effort =2735 const effort =
2707 requestedEffort && supportedEffortOptions.includes(requestedEffort)2736 requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null;
2708 ? requestedEffort2737 const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode;
2709 : null;
2710 const fastMode =
2711 input.modelOptions?.claudeAgent?.fastMode === true && supportsClaudeFastMode(input.model);
2712 const thinking =2738 const thinking =
2713 typeof input.modelOptions?.claudeAgent?.thinking === "boolean" &&2739 typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle
2714 supportsClaudeThinkingToggle(input.model)2740 ? modelSelection.options.thinking
2715 ? input.modelOptions.claudeAgent.thinking
2716 : undefined;2741 : undefined;
2717 const effectiveEffort = getEffectiveClaudeCodeEffort(effort);2742 const effectiveEffort = getEffectiveClaudeCodeEffort(effort);
2718 const permissionMode =2743 const permissionMode =
...@@ -2725,7 +2750,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2725,7 +2750,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
27252750
2726 const queryOptions: ClaudeQueryOptions = {2751 const queryOptions: ClaudeQueryOptions = {
2727 ...(input.cwd ? { cwd: input.cwd } : {}),2752 ...(input.cwd ? { cwd: input.cwd } : {}),
2728 ...(input.model ? { model: input.model } : {}),2753 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
2729 pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude",2754 pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude",
2730 settingSources: [...CLAUDE_SETTING_SOURCES],2755 settingSources: [...CLAUDE_SETTING_SOURCES],
2731 ...(effectiveEffort ? { effort: effectiveEffort } : {}),2756 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
...@@ -2766,7 +2791,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2766,7 +2791,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2766 status: "ready",2791 status: "ready",
2767 runtimeMode: input.runtimeMode,2792 runtimeMode: input.runtimeMode,
2768 ...(input.cwd ? { cwd: input.cwd } : {}),2793 ...(input.cwd ? { cwd: input.cwd } : {}),
2769 ...(input.model ? { model: input.model } : {}),2794 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
2770 ...(threadId ? { threadId } : {}),2795 ...(threadId ? { threadId } : {}),
2771 resumeCursor: {2796 resumeCursor: {
2772 ...(threadId ? { threadId } : {}),2797 ...(threadId ? { threadId } : {}),
...@@ -2794,6 +2819,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2794,6 +2819,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2794 inFlightTools,2819 inFlightTools,
2795 turnState: undefined,2820 turnState: undefined,
2796 lastKnownContextWindow: undefined,2821 lastKnownContextWindow: undefined,
2822 lastKnownTokenUsage: undefined,
2797 lastAssistantUuid: resumeState?.resumeSessionAt,2823 lastAssistantUuid: resumeState?.resumeSessionAt,
2798 lastThreadStartedId: undefined,2824 lastThreadStartedId: undefined,
2799 stopped: false,2825 stopped: false,
...@@ -2821,7 +2847,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2821,7 +2847,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2821 threadId,2847 threadId,
2822 payload: {2848 payload: {
2823 config: {2849 config: {
2824 ...(input.model ? { model: input.model } : {}),2850 ...(modelSelection?.model ? { model: modelSelection.model } : {}),
2825 ...(input.cwd ? { cwd: input.cwd } : {}),2851 ...(input.cwd ? { cwd: input.cwd } : {}),
2826 ...(effectiveEffort ? { effort: effectiveEffort } : {}),2852 ...(effectiveEffort ? { effort: effectiveEffort } : {}),
2827 ...(permissionMode ? { permissionMode } : {}),2853 ...(permissionMode ? { permissionMode } : {}),
...@@ -2867,6 +2893,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2867,6 +2893,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2867 const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) =>2893 const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) =>
2868 Effect.gen(function* () {2894 Effect.gen(function* () {
2869 const context = yield* requireSession(input.threadId);2895 const context = yield* requireSession(input.threadId);
2896 const modelSelection =
2897 input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined;
28702898
2871 if (context.turnState) {2899 if (context.turnState) {
2872 // Auto-close a stale synthetic turn (from background agent responses2900 // Auto-close a stale synthetic turn (from background agent responses
...@@ -2874,9 +2902,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2874,9 +2902,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2874 yield* completeTurn(context, "completed");2902 yield* completeTurn(context, "completed");
2875 }2903 }
28762904
2877 if (input.model) {2905 if (modelSelection?.model) {
2878 yield* Effect.tryPromise({2906 yield* Effect.tryPromise({
2879 try: () => context.query.setModel(input.model),2907 try: () => context.query.setModel(modelSelection.model),
2880 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),2908 catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause),
2881 });2909 });
2882 }2910 }
...@@ -2926,7 +2954,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {...@@ -2926,7 +2954,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) {
2926 createdAt: turnStartedStamp.createdAt,2954 createdAt: turnStartedStamp.createdAt,
2927 threadId: context.session.threadId,2955 threadId: context.session.threadId,
2928 turnId,2956 turnId,
2929 payload: input.model ? { model: input.model } : {},2957 payload: modelSelection?.model ? { model: modelSelection.model } : {},
2930 providerRefs: {},2958 providerRefs: {},
2931 });2959 });
29322960
apps/server/src/provider/Layers/CodexAdapter.test.ts+8-6
...@@ -188,9 +188,10 @@ validationLayer("CodexAdapterLive validation", (it) => {...@@ -188,9 +188,10 @@ validationLayer("CodexAdapterLive validation", (it) => {
188 yield* adapter.startSession({188 yield* adapter.startSession({
189 provider: "codex",189 provider: "codex",
190 threadId: asThreadId("thread-1"),190 threadId: asThreadId("thread-1"),
191 model: "gpt-5.3-codex",191 modelSelection: {
192 modelOptions: {192 provider: "codex",
193 codex: {193 model: "gpt-5.3-codex",
194 options: {
194 fastMode: true,195 fastMode: true,
195 },196 },
196 },197 },
...@@ -256,9 +257,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {...@@ -256,9 +257,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
256 adapter.sendTurn({257 adapter.sendTurn({
257 threadId: asThreadId("sess-missing"),258 threadId: asThreadId("sess-missing"),
258 input: "hello",259 input: "hello",
259 model: "gpt-5.3-codex",260 modelSelection: {
260 modelOptions: {261 provider: "codex",
261 codex: {262 model: "gpt-5.3-codex",
263 options: {
262 reasoningEffort: "high",264 reasoningEffort: "high",
263 fastMode: true,265 fastMode: true,
264 },266 },
apps/server/src/provider/Layers/CodexAdapter.ts+16-6
...@@ -1361,8 +1361,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1361,8 +1361,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1361 ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),1361 ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}),
1362 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),1362 ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}),
1363 runtimeMode: input.runtimeMode,1363 runtimeMode: input.runtimeMode,
1364 ...(input.model !== undefined ? { model: input.model } : {}),1364 ...(input.modelSelection?.provider === "codex"
1365 ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}),1365 ? { model: input.modelSelection.model }
1366 : {}),
1367 ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode
1368 ? { serviceTier: "fast" }
1369 : {}),
1366 };1370 };
13671371
1368 return Effect.tryPromise({1372 return Effect.tryPromise({
...@@ -1418,11 +1422,17 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>...@@ -1418,11 +1422,17 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
1418 const managerInput = {1422 const managerInput = {
1419 threadId: input.threadId,1423 threadId: input.threadId,
1420 ...(input.input !== undefined ? { input: input.input } : {}),1424 ...(input.input !== undefined ? { input: input.input } : {}),
1421 ...(input.model !== undefined ? { model: input.model } : {}),1425 ...(input.modelSelection?.provider === "codex"
1422 ...(input.modelOptions?.codex?.reasoningEffort !== undefined1426 ? { model: input.modelSelection.model }
1423 ? { effort: input.modelOptions.codex.reasoningEffort }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" }
1424 : {}),1435 : {}),
1425 ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}),
1426 ...(input.interactionMode !== undefined1436 ...(input.interactionMode !== undefined
1427 ? { interactionMode: input.interactionMode }1437 ? { interactionMode: input.interactionMode }
1428 : {}),1438 : {}),
apps/server/src/provider/Layers/ProviderService.test.ts+9-5
...@@ -633,8 +633,10 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -633,8 +633,10 @@ routing.layer("ProviderServiceLive routing", (it) => {
633 provider: "claudeAgent",633 provider: "claudeAgent",
634 threadId: asThreadId("thread-claude-send-turn"),634 threadId: asThreadId("thread-claude-send-turn"),
635 cwd: "/tmp/project-claude-send-turn",635 cwd: "/tmp/project-claude-send-turn",
636 modelOptions: {636 modelSelection: {
637 claudeAgent: {637 provider: "claudeAgent",
638 model: "claude-opus-4-6",
639 options: {
638 effort: "max",640 effort: "max",
639 },641 },
640 },642 },
...@@ -658,14 +660,16 @@ routing.layer("ProviderServiceLive routing", (it) => {...@@ -658,14 +660,16 @@ routing.layer("ProviderServiceLive routing", (it) => {
658 const startPayload = resumedStartInput as {660 const startPayload = resumedStartInput as {
659 provider?: string;661 provider?: string;
660 cwd?: string;662 cwd?: string;
661 modelOptions?: unknown;663 modelSelection?: unknown;
662 resumeCursor?: unknown;664 resumeCursor?: unknown;
663 threadId?: string;665 threadId?: string;
664 };666 };
665 assert.equal(startPayload.provider, "claudeAgent");667 assert.equal(startPayload.provider, "claudeAgent");
666 assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn");668 assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn");
667 assert.deepEqual(startPayload.modelOptions, {669 assert.deepEqual(startPayload.modelSelection, {
668 claudeAgent: {670 provider: "claudeAgent",
671 model: "claude-opus-4-6",
672 options: {
669 effort: "max",673 effort: "max",
670 },674 },
671 });675 });
apps/server/src/provider/Layers/ProviderService.ts+16-13
...@@ -10,6 +10,7 @@...@@ -10,6 +10,7 @@
10 * @module ProviderServiceLive10 * @module ProviderServiceLive
11 */11 */
12import {12import {
13 ModelSelection,
13 NonNegativeInt,14 NonNegativeInt,
14 ThreadId,15 ThreadId,
15 ProviderInterruptTurnInput,16 ProviderInterruptTurnInput,
...@@ -89,7 +90,7 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st...@@ -89,7 +90,7 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st
89function toRuntimePayloadFromSession(90function toRuntimePayloadFromSession(
90 session: ProviderSession,91 session: ProviderSession,
91 extra?: {92 extra?: {
92 readonly modelOptions?: unknown;93 readonly modelSelection?: unknown;
93 readonly providerOptions?: unknown;94 readonly providerOptions?: unknown;
94 readonly lastRuntimeEvent?: string;95 readonly lastRuntimeEvent?: string;
95 readonly lastRuntimeEventAt?: string;96 readonly lastRuntimeEventAt?: string;
...@@ -100,7 +101,7 @@ function toRuntimePayloadFromSession(...@@ -100,7 +101,7 @@ function toRuntimePayloadFromSession(
100 model: session.model ?? null,101 model: session.model ?? null,
101 activeTurnId: session.activeTurnId ?? null,102 activeTurnId: session.activeTurnId ?? null,
102 lastError: session.lastError ?? null,103 lastError: session.lastError ?? null,
103 ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}),104 ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}),
104 ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}),105 ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}),
105 ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),106 ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}),
106 ...(extra?.lastRuntimeEventAt !== undefined107 ...(extra?.lastRuntimeEventAt !== undefined
...@@ -109,15 +110,14 @@ function toRuntimePayloadFromSession(...@@ -109,15 +110,14 @@ function toRuntimePayloadFromSession(
109 };110 };
110}111}
111112
112function readPersistedModelOptions(113function readPersistedModelSelection(
113 runtimePayload: ProviderRuntimeBinding["runtimePayload"],114 runtimePayload: ProviderRuntimeBinding["runtimePayload"],
114): Record<string, unknown> | undefined {115): ModelSelection | undefined {
115 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {116 if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) {
116 return undefined;117 return undefined;
117 }118 }
118 const raw = "modelOptions" in runtimePayload ? runtimePayload.modelOptions : undefined;119 const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined;
119 if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;120 return Schema.is(ModelSelection)(raw) ? raw : undefined;
120 return raw as Record<string, unknown>;
121}121}
122122
123function readPersistedProviderOptions(123function readPersistedProviderOptions(
...@@ -172,7 +172,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -172,7 +172,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
172 session: ProviderSession,172 session: ProviderSession,
173 threadId: ThreadId,173 threadId: ThreadId,
174 extra?: {174 extra?: {
175 readonly modelOptions?: unknown;175 readonly modelSelection?: unknown;
176 readonly providerOptions?: unknown;176 readonly providerOptions?: unknown;
177 readonly lastRuntimeEvent?: string;177 readonly lastRuntimeEvent?: string;
178 readonly lastRuntimeEventAt?: string;178 readonly lastRuntimeEventAt?: string;
...@@ -238,14 +238,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -238,14 +238,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
238 }238 }
239239
240 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);240 const persistedCwd = readPersistedCwd(input.binding.runtimePayload);
241 const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload);241 const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload);
242 const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload);242 const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload);
243243
244 const resumed = yield* adapter.startSession({244 const resumed = yield* adapter.startSession({
245 threadId: input.binding.threadId,245 threadId: input.binding.threadId,
246 provider: input.binding.provider,246 provider: input.binding.provider,
247 ...(persistedCwd ? { cwd: persistedCwd } : {}),247 ...(persistedCwd ? { cwd: persistedCwd } : {}),
248 ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}),248 ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}),
249 ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}),249 ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}),
250 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),250 ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}),
251 runtimeMode: input.binding.runtimeMode ?? "full-access",251 runtimeMode: input.binding.runtimeMode ?? "full-access",
...@@ -328,7 +328,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -328,7 +328,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
328 }328 }
329329
330 yield* upsertSessionBinding(session, threadId, {330 yield* upsertSessionBinding(session, threadId, {
331 modelOptions: input.modelOptions,331 modelSelection: input.modelSelection,
332 providerOptions: input.providerOptions,332 providerOptions: input.providerOptions,
333 });333 });
334 yield* analytics.record("provider.session.started", {334 yield* analytics.record("provider.session.started", {
...@@ -336,7 +336,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -336,7 +336,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
336 runtimeMode: input.runtimeMode,336 runtimeMode: input.runtimeMode,
337 hasResumeCursor: session.resumeCursor !== undefined,337 hasResumeCursor: session.resumeCursor !== undefined,
338 hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0,338 hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0,
339 hasModel: typeof input.model === "string" && input.model.trim().length > 0,339 hasModel:
340 typeof input.modelSelection?.model === "string" &&
341 input.modelSelection.model.trim().length > 0,
340 });342 });
341343
342 return session;344 return session;
...@@ -372,6 +374,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -372,6 +374,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
372 status: "running",374 status: "running",
373 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),375 ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}),
374 runtimePayload: {376 runtimePayload: {
377 ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}),
375 activeTurnId: turn.turnId,378 activeTurnId: turn.turnId,
376 lastRuntimeEvent: "provider.sendTurn",379 lastRuntimeEvent: "provider.sendTurn",
377 lastRuntimeEventAt: new Date().toISOString(),380 lastRuntimeEventAt: new Date().toISOString(),
...@@ -379,7 +382,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -379,7 +382,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
379 });382 });
380 yield* analytics.record("provider.turn.sent", {383 yield* analytics.record("provider.turn.sent", {
381 provider: routed.adapter.provider,384 provider: routed.adapter.provider,
382 model: input.model,385 model: input.modelSelection?.model,
383 interactionMode: input.interactionMode,386 interactionMode: input.interactionMode,
384 attachmentCount: input.attachments.length,387 attachmentCount: input.attachments.length,
385 hasInput: typeof input.input === "string" && input.input.trim().length > 0,388 hasInput: typeof input.input === "string" && input.input.trim().length > 0,
apps/server/src/wsServer.test.ts+32-8
...@@ -703,13 +703,19 @@ describe("WebSocket Server", () => {...@@ -703,13 +703,19 @@ describe("WebSocket Server", () => {
703 id: string;703 id: string;
704 workspaceRoot: string;704 workspaceRoot: string;
705 title: string;705 title: string;
706 defaultModel: string | null;706 defaultModelSelection: {
707 provider: string;
708 model: string;
709 } | null;
707 }>;710 }>;
708 threads: Array<{711 threads: Array<{
709 id: string;712 id: string;
710 projectId: string;713 projectId: string;
711 title: string;714 title: string;
712 model: string;715 modelSelection: {
716 provider: string;
717 model: string;
718 };
713 branch: string | null;719 branch: string | null;
714 worktreePath: string | null;720 worktreePath: string | null;
715 }>;721 }>;
...@@ -725,7 +731,10 @@ describe("WebSocket Server", () => {...@@ -725,7 +731,10 @@ describe("WebSocket Server", () => {
725 id: bootstrapProjectId,731 id: bootstrapProjectId,
726 workspaceRoot: "/test/bootstrap-workspace",732 workspaceRoot: "/test/bootstrap-workspace",
727 title: "bootstrap-workspace",733 title: "bootstrap-workspace",
728 defaultModel: "gpt-5-codex",734 defaultModelSelection: {
735 provider: "codex",
736 model: "gpt-5-codex",
737 },
729 }),738 }),
730 ]),739 ]),
731 );740 );
...@@ -735,7 +744,10 @@ describe("WebSocket Server", () => {...@@ -735,7 +744,10 @@ describe("WebSocket Server", () => {
735 id: bootstrapThreadId,744 id: bootstrapThreadId,
736 projectId: bootstrapProjectId,745 projectId: bootstrapProjectId,
737 title: "New thread",746 title: "New thread",
738 model: "gpt-5-codex",747 modelSelection: {
748 provider: "codex",
749 model: "gpt-5-codex",
750 },
739 branch: null,751 branch: null,
740 worktreePath: null,752 worktreePath: null,
741 }),753 }),
...@@ -1192,7 +1204,10 @@ describe("WebSocket Server", () => {...@@ -1192,7 +1204,10 @@ describe("WebSocket Server", () => {
1192 projectId: "project-diff",1204 projectId: "project-diff",
1193 title: "Diff Project",1205 title: "Diff Project",
1194 workspaceRoot,1206 workspaceRoot,
1195 defaultModel: "gpt-5-codex",1207 defaultModelSelection: {
1208 provider: "codex",
1209 model: "gpt-5-codex",
1210 },
1196 createdAt,1211 createdAt,
1197 });1212 });
1198 expect(createProjectResponse.error).toBeUndefined();1213 expect(createProjectResponse.error).toBeUndefined();
...@@ -1202,7 +1217,10 @@ describe("WebSocket Server", () => {...@@ -1202,7 +1217,10 @@ describe("WebSocket Server", () => {
1202 threadId: "thread-diff",1217 threadId: "thread-diff",
1203 projectId: "project-diff",1218 projectId: "project-diff",
1204 title: "Diff Thread",1219 title: "Diff Thread",
1205 model: "gpt-5-codex",1220 modelSelection: {
1221 provider: "codex",
1222 model: "gpt-5-codex",
1223 },
1206 runtimeMode: "full-access",1224 runtimeMode: "full-access",
1207 interactionMode: "default",1225 interactionMode: "default",
1208 branch: null,1226 branch: null,
...@@ -1270,7 +1288,10 @@ describe("WebSocket Server", () => {...@@ -1270,7 +1288,10 @@ describe("WebSocket Server", () => {
1270 projectId: "project-1",1288 projectId: "project-1",
1271 title: "WS Project",1289 title: "WS Project",
1272 workspaceRoot,1290 workspaceRoot,
1273 defaultModel: "gpt-5-codex",1291 defaultModelSelection: {
1292 provider: "codex",
1293 model: "gpt-5-codex",
1294 },
1274 createdAt,1295 createdAt,
1275 });1296 });
1276 expect(createProjectResponse.error).toBeUndefined();1297 expect(createProjectResponse.error).toBeUndefined();
...@@ -1280,7 +1301,10 @@ describe("WebSocket Server", () => {...@@ -1280,7 +1301,10 @@ describe("WebSocket Server", () => {
1280 threadId: "thread-1",1301 threadId: "thread-1",
1281 projectId: "project-1",1302 projectId: "project-1",
1282 title: "Thread 1",1303 title: "Thread 1",
1283 model: "gpt-5-codex",1304 modelSelection: {
1305 provider: "codex",
1306 model: "gpt-5-codex",
1307 },
1284 runtimeMode: "full-access",1308 runtimeMode: "full-access",
1285 interactionMode: "default",1309 interactionMode: "default",
1286 branch: null,1310 branch: null,
apps/server/src/wsServer.ts+11-5
...@@ -631,25 +631,31 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -631,25 +631,31 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
631 (project) => project.workspaceRoot === cwd && project.deletedAt === null,631 (project) => project.workspaceRoot === cwd && project.deletedAt === null,
632 );632 );
633 let bootstrapProjectId: ProjectId;633 let bootstrapProjectId: ProjectId;
634 let bootstrapProjectDefaultModel: string;634 let bootstrapProjectDefaultModelSelection;
635635
636 if (!existingProject) {636 if (!existingProject) {
637 const createdAt = new Date().toISOString();637 const createdAt = new Date().toISOString();
638 bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID());638 bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID());
639 const bootstrapProjectTitle = path.basename(cwd) || "project";639 const bootstrapProjectTitle = path.basename(cwd) || "project";
640 bootstrapProjectDefaultModel = "gpt-5-codex";640 bootstrapProjectDefaultModelSelection = {
641 provider: "codex" as const,
642 model: "gpt-5-codex",
643 };
641 yield* orchestrationEngine.dispatch({644 yield* orchestrationEngine.dispatch({
642 type: "project.create",645 type: "project.create",
643 commandId: CommandId.makeUnsafe(crypto.randomUUID()),646 commandId: CommandId.makeUnsafe(crypto.randomUUID()),
644 projectId: bootstrapProjectId,647 projectId: bootstrapProjectId,
645 title: bootstrapProjectTitle,648 title: bootstrapProjectTitle,
646 workspaceRoot: cwd,649 workspaceRoot: cwd,
647 defaultModel: bootstrapProjectDefaultModel,650 defaultModelSelection: bootstrapProjectDefaultModelSelection,
648 createdAt,651 createdAt,
649 });652 });
650 } else {653 } else {
651 bootstrapProjectId = existingProject.id;654 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 };
653 }659 }
654660
655 const existingThread = snapshot.threads.find(661 const existingThread = snapshot.threads.find(
...@@ -664,7 +670,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -664,7 +670,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
664 threadId,670 threadId,
665 projectId: bootstrapProjectId,671 projectId: bootstrapProjectId,
666 title: "New thread",672 title: "New thread",
667 model: bootstrapProjectDefaultModel,673 modelSelection: bootstrapProjectDefaultModelSelection,
668 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,674 interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
669 runtimeMode: "full-access",675 runtimeMode: "full-access",
670 branch: null,676 branch: null,
apps/web/src/components/ChatView.browser.tsx+80-41
...@@ -90,6 +90,7 @@ interface UserRowMeasurement {...@@ -90,6 +90,7 @@ interface UserRowMeasurement {
90}90}
9191
92interface MountedChatView {92interface MountedChatView {
93 [Symbol.asyncDispose]: () => Promise<void>;
93 cleanup: () => Promise<void>;94 cleanup: () => Promise<void>;
94 measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>;95 measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>;
95 setViewport: (viewport: ViewportSpec) => Promise<void>;96 setViewport: (viewport: ViewportSpec) => Promise<void>;
...@@ -221,7 +222,10 @@ function createSnapshotForTargetUser(options: {...@@ -221,7 +222,10 @@ function createSnapshotForTargetUser(options: {
221 id: PROJECT_ID,222 id: PROJECT_ID,
222 title: "Project",223 title: "Project",
223 workspaceRoot: "/repo/project",224 workspaceRoot: "/repo/project",
224 defaultModel: "gpt-5",225 defaultModelSelection: {
226 provider: "codex",
227 model: "gpt-5",
228 },
225 scripts: [],229 scripts: [],
226 createdAt: NOW_ISO,230 createdAt: NOW_ISO,
227 updatedAt: NOW_ISO,231 updatedAt: NOW_ISO,
...@@ -233,7 +237,10 @@ function createSnapshotForTargetUser(options: {...@@ -233,7 +237,10 @@ function createSnapshotForTargetUser(options: {
233 id: THREAD_ID,237 id: THREAD_ID,
234 projectId: PROJECT_ID,238 projectId: PROJECT_ID,
235 title: "Browser test thread",239 title: "Browser test thread",
236 model: "gpt-5",240 modelSelection: {
241 provider: "codex",
242 model: "gpt-5",
243 },
237 interactionMode: "default",244 interactionMode: "default",
238 runtimeMode: "full-access",245 runtimeMode: "full-access",
239 branch: "main",246 branch: "main",
...@@ -287,7 +294,10 @@ function addThreadToSnapshot(...@@ -287,7 +294,10 @@ function addThreadToSnapshot(
287 id: threadId,294 id: threadId,
288 projectId: PROJECT_ID,295 projectId: PROJECT_ID,
289 title: "New thread",296 title: "New thread",
290 model: "gpt-5",297 modelSelection: {
298 provider: "codex",
299 model: "gpt-5",
300 },
291 interactionMode: "default",301 interactionMode: "default",
292 runtimeMode: "full-access",302 runtimeMode: "full-access",
293 branch: "main",303 branch: "main",
...@@ -756,11 +766,14 @@ async function mountChatView(options: {...@@ -756,11 +766,14 @@ async function mountChatView(options: {
756766
757 await waitForLayout();767 await waitForLayout();
758768
769 const cleanup = async () => {
770 await screen.unmount();
771 host.remove();
772 };
773
759 return {774 return {
760 cleanup: async () => {775 [Symbol.asyncDispose]: cleanup,
761 await screen.unmount();776 cleanup,
762 host.remove();
763 },
764 measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }),777 measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }),
765 setViewport: async (viewport: ViewportSpec) => {778 setViewport: async (viewport: ViewportSpec) => {
766 await setViewport(viewport);779 await setViewport(viewport);
...@@ -817,8 +830,8 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -817,8 +830,8 @@ describe("ChatView timeline estimator parity (full app)", () => {
817 draftsByThreadId: {},830 draftsByThreadId: {},
818 draftThreadsByThreadId: {},831 draftThreadsByThreadId: {},
819 projectDraftThreadIdByProjectId: {},832 projectDraftThreadIdByProjectId: {},
820 stickyModel: null,833 stickyModelSelectionByProvider: {},
821 stickyModelOptions: {},834 stickyActiveProvider: null,
822 });835 });
823 useStore.setState({836 useStore.setState({
824 projects: [],837 projects: [],
...@@ -1483,13 +1496,17 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1483,13 +1496,17 @@ describe("ChatView timeline estimator parity (full app)", () => {
14831496
1484 it("snapshots sticky codex settings into a new draft thread", async () => {1497 it("snapshots sticky codex settings into a new draft thread", async () => {
1485 useComposerDraftStore.setState({1498 useComposerDraftStore.setState({
1486 stickyModel: "gpt-5.3-codex",1499 stickyModelSelectionByProvider: {
1487 stickyModelOptions: {
1488 codex: {1500 codex: {
1489 reasoningEffort: "medium",1501 provider: "codex",
1490 fastMode: true,1502 model: "gpt-5.3-codex",
1503 options: {
1504 reasoningEffort: "medium",
1505 fastMode: true,
1506 },
1491 },1507 },
1492 },1508 },
1509 stickyActiveProvider: "codex",
1493 });1510 });
14941511
1495 const mounted = await mountChatView({1512 const mounted = await mountChatView({
...@@ -1514,13 +1531,16 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1514,13 +1531,16 @@ describe("ChatView timeline estimator parity (full app)", () => {
1514 const newThreadId = newThreadPath.slice(1) as ThreadId;1531 const newThreadId = newThreadPath.slice(1) as ThreadId;
15151532
1516 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({1533 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
1517 model: "gpt-5.3-codex",1534 modelSelectionByProvider: {
1518 provider: "codex",
1519 modelOptions: {
1520 codex: {1535 codex: {
1521 fastMode: true,1536 provider: "codex",
1537 model: "gpt-5.3-codex",
1538 options: {
1539 fastMode: true,
1540 },
1522 },1541 },
1523 },1542 },
1543 activeProvider: "codex",
1524 });1544 });
1525 } finally {1545 } finally {
1526 await mounted.cleanup();1546 await mounted.cleanup();
...@@ -1529,13 +1549,17 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1529,13 +1549,17 @@ describe("ChatView timeline estimator parity (full app)", () => {
15291549
1530 it("hydrates the provider alongside a sticky claude model", async () => {1550 it("hydrates the provider alongside a sticky claude model", async () => {
1531 useComposerDraftStore.setState({1551 useComposerDraftStore.setState({
1532 stickyModel: "claude-opus-4-6",1552 stickyModelSelectionByProvider: {
1533 stickyModelOptions: {
1534 claudeAgent: {1553 claudeAgent: {
1535 effort: "max",1554 provider: "claudeAgent",
1536 fastMode: true,1555 model: "claude-opus-4-6",
1556 options: {
1557 effort: "max",
1558 fastMode: true,
1559 },
1537 },1560 },
1538 },1561 },
1562 stickyActiveProvider: "claudeAgent",
1539 });1563 });
15401564
1541 const mounted = await mountChatView({1565 const mounted = await mountChatView({
...@@ -1560,16 +1584,18 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1560,16 +1584,18 @@ describe("ChatView timeline estimator parity (full app)", () => {
1560 const newThreadId = newThreadPath.slice(1) as ThreadId;1584 const newThreadId = newThreadPath.slice(1) as ThreadId;
15611585
1562 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({1586 expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({
1563 provider: "claudeAgent",1587 modelSelectionByProvider: {
1564 model: "claude-opus-4-6",
1565 modelOptions: {
1566 claudeAgent: {1588 claudeAgent: {
1567 effort: "max",1589 provider: "claudeAgent",
1568 fastMode: true,1590 model: "claude-opus-4-6",
1591 options: {
1592 effort: "max",
1593 fastMode: true,
1594 },
1569 },1595 },
1570 },1596 },
1597 activeProvider: "claudeAgent",
1571 });1598 });
1572 await expect.element(page.getByText("Claude Opus 4.6")).toBeInTheDocument();
1573 } finally {1599 } finally {
1574 await mounted.cleanup();1600 await mounted.cleanup();
1575 }1601 }
...@@ -1605,13 +1631,17 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1605,13 +1631,17 @@ describe("ChatView timeline estimator parity (full app)", () => {
16051631
1606 it("prefers draft state over sticky composer settings and defaults", async () => {1632 it("prefers draft state over sticky composer settings and defaults", async () => {
1607 useComposerDraftStore.setState({1633 useComposerDraftStore.setState({
1608 stickyModel: "gpt-5.3-codex",1634 stickyModelSelectionByProvider: {
1609 stickyModelOptions: {
1610 codex: {1635 codex: {
1611 reasoningEffort: "medium",1636 provider: "codex",
1612 fastMode: true,1637 model: "gpt-5.3-codex",
1638 options: {
1639 reasoningEffort: "medium",
1640 fastMode: true,
1641 },
1613 },1642 },
1614 },1643 },
1644 stickyActiveProvider: "codex",
1615 });1645 });
16161646
1617 const mounted = await mountChatView({1647 const mounted = await mountChatView({
...@@ -1636,17 +1666,22 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1636,17 +1666,22 @@ describe("ChatView timeline estimator parity (full app)", () => {
1636 const threadId = threadPath.slice(1) as ThreadId;1666 const threadId = threadPath.slice(1) as ThreadId;
16371667
1638 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({1668 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
1639 model: "gpt-5.3-codex",1669 modelSelectionByProvider: {
1640 modelOptions: {
1641 codex: {1670 codex: {
1642 fastMode: true,1671 provider: "codex",
1672 model: "gpt-5.3-codex",
1673 options: {
1674 fastMode: true,
1675 },
1643 },1676 },
1644 },1677 },
1678 activeProvider: "codex",
1645 });1679 });
16461680
1647 useComposerDraftStore.getState().setModel(threadId, "gpt-5.4");1681 useComposerDraftStore.getState().setModelSelection(threadId, {
1648 useComposerDraftStore.getState().setModelOptions(threadId, {1682 provider: "codex",
1649 codex: {1683 model: "gpt-5.4",
1684 options: {
1650 reasoningEffort: "low",1685 reasoningEffort: "low",
1651 fastMode: true,1686 fastMode: true,
1652 },1687 },
...@@ -1660,13 +1695,17 @@ describe("ChatView timeline estimator parity (full app)", () => {...@@ -1660,13 +1695,17 @@ describe("ChatView timeline estimator parity (full app)", () => {
1660 "New-thread should reuse the existing project draft thread.",1695 "New-thread should reuse the existing project draft thread.",
1661 );1696 );
1662 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({1697 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({
1663 model: "gpt-5.4",1698 modelSelectionByProvider: {
1664 modelOptions: {
1665 codex: {1699 codex: {
1666 reasoningEffort: "low",1700 provider: "codex",
1667 fastMode: true,1701 model: "gpt-5.4",
1702 options: {
1703 reasoningEffort: "low",
1704 fastMode: true,
1705 },
1668 },1706 },
1669 },1707 },
1708 activeProvider: "codex",
1670 });1709 });
1671 } finally {1710 } finally {
1672 await mounted.cleanup();1711 await mounted.cleanup();
apps/web/src/components/ChatView.logic.ts+3-3
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { ProjectId, type ThreadId } from "@t3tools/contracts";1import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts";
2import { type ChatMessage, type Thread } from "../types";2import { type ChatMessage, type Thread } from "../types";
3import { randomUUID } from "~/lib/utils";3import { randomUUID } from "~/lib/utils";
4import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";4import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
...@@ -17,7 +17,7 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema....@@ -17,7 +17,7 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.
17export function buildLocalDraftThread(17export function buildLocalDraftThread(
18 threadId: ThreadId,18 threadId: ThreadId,
19 draftThread: DraftThreadState,19 draftThread: DraftThreadState,
20 fallbackModel: string,20 fallbackModelSelection: ModelSelection,
21 error: string | null,21 error: string | null,
22): Thread {22): Thread {
23 return {23 return {
...@@ -25,7 +25,7 @@ export function buildLocalDraftThread(...@@ -25,7 +25,7 @@ export function buildLocalDraftThread(
25 codexThreadId: null,25 codexThreadId: null,
26 projectId: draftThread.projectId,26 projectId: draftThread.projectId,
27 title: "New thread",27 title: "New thread",
28 model: fallbackModel,28 modelSelection: fallbackModelSelection,
29 runtimeMode: draftThread.runtimeMode,29 runtimeMode: draftThread.runtimeMode,
30 interactionMode: draftThread.interactionMode,30 interactionMode: draftThread.interactionMode,
31 session: null,31 session: null,
apps/web/src/components/ChatView.tsx+82-64
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 DEFAULT_MODEL_BY_PROVIDER,3 DEFAULT_MODEL_BY_PROVIDER,
4 type ClaudeCodeEffort,4 type ClaudeCodeEffort,
5 type MessageId,5 type MessageId,
6 type ModelSelection,
6 type ProjectScript,7 type ProjectScript,
7 type ModelSlug,8 type ModelSlug,
8 type ProviderKind,9 type ProviderKind,
...@@ -23,9 +24,8 @@ import {...@@ -23,9 +24,8 @@ import {
23} from "@t3tools/contracts";24} from "@t3tools/contracts";
24import {25import {
25 applyClaudePromptEffortPrefix,26 applyClaudePromptEffortPrefix,
26 getDefaultModel,27 getModelCapabilities,
27 normalizeModelSlug,28 normalizeModelSlug,
28 resolveModelSlugForProvider,
29} from "@t3tools/shared/model";29} from "@t3tools/shared/model";
30import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";30import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
31import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";31import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
...@@ -133,6 +133,7 @@ import {...@@ -133,6 +133,7 @@ import {
133 type DraftThreadEnvMode,133 type DraftThreadEnvMode,
134 type PersistedComposerImageAttachment,134 type PersistedComposerImageAttachment,
135 useComposerDraftStore,135 useComposerDraftStore,
136 useEffectiveComposerModelState,
136 useComposerThreadDraft,137 useComposerThreadDraft,
137} from "../composerDraftStore";138} from "../composerDraftStore";
138import {139import {
...@@ -196,10 +197,12 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw...@@ -196,10 +197,12 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw
196197
197function formatOutgoingPrompt(params: {198function formatOutgoingPrompt(params: {
198 provider: ProviderKind;199 provider: ProviderKind;
200 model: string | null;
199 effort: string | null;201 effort: string | null;
200 text: string;202 text: string;
201}): string {203}): 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)) {
203 return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null);206 return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null);
204 }207 }
205 return params.text;208 return params.text;
...@@ -248,7 +251,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -248,7 +251,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
248 const setStoreThreadError = useStore((store) => store.setError);251 const setStoreThreadError = useStore((store) => store.setError);
249 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);252 const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
250 const { settings } = useAppSettings();253 const { settings } = useAppSettings();
251 const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel);254 const setStickyComposerModelSelection = useComposerDraftStore(
255 (store) => store.setStickyModelSelection,
256 );
252 const timestampFormat = settings.timestampFormat;257 const timestampFormat = settings.timestampFormat;
253 const navigate = useNavigate();258 const navigate = useNavigate();
254 const rawSearch = useSearch({259 const rawSearch = useSearch({
...@@ -273,8 +278,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -273,8 +278,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
273 );278 );
274 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;279 const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds;
275 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);280 const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt);
276 const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider);281 const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection);
277 const setComposerDraftModel = useComposerDraftStore((store) => store.setModel);
278 const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);282 const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode);
279 const setComposerDraftInteractionMode = useComposerDraftStore(283 const setComposerDraftInteractionMode = useComposerDraftStore(
280 (store) => store.setInteractionMode,284 (store) => store.setInteractionMode,
...@@ -467,11 +471,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -467,11 +471,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
467 ? buildLocalDraftThread(471 ? buildLocalDraftThread(
468 threadId,472 threadId,
469 draftThread,473 draftThread,
470 fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex,474 fallbackDraftProject?.defaultModelSelection ?? {
475 provider: "codex",
476 model: DEFAULT_MODEL_BY_PROVIDER.codex,
477 },
471 localDraftError,478 localDraftError,
472 )479 )
473 : undefined,480 : undefined,
474 [draftThread, fallbackDraftProject?.model, localDraftError, threadId],481 [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId],
475 );482 );
476 const activeThread = serverThread ?? localDraftThread;483 const activeThread = serverThread ?? localDraftThread;
477 const runtimeMode =484 const runtimeMode =
...@@ -590,7 +597,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -590,7 +597,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
590 ]);597 ]);
591598
592 const sessionProvider = activeThread?.session?.provider ?? null;599 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;
594 const hasThreadStarted = Boolean(603 const hasThreadStarted = Boolean(
595 activeThread &&604 activeThread &&
596 (activeThread.latestTurn !== null ||605 (activeThread.latestTurn !== null ||
...@@ -598,34 +607,38 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -598,34 +607,38 @@ export default function ChatView({ threadId }: ChatViewProps) {
598 activeThread.session !== null),607 activeThread.session !== null),
599 );608 );
600 const lockedProvider: ProviderKind | null = hasThreadStarted609 const lockedProvider: ProviderKind | null = hasThreadStarted
601 ? (sessionProvider ?? selectedProviderByThreadId ?? null)610 ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null)
602 : null;611 : null;
603 const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex";612 const selectedProvider: ProviderKind =
604 const baseThreadModel = resolveModelSlugForProvider(613 lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex";
605 selectedProvider,
606 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
607 );
608 const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]);614 const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]);
609 const selectedModel = useMemo(() => {615 const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({
610 const draftModel = composerDraft.model;616 threadId,
611 if (!draftModel) {617 selectedProvider,
612 return baseThreadModel;618 threadModelSelection: activeThread?.modelSelection,
613 }619 projectModelSelection: activeProject?.defaultModelSelection,
614 return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel);620 customModelsByProvider,
615 }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]);621 });
616 const draftModelOptions = composerDraft.modelOptions;
617 const composerProviderState = useMemo(622 const composerProviderState = useMemo(
618 () =>623 () =>
619 getComposerProviderState({624 getComposerProviderState({
620 provider: selectedProvider,625 provider: selectedProvider,
621 model: selectedModel,626 model: selectedModel,
622 prompt,627 prompt,
623 modelOptions: draftModelOptions,628 modelOptions: composerModelOptions,
624 }),629 }),
625 [draftModelOptions, prompt, selectedModel, selectedProvider],630 [composerModelOptions, prompt, selectedModel, selectedProvider],
626 );631 );
627 const selectedPromptEffort = composerProviderState.promptEffort;632 const selectedPromptEffort = composerProviderState.promptEffort;
628 const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch;633 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 );
629 const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);642 const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]);
630 const selectedModelForPicker = selectedModel;643 const selectedModelForPicker = selectedModel;
631 const modelOptionsByProvider = useMemo(644 const modelOptionsByProvider = useMemo(
...@@ -1594,7 +1607,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1594,7 +1607,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1594 async (input: {1607 async (input: {
1595 threadId: ThreadId;1608 threadId: ThreadId;
1596 createdAt: string;1609 createdAt: string;
1597 model?: string;1610 modelSelection?: ModelSelection;
1598 runtimeMode: RuntimeMode;1611 runtimeMode: RuntimeMode;
1599 interactionMode: ProviderInteractionMode;1612 interactionMode: ProviderInteractionMode;
1600 }) => {1613 }) => {
...@@ -1606,12 +1619,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1606,12 +1619,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
1606 return;1619 return;
1607 }1620 }
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 ) {
1610 await api.orchestration.dispatchCommand({1629 await api.orchestration.dispatchCommand({
1611 type: "thread.meta.update",1630 type: "thread.meta.update",
1612 commandId: newCommandId(),1631 commandId: newCommandId(),
1613 threadId: input.threadId,1632 threadId: input.threadId,
1614 model: input.model,1633 modelSelection: input.modelSelection,
1615 });1634 });
1616 }1635 }
16171636
...@@ -2441,6 +2460,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2441,6 +2460,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2441 const messageCreatedAt = new Date().toISOString();2460 const messageCreatedAt = new Date().toISOString();
2442 const outgoingMessageText = formatOutgoingPrompt({2461 const outgoingMessageText = formatOutgoingPrompt({
2443 provider: selectedProvider,2462 provider: selectedProvider,
2463 model: selectedModel,
2444 effort: selectedPromptEffort,2464 effort: selectedPromptEffort,
2445 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,2465 text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT,
2446 });2466 });
...@@ -2542,8 +2562,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2542,8 +2562,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
2542 }2562 }
2543 }2563 }
2544 const title = truncateTitle(titleSeed);2564 const title = truncateTitle(titleSeed);
2545 let threadCreateModel: ModelSlug =2565 const threadCreateModelSelection: ModelSelection = {
2546 selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex;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
2548 if (isLocalDraftThread) {2574 if (isLocalDraftThread) {
2549 await api.orchestration.dispatchCommand({2575 await api.orchestration.dispatchCommand({
...@@ -2552,7 +2578,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2552,7 +2578,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2552 threadId: threadIdForSend,2578 threadId: threadIdForSend,
2553 projectId: activeProject.id,2579 projectId: activeProject.id,
2554 title,2580 title,
2555 model: threadCreateModel,2581 modelSelection: threadCreateModelSelection,
2556 runtimeMode,2582 runtimeMode,
2557 interactionMode,2583 interactionMode,
2558 branch: nextThreadBranch,2584 branch: nextThreadBranch,
...@@ -2601,7 +2627,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2601,7 +2627,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2601 await persistThreadSettingsForNextTurn({2627 await persistThreadSettingsForNextTurn({
2602 threadId: threadIdForSend,2628 threadId: threadIdForSend,
2603 createdAt: messageCreatedAt,2629 createdAt: messageCreatedAt,
2604 ...(selectedModel ? { model: selectedModel } : {}),2630 ...(selectedModel ? { modelSelection: selectedModelSelection } : {}),
2605 runtimeMode,2631 runtimeMode,
2606 interactionMode,2632 interactionMode,
2607 });2633 });
...@@ -2619,12 +2645,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2619,12 +2645,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
2619 text: outgoingMessageText,2645 text: outgoingMessageText,
2620 attachments: turnAttachments,2646 attachments: turnAttachments,
2621 },2647 },
2622 model: selectedModel || undefined,2648 modelSelection: selectedModelSelection,
2623 ...(selectedModelOptionsForDispatch
2624 ? { modelOptions: selectedModelOptionsForDispatch }
2625 : {}),
2626 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),2649 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
2627 provider: selectedProvider,
2628 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",2650 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2629 runtimeMode,2651 runtimeMode,
2630 interactionMode,2652 interactionMode,
...@@ -2861,6 +2883,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2861,6 +2883,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2861 const messageCreatedAt = new Date().toISOString();2883 const messageCreatedAt = new Date().toISOString();
2862 const outgoingMessageText = formatOutgoingPrompt({2884 const outgoingMessageText = formatOutgoingPrompt({
2863 provider: selectedProvider,2885 provider: selectedProvider,
2886 model: selectedModel,
2864 effort: selectedPromptEffort,2887 effort: selectedPromptEffort,
2865 text: trimmed,2888 text: trimmed,
2866 });2889 });
...@@ -2885,7 +2908,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2885,7 +2908,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2885 await persistThreadSettingsForNextTurn({2908 await persistThreadSettingsForNextTurn({
2886 threadId: threadIdForSend,2909 threadId: threadIdForSend,
2887 createdAt: messageCreatedAt,2910 createdAt: messageCreatedAt,
2888 ...(selectedModel ? { model: selectedModel } : {}),2911 modelSelection: selectedModelSelection,
2889 runtimeMode,2912 runtimeMode,
2890 interactionMode: nextInteractionMode,2913 interactionMode: nextInteractionMode,
2891 });2914 });
...@@ -2904,11 +2927,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2904,11 +2927,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2904 text: outgoingMessageText,2927 text: outgoingMessageText,
2905 attachments: [],2928 attachments: [],
2906 },2929 },
2907 provider: selectedProvider,2930 modelSelection: selectedModelSelection,
2908 model: selectedModel || undefined,
2909 ...(selectedModelOptionsForDispatch
2910 ? { modelOptions: selectedModelOptionsForDispatch }
2911 : {}),
2912 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),2931 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
2913 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",2932 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
2914 runtimeMode,2933 runtimeMode,
...@@ -2955,13 +2974,13 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2955,13 +2974,13 @@ export default function ChatView({ threadId }: ChatViewProps) {
2955 resetSendPhase,2974 resetSendPhase,
2956 runtimeMode,2975 runtimeMode,
2957 selectedPromptEffort,2976 selectedPromptEffort,
2958 selectedModel,2977 selectedModelSelection,
2959 selectedModelOptionsForDispatch,
2960 providerOptionsForDispatch,2978 providerOptionsForDispatch,
2961 selectedProvider,2979 selectedProvider,
2962 setComposerDraftInteractionMode,2980 setComposerDraftInteractionMode,
2963 setThreadError,2981 setThreadError,
2964 settings.enableAssistantStreaming,2982 settings.enableAssistantStreaming,
2983 selectedModel,
2965 ],2984 ],
2966 );2985 );
29672986
...@@ -2986,15 +3005,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2986,15 +3005,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
2986 const implementationPrompt = buildPlanImplementationPrompt(planMarkdown);3005 const implementationPrompt = buildPlanImplementationPrompt(planMarkdown);
2987 const outgoingImplementationPrompt = formatOutgoingPrompt({3006 const outgoingImplementationPrompt = formatOutgoingPrompt({
2988 provider: selectedProvider,3007 provider: selectedProvider,
3008 model: selectedModel,
2989 effort: selectedPromptEffort,3009 effort: selectedPromptEffort,
2990 text: implementationPrompt,3010 text: implementationPrompt,
2991 });3011 });
2992 const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown));3012 const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown));
2993 const nextThreadModel: ModelSlug =3013 const nextThreadModelSelection: ModelSelection = selectedModelSelection;
2994 selectedModel ||
2995 (activeThread.model as ModelSlug) ||
2996 (activeProject.model as ModelSlug) ||
2997 DEFAULT_MODEL_BY_PROVIDER.codex;
29983014
2999 sendInFlightRef.current = true;3015 sendInFlightRef.current = true;
3000 beginSendPhase("sending-turn");3016 beginSendPhase("sending-turn");
...@@ -3010,7 +3026,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3010,7 +3026,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3010 threadId: nextThreadId,3026 threadId: nextThreadId,
3011 projectId: activeProject.id,3027 projectId: activeProject.id,
3012 title: nextThreadTitle,3028 title: nextThreadTitle,
3013 model: nextThreadModel,3029 modelSelection: nextThreadModelSelection,
3014 runtimeMode,3030 runtimeMode,
3015 interactionMode: "default",3031 interactionMode: "default",
3016 branch: activeThread.branch,3032 branch: activeThread.branch,
...@@ -3028,11 +3044,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3028,11 +3044,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
3028 text: outgoingImplementationPrompt,3044 text: outgoingImplementationPrompt,
3029 attachments: [],3045 attachments: [],
3030 },3046 },
3031 provider: selectedProvider,3047 modelSelection: selectedModelSelection,
3032 model: selectedModel || undefined,
3033 ...(selectedModelOptionsForDispatch
3034 ? { modelOptions: selectedModelOptionsForDispatch }
3035 : {}),
3036 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),3048 ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}),
3037 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",3049 assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered",
3038 runtimeMode,3050 runtimeMode,
...@@ -3084,12 +3096,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3084,12 +3096,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
3084 resetSendPhase,3096 resetSendPhase,
3085 runtimeMode,3097 runtimeMode,
3086 selectedPromptEffort,3098 selectedPromptEffort,
3087 selectedModel,3099 selectedModelSelection,
3088 selectedModelOptionsForDispatch,
3089 providerOptionsForDispatch,3100 providerOptionsForDispatch,
3090 selectedProvider,3101 selectedProvider,
3091 settings.enableAssistantStreaming,3102 settings.enableAssistantStreaming,
3092 syncServerReadModel,3103 syncServerReadModel,
3104 selectedModel,
3093 ]);3105 ]);
30943106
3095 const onProviderModelSelect = useCallback(3107 const onProviderModelSelect = useCallback(
...@@ -3100,18 +3112,20 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3100,18 +3112,20 @@ export default function ChatView({ threadId }: ChatViewProps) {
3100 return;3112 return;
3101 }3113 }
3102 const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model);3114 const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model);
3103 setComposerDraftProvider(activeThread.id, provider);3115 const nextModelSelection: ModelSelection = {
3104 setComposerDraftModel(activeThread.id, resolvedModel);3116 provider,
3105 setStickyComposerModel(resolvedModel);3117 model: resolvedModel,
3118 };
3119 setComposerDraftModelSelection(activeThread.id, nextModelSelection);
3120 setStickyComposerModelSelection(nextModelSelection);
3106 scheduleComposerFocus();3121 scheduleComposerFocus();
3107 },3122 },
3108 [3123 [
3109 activeThread,3124 activeThread,
3110 lockedProvider,3125 lockedProvider,
3111 scheduleComposerFocus,3126 scheduleComposerFocus,
3112 setComposerDraftModel,3127 setComposerDraftModelSelection,
3113 setComposerDraftProvider,3128 setStickyComposerModelSelection,
3114 setStickyComposerModel,
3115 customModelsByProvider,3129 customModelsByProvider,
3116 ],3130 ],
3117 );3131 );
...@@ -3135,12 +3149,16 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -3135,12 +3149,16 @@ export default function ChatView({ threadId }: ChatViewProps) {
3135 provider: selectedProvider,3149 provider: selectedProvider,
3136 threadId,3150 threadId,
3137 model: selectedModel,3151 model: selectedModel,
3152 modelOptions: composerModelOptions?.[selectedProvider],
3153 prompt,
3138 onPromptChange: setPromptFromTraits,3154 onPromptChange: setPromptFromTraits,
3139 });3155 });
3140 const providerTraitsPicker = renderProviderTraitsPicker({3156 const providerTraitsPicker = renderProviderTraitsPicker({
3141 provider: selectedProvider,3157 provider: selectedProvider,
3142 threadId,3158 threadId,
3143 model: selectedModel,3159 model: selectedModel,
3160 modelOptions: composerModelOptions?.[selectedProvider],
3161 prompt,
3144 onPromptChange: setPromptFromTraits,3162 onPromptChange: setPromptFromTraits,
3145 });3163 });
3146 const onEnvModeChange = useCallback(3164 const onEnvModeChange = useCallback(
apps/web/src/components/KeybindingsToast.browser.tsx+8-2
...@@ -64,7 +64,10 @@ function createMinimalSnapshot(): OrchestrationReadModel {...@@ -64,7 +64,10 @@ function createMinimalSnapshot(): OrchestrationReadModel {
64 id: PROJECT_ID,64 id: PROJECT_ID,
65 title: "Project",65 title: "Project",
66 workspaceRoot: "/repo/project",66 workspaceRoot: "/repo/project",
67 defaultModel: "gpt-5",67 defaultModelSelection: {
68 provider: "codex",
69 model: "gpt-5",
70 },
68 scripts: [],71 scripts: [],
69 createdAt: NOW_ISO,72 createdAt: NOW_ISO,
70 updatedAt: NOW_ISO,73 updatedAt: NOW_ISO,
...@@ -76,7 +79,10 @@ function createMinimalSnapshot(): OrchestrationReadModel {...@@ -76,7 +79,10 @@ function createMinimalSnapshot(): OrchestrationReadModel {
76 id: THREAD_ID,79 id: THREAD_ID,
77 projectId: PROJECT_ID,80 projectId: PROJECT_ID,
78 title: "Test thread",81 title: "Test thread",
79 model: "gpt-5",82 modelSelection: {
83 provider: "codex",
84 model: "gpt-5",
85 },
80 interactionMode: "default",86 interactionMode: "default",
81 runtimeMode: "full-access",87 runtimeMode: "full-access",
82 branch: "main",88 branch: "main",
apps/web/src/components/Sidebar.logic.test.ts+12-3
...@@ -343,16 +343,21 @@ describe("getVisibleThreadsForProject", () => {...@@ -343,16 +343,21 @@ describe("getVisibleThreadsForProject", () => {
343});343});
344344
345function makeProject(overrides: Partial<Project> = {}): Project {345function makeProject(overrides: Partial<Project> = {}): Project {
346 const { defaultModelSelection, ...rest } = overrides;
346 return {347 return {
347 id: ProjectId.makeUnsafe("project-1"),348 id: ProjectId.makeUnsafe("project-1"),
348 name: "Project",349 name: "Project",
349 cwd: "/tmp/project",350 cwd: "/tmp/project",
350 model: "gpt-5.4",351 defaultModelSelection: {
352 provider: "codex",
353 model: "gpt-5.4",
354 ...defaultModelSelection,
355 },
351 expanded: true,356 expanded: true,
352 createdAt: "2026-03-09T10:00:00.000Z",357 createdAt: "2026-03-09T10:00:00.000Z",
353 updatedAt: "2026-03-09T10:00:00.000Z",358 updatedAt: "2026-03-09T10:00:00.000Z",
354 scripts: [],359 scripts: [],
355 ...overrides,360 ...rest,
356 };361 };
357}362}
358363
...@@ -362,7 +367,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -362,7 +367,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
362 codexThreadId: null,367 codexThreadId: null,
363 projectId: ProjectId.makeUnsafe("project-1"),368 projectId: ProjectId.makeUnsafe("project-1"),
364 title: "Thread",369 title: "Thread",
365 model: "gpt-5.4",370 modelSelection: {
371 provider: "codex",
372 model: "gpt-5.4",
373 ...overrides?.modelSelection,
374 },
366 runtimeMode: DEFAULT_RUNTIME_MODE,375 runtimeMode: DEFAULT_RUNTIME_MODE,
367 interactionMode: DEFAULT_INTERACTION_MODE,376 interactionMode: DEFAULT_INTERACTION_MODE,
368 session: null,377 session: null,
apps/web/src/components/Sidebar.tsx+4-1
...@@ -539,7 +539,10 @@ export default function Sidebar() {...@@ -539,7 +539,10 @@ export default function Sidebar() {
539 projectId,539 projectId,
540 title,540 title,
541 workspaceRoot: cwd,541 workspaceRoot: cwd,
542 defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,542 defaultModelSelection: {
543 provider: "codex",
544 model: DEFAULT_MODEL_BY_PROVIDER.codex,
545 },
543 createdAt,546 createdAt,
544 });547 });
545 await handleNewThread(projectId, {548 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 @@...@@ -1,4 +1,4 @@
1import { type ProviderModelOptions, ThreadId } from "@t3tools/contracts";1import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts";
2import "../../index.css";2import "../../index.css";
33
4import { page } from "vitest/browser";4import { page } from "vitest/browser";
...@@ -6,30 +6,31 @@ import { afterEach, describe, expect, it, vi } from "vitest";...@@ -6,30 +6,31 @@ import { afterEach, describe, expect, it, vi } from "vitest";
6import { render } from "vitest-browser-react";6import { render } from "vitest-browser-react";
77
8import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";8import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
9import { ClaudeTraitsMenuContent } from "./ClaudeTraitsPicker";9import { TraitsMenuContent } from "./TraitsPicker";
10import { CodexTraitsMenuContent } from "./CodexTraitsPicker";
11import { useComposerDraftStore } from "../../composerDraftStore";10import { useComposerDraftStore } from "../../composerDraftStore";
1211
13async function mountMenu(props?: {12async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) {
14 model?: string;
15 prompt?: string;
16 provider?: "codex" | "claudeAgent";
17 modelOptions?: ProviderModelOptions | null;
18}) {
19 const threadId = ThreadId.makeUnsafe("thread-compact-menu");13 const threadId = ThreadId.makeUnsafe("thread-compact-menu");
20 const provider = props?.provider ?? "claudeAgent";14 const provider = props?.modelSelection?.provider ?? "claudeAgent";
21 const draftsByThreadId = {} as ReturnType<15 const draftsByThreadId = {} as ReturnType<
22 typeof useComposerDraftStore.getState16 typeof useComposerDraftStore.getState
23 >["draftsByThreadId"];17 >["draftsByThreadId"];
18 const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider];
19
24 draftsByThreadId[threadId] = {20 draftsByThreadId[threadId] = {
25 prompt: props?.prompt ?? "",21 prompt: props?.prompt ?? "",
26 images: [],22 images: [],
27 nonPersistedImageIds: [],23 nonPersistedImageIds: [],
28 persistedAttachments: [],24 persistedAttachments: [],
29 terminalContexts: [],25 terminalContexts: [],
30 provider,26 modelSelectionByProvider: {
31 model: props?.model ?? "claude-opus-4-6",27 [provider]: {
32 modelOptions: props?.modelOptions ?? null,28 provider,
29 model,
30 ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}),
31 },
32 },
33 activeProvider: provider,
33 runtimeMode: null,34 runtimeMode: null,
34 interactionMode: null,35 interactionMode: null,
35 };36 };
...@@ -41,6 +42,7 @@ async function mountMenu(props?: {...@@ -41,6 +42,7 @@ async function mountMenu(props?: {
41 const host = document.createElement("div");42 const host = document.createElement("div");
42 document.body.append(host);43 document.body.append(host);
43 const onPromptChange = vi.fn();44 const onPromptChange = vi.fn();
45 const providerOptions = props?.modelSelection?.options;
44 const screen = await render(46 const screen = await render(
45 <CompactComposerControlsMenu47 <CompactComposerControlsMenu
46 activePlan={false}48 activePlan={false}
...@@ -48,15 +50,14 @@ async function mountMenu(props?: {...@@ -48,15 +50,14 @@ async function mountMenu(props?: {
48 planSidebarOpen={false}50 planSidebarOpen={false}
49 runtimeMode="approval-required"51 runtimeMode="approval-required"
50 traitsMenuContent={52 traitsMenuContent={
51 provider === "codex" ? (53 <TraitsMenuContent
52 <CodexTraitsMenuContent threadId={threadId} />54 provider={provider}
53 ) : (55 threadId={threadId}
54 <ClaudeTraitsMenuContent56 model={model}
55 threadId={threadId}57 prompt={props?.prompt ?? ""}
56 model={props?.model ?? "claude-opus-4-6"}58 modelOptions={providerOptions}
57 onPromptChange={onPromptChange}59 onPromptChange={onPromptChange}
58 />60 />
59 )
60 }61 }
61 onToggleInteractionMode={vi.fn()}62 onToggleInteractionMode={vi.fn()}
62 onTogglePlanSidebar={vi.fn()}63 onTogglePlanSidebar={vi.fn()}
...@@ -65,11 +66,14 @@ async function mountMenu(props?: {...@@ -65,11 +66,14 @@ async function mountMenu(props?: {
65 { container: host },66 { container: host },
66 );67 );
6768
69 const cleanup = async () => {
70 await screen.unmount();
71 host.remove();
72 };
73
68 return {74 return {
69 cleanup: async () => {75 [Symbol.asyncDispose]: cleanup,
70 await screen.unmount();76 cleanup,
71 host.remove();
72 },
73 };77 };
74}78}
7579
...@@ -80,107 +84,90 @@ describe("CompactComposerControlsMenu", () => {...@@ -80,107 +84,90 @@ describe("CompactComposerControlsMenu", () => {
80 draftsByThreadId: {},84 draftsByThreadId: {},
81 draftThreadsByThreadId: {},85 draftThreadsByThreadId: {},
82 projectDraftThreadIdByProjectId: {},86 projectDraftThreadIdByProjectId: {},
87 stickyModelSelectionByProvider: {},
83 });88 });
84 });89 });
8590
86 it("shows fast mode controls for Opus", async () => {91 it("shows fast mode controls for Opus", async () => {
87 const mounted = await mountMenu();92 await using _ = await mountMenu({
8893 modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" },
89 try {94 });
90 await page.getByLabelText("More composer controls").click();95
9196 await page.getByLabelText("More composer controls").click();
92 await vi.waitFor(() => {97
93 const text = document.body.textContent ?? "";98 await vi.waitFor(() => {
94 expect(text).toContain("Fast Mode");99 const text = document.body.textContent ?? "";
95 expect(text).toContain("off");100 expect(text).toContain("Fast Mode");
96 expect(text).toContain("on");101 expect(text).toContain("off");
97 });102 expect(text).toContain("on");
98 } finally {103 });
99 await mounted.cleanup();
100 }
101 });104 });
102105
103 it("hides fast mode controls for non-Opus Claude models", async () => {106 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 {111 await page.getByLabelText("More composer controls").click();
107 await page.getByLabelText("More composer controls").click();
108112
109 await vi.waitFor(() => {113 await vi.waitFor(() => {
110 expect(document.body.textContent ?? "").not.toContain("Fast Mode");114 expect(document.body.textContent ?? "").not.toContain("Fast Mode");
111 });115 });
112 } finally {
113 await mounted.cleanup();
114 }
115 });116 });
116117
117 it("shows only the provided effort options", async () => {118 it("shows only the provided effort options", async () => {
118 const mounted = await mountMenu({119 await using _ = await mountMenu({
119 model: "claude-sonnet-4-6",120 modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" },
120 });121 });
121122
122 try {123 await page.getByLabelText("More composer controls").click();
123 await page.getByLabelText("More composer controls").click();124
124125 await vi.waitFor(() => {
125 await vi.waitFor(() => {126 const text = document.body.textContent ?? "";
126 const text = document.body.textContent ?? "";127 expect(text).toContain("Low");
127 expect(text).toContain("Low");128 expect(text).toContain("Medium");
128 expect(text).toContain("Medium");129 expect(text).toContain("High");
129 expect(text).toContain("High");130 expect(text).not.toContain("Max");
130 expect(text).not.toContain("Max");131 expect(text).toContain("Ultrathink");
131 expect(text).toContain("Ultrathink");132 });
132 });
133 } finally {
134 await mounted.cleanup();
135 }
136 });133 });
137134
138 it("shows a Claude thinking on/off section for Haiku", async () => {135 it("shows a Claude thinking on/off section for Haiku", async () => {
139 const mounted = await mountMenu({136 await using _ = await mountMenu({
140 model: "claude-haiku-4-5",137 modelSelection: {
141 modelOptions: {138 provider: "claudeAgent",
142 claudeAgent: {139 model: "claude-haiku-4-5",
143 thinking: true,140 options: { thinking: true },
144 },
145 },141 },
146 });142 });
147143
148 try {144 await page.getByLabelText("More composer controls").click();
149 await page.getByLabelText("More composer controls").click();145
150146 await vi.waitFor(() => {
151 await vi.waitFor(() => {147 const text = document.body.textContent ?? "";
152 const text = document.body.textContent ?? "";148 expect(text).toContain("Thinking");
153 expect(text).toContain("Thinking");149 expect(text).toContain("On (default)");
154 expect(text).toContain("On (default)");150 expect(text).toContain("Off");
155 expect(text).toContain("Off");151 });
156 });
157 } finally {
158 await mounted.cleanup();
159 }
160 });152 });
161153
162 it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => {154 it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => {
163 const mounted = await mountMenu({155 await using _ = await mountMenu({
164 model: "claude-opus-4-6",156 modelSelection: {
165 prompt: "Ultrathink:\nInvestigate this",157 provider: "claudeAgent",
166 modelOptions: {158 model: "claude-opus-4-6",
167 claudeAgent: {159 options: { effort: "high" },
168 effort: "high",
169 },
170 },160 },
161 prompt: "Ultrathink:\nInvestigate this",
171 });162 });
172163
173 try {164 await page.getByLabelText("More composer controls").click();
174 await page.getByLabelText("More composer controls").click();165
175166 await vi.waitFor(() => {
176 await vi.waitFor(() => {167 const text = document.body.textContent ?? "";
177 const text = document.body.textContent ?? "";168 expect(text).toContain("Effort");
178 expect(text).toContain("Effort");169 expect(text).toContain("Remove Ultrathink from the prompt to change effort.");
179 expect(text).toContain("Remove Ultrathink from the prompt to change effort.");170 expect(text).not.toContain("Fallback Effort");
180 expect(text).not.toContain("Fallback Effort");171 });
181 });
182 } finally {
183 await mounted.cleanup();
184 }
185 });172 });
186});173});
apps/web/src/components/chat/ProviderHealthBanner.tsx+2-7
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { type ServerProviderStatus } from "@t3tools/contracts";1import { PROVIDER_DISPLAY_NAMES, type ServerProviderStatus } from "@t3tools/contracts";
2import { memo } from "react";2import { memo } from "react";
3import { Alert, AlertDescription, AlertTitle } from "../ui/alert";3import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
4import { CircleAlertIcon } from "lucide-react";4import { CircleAlertIcon } from "lucide-react";
...@@ -12,12 +12,7 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({...@@ -12,12 +12,7 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({
12 return null;12 return null;
13 }13 }
1414
15 const providerLabel =15 const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider;
16 status.provider === "codex"
17 ? "Codex"
18 : status.provider === "claudeAgent"
19 ? "Claude"
20 : status.provider;
21 const defaultMessage =16 const defaultMessage =
22 status.status === "error"17 status.status === "error"
23 ? `${providerLabel} provider is unavailable.`18 ? `${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", () => {...@@ -34,12 +34,51 @@ describe("getComposerProviderState", () => {
34 provider: "codex",34 provider: "codex",
35 promptEffort: "low",35 promptEffort: "low",
36 modelOptionsForDispatch: {36 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: {
37 codex: {49 codex: {
38 reasoningEffort: "low",
39 fastMode: true,50 fastMode: true,
40 },51 },
41 },52 },
42 });53 });
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 });
43 });82 });
4483
45 it("returns Claude defaults for effort-capable models", () => {84 it("returns Claude defaults for effort-capable models", () => {
...@@ -73,9 +112,7 @@ describe("getComposerProviderState", () => {...@@ -73,9 +112,7 @@ describe("getComposerProviderState", () => {
73 provider: "claudeAgent",112 provider: "claudeAgent",
74 promptEffort: "medium",113 promptEffort: "medium",
75 modelOptionsForDispatch: {114 modelOptionsForDispatch: {
76 claudeAgent: {115 effort: "medium",
77 effort: "medium",
78 },
79 },116 },
80 composerFrameClassName: "ultrathink-frame",117 composerFrameClassName: "ultrathink-frame",
81 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]",118 composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]",
...@@ -100,21 +137,18 @@ describe("getComposerProviderState", () => {...@@ -100,21 +137,18 @@ describe("getComposerProviderState", () => {
100 provider: "claudeAgent",137 provider: "claudeAgent",
101 promptEffort: null,138 promptEffort: null,
102 modelOptionsForDispatch: {139 modelOptionsForDispatch: {
103 claudeAgent: {140 thinking: false,
104 thinking: false,
105 },
106 },141 },
107 });142 });
108 });143 });
109144
110 it("ignores codex options while resolving Claude state", () => {145 it("preserves Claude fast mode when it is the only active option", () => {
111 const state = getComposerProviderState({146 const state = getComposerProviderState({
112 provider: "claudeAgent",147 provider: "claudeAgent",
113 model: "claude-opus-4-6",148 model: "claude-opus-4-6",
114 prompt: "",149 prompt: "",
115 modelOptions: {150 modelOptions: {
116 codex: {151 claudeAgent: {
117 reasoningEffort: "low",
118 fastMode: true,152 fastMode: true,
119 },153 },
120 },154 },
...@@ -123,25 +157,27 @@ describe("getComposerProviderState", () => {...@@ -123,25 +157,27 @@ describe("getComposerProviderState", () => {
123 expect(state).toEqual({157 expect(state).toEqual({
124 provider: "claudeAgent",158 provider: "claudeAgent",
125 promptEffort: "high",159 promptEffort: "high",
126 modelOptionsForDispatch: undefined,160 modelOptionsForDispatch: {
161 fastMode: true,
162 },
127 });163 });
128 });164 });
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", () => {
131 const state = getComposerProviderState({167 const state = getComposerProviderState({
132 provider: "codex",168 provider: "claudeAgent",
133 model: "gpt-5.4",169 model: "claude-opus-4-6",
134 prompt: "Ultrathink:\nThis should not matter",170 prompt: "",
135 modelOptions: {171 modelOptions: {
136 claudeAgent: {172 claudeAgent: {
137 effort: "max",173 effort: "high",
138 fastMode: true,174 fastMode: false,
139 },175 },
140 },176 },
141 });177 });
142178
143 expect(state).toEqual({179 expect(state).toEqual({
144 provider: "codex",180 provider: "claudeAgent",
145 promptEffort: "high",181 promptEffort: "high",
146 modelOptionsForDispatch: undefined,182 modelOptionsForDispatch: undefined,
147 });183 });
apps/web/src/components/chat/composerProviderRegistry.tsx+108-57
...@@ -5,17 +5,16 @@ import {...@@ -5,17 +5,16 @@ import {
5 type ThreadId,5 type ThreadId,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import {7import {
8 getDefaultReasoningEffort,8 getModelCapabilities,
9 getReasoningEffortOptions,
10 isClaudeUltrathinkPrompt,9 isClaudeUltrathinkPrompt,
11 normalizeClaudeModelOptions,10 normalizeClaudeModelOptions,
12 normalizeCodexModelOptions,11 normalizeCodexModelOptions,
13 resolveReasoningEffortForProvider,12 trimOrNull,
14 supportsClaudeUltrathinkKeyword,13 getDefaultEffort,
14 hasEffortLevel,
15} from "@t3tools/shared/model";15} from "@t3tools/shared/model";
16import type { ReactNode } from "react";16import type { ReactNode } from "react";
17import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./ClaudeTraitsPicker";17import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker";
18import { CodexTraitsMenuContent, CodexTraitsPicker } from "./CodexTraitsPicker";
1918
20export type ComposerProviderStateInput = {19export type ComposerProviderStateInput = {
21 provider: ProviderKind;20 provider: ProviderKind;
...@@ -27,7 +26,7 @@ export type ComposerProviderStateInput = {...@@ -27,7 +26,7 @@ export type ComposerProviderStateInput = {
27export type ComposerProviderState = {26export type ComposerProviderState = {
28 provider: ProviderKind;27 provider: ProviderKind;
29 promptEffort: string | null;28 promptEffort: string | null;
30 modelOptionsForDispatch: ProviderModelOptions | undefined;29 modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined;
31 composerFrameClassName?: string;30 composerFrameClassName?: string;
32 composerSurfaceClassName?: string;31 composerSurfaceClassName?: string;
33 modelPickerIconClassName?: string;32 modelPickerIconClassName?: string;
...@@ -38,70 +37,114 @@ type ProviderRegistryEntry = {...@@ -38,70 +37,114 @@ type ProviderRegistryEntry = {
38 renderTraitsMenuContent: (input: {37 renderTraitsMenuContent: (input: {
39 threadId: ThreadId;38 threadId: ThreadId;
40 model: ModelSlug;39 model: ModelSlug;
40 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
41 prompt: string;
41 onPromptChange: (prompt: string) => void;42 onPromptChange: (prompt: string) => void;
42 }) => ReactNode;43 }) => ReactNode;
43 renderTraitsPicker: (input: {44 renderTraitsPicker: (input: {
44 threadId: ThreadId;45 threadId: ThreadId;
45 model: ModelSlug;46 model: ModelSlug;
47 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
48 prompt: string;
46 onPromptChange: (prompt: string) => void;49 onPromptChange: (prompt: string) => void;
47 }) => ReactNode;50 }) => ReactNode;
48};51};
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
50const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {103const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = {
51 codex: {104 codex: {
52 getState: ({ modelOptions }) => {105 getState: (input) => getProviderStateFromCapabilities(input),
53 const promptEffort =106 renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => (
54 resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ??107 <TraitsMenuContent
55 getDefaultReasoningEffort("codex");108 provider="codex"
56 const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex);109 threadId={threadId}
57110 model={model}
58 return {111 modelOptions={modelOptions}
59 provider: "codex",112 prompt={prompt}
60 promptEffort,113 onPromptChange={onPromptChange}
61 modelOptionsForDispatch: normalizedCodexOptions114 />
62 ? { codex: normalizedCodexOptions }115 ),
63 : undefined,116 renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => (
64 };117 <TraitsPicker
65 },118 provider="codex"
66 renderTraitsMenuContent: ({ threadId }) => <CodexTraitsMenuContent threadId={threadId} />,119 threadId={threadId}
67 renderTraitsPicker: ({ threadId }) => <CodexTraitsPicker threadId={threadId} />,120 model={model}
121 modelOptions={modelOptions}
122 prompt={prompt}
123 onPromptChange={onPromptChange}
124 />
125 ),
68 },126 },
69 claudeAgent: {127 claudeAgent: {
70 getState: ({ model, prompt, modelOptions }) => {128 getState: (input) => getProviderStateFromCapabilities(input),
71 const reasoningOptions = getReasoningEffortOptions("claudeAgent", model);129 renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => (
72 const draftEffort = resolveReasoningEffortForProvider(130 <TraitsMenuContent
73 "claudeAgent",131 provider="claudeAgent"
74 modelOptions?.claudeAgent?.effort,132 threadId={threadId}
75 );133 model={model}
76 const defaultEffort = getDefaultReasoningEffort("claudeAgent");134 modelOptions={modelOptions}
77 const promptEffort =135 prompt={prompt}
78 draftEffort && draftEffort !== "ultrathink" && reasoningOptions.includes(draftEffort)136 onPromptChange={onPromptChange}
79 ? draftEffort137 />
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} />
102 ),138 ),
103 renderTraitsPicker: ({ threadId, model, onPromptChange }) => (139 renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => (
104 <ClaudeTraitsPicker threadId={threadId} model={model} onPromptChange={onPromptChange} />140 <TraitsPicker
141 provider="claudeAgent"
142 threadId={threadId}
143 model={model}
144 modelOptions={modelOptions}
145 prompt={prompt}
146 onPromptChange={onPromptChange}
147 />
105 ),148 ),
106 },149 },
107};150};
...@@ -114,11 +157,15 @@ export function renderProviderTraitsMenuContent(input: {...@@ -114,11 +157,15 @@ export function renderProviderTraitsMenuContent(input: {
114 provider: ProviderKind;157 provider: ProviderKind;
115 threadId: ThreadId;158 threadId: ThreadId;
116 model: ModelSlug;159 model: ModelSlug;
160 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
161 prompt: string;
117 onPromptChange: (prompt: string) => void;162 onPromptChange: (prompt: string) => void;
118}): ReactNode {163}): ReactNode {
119 return composerProviderRegistry[input.provider].renderTraitsMenuContent({164 return composerProviderRegistry[input.provider].renderTraitsMenuContent({
120 threadId: input.threadId,165 threadId: input.threadId,
121 model: input.model,166 model: input.model,
167 modelOptions: input.modelOptions,
168 prompt: input.prompt,
122 onPromptChange: input.onPromptChange,169 onPromptChange: input.onPromptChange,
123 });170 });
124}171}
...@@ -127,11 +174,15 @@ export function renderProviderTraitsPicker(input: {...@@ -127,11 +174,15 @@ export function renderProviderTraitsPicker(input: {
127 provider: ProviderKind;174 provider: ProviderKind;
128 threadId: ThreadId;175 threadId: ThreadId;
129 model: ModelSlug;176 model: ModelSlug;
177 modelOptions: ProviderModelOptions[ProviderKind] | undefined;
178 prompt: string;
130 onPromptChange: (prompt: string) => void;179 onPromptChange: (prompt: string) => void;
131}): ReactNode {180}): ReactNode {
132 return composerProviderRegistry[input.provider].renderTraitsPicker({181 return composerProviderRegistry[input.provider].renderTraitsPicker({
133 threadId: input.threadId,182 threadId: input.threadId,
134 model: input.model,183 model: input.model,
184 modelOptions: input.modelOptions,
185 prompt: input.prompt,
135 onPromptChange: input.onPromptChange,186 onPromptChange: input.onPromptChange,
136 });187 });
137}188}
apps/web/src/composerDraftStore.test.ts+241-156
...@@ -1,5 +1,10 @@...@@ -1,5 +1,10 @@
1import * as Schema from "effect/Schema";1import * 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";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";8import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
49
5import {10import {
...@@ -67,11 +72,27 @@ function resetComposerDraftStore() {...@@ -67,11 +72,27 @@ function resetComposerDraftStore() {
67 draftsByThreadId: {},72 draftsByThreadId: {},
68 draftThreadsByThreadId: {},73 draftThreadsByThreadId: {},
69 projectDraftThreadIdByProjectId: {},74 projectDraftThreadIdByProjectId: {},
70 stickyModel: null,75 stickyModelSelectionByProvider: {},
71 stickyModelOptions: {},76 stickyActiveProvider: null,
72 });77 });
73}78}
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
75describe("composerDraftStore addImages", () => {96describe("composerDraftStore addImages", () => {
76 const threadId = ThreadId.makeUnsafe("thread-dedupe");97 const threadId = ThreadId.makeUnsafe("thread-dedupe");
77 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;98 let originalRevokeObjectUrl: typeof URL.revokeObjectURL;
...@@ -197,6 +218,8 @@ describe("composerDraftStore syncPersistedAttachments", () => {...@@ -197,6 +218,8 @@ describe("composerDraftStore syncPersistedAttachments", () => {
197 draftsByThreadId: {},218 draftsByThreadId: {},
198 draftThreadsByThreadId: {},219 draftThreadsByThreadId: {},
199 projectDraftThreadIdByProjectId: {},220 projectDraftThreadIdByProjectId: {},
221 stickyModelSelectionByProvider: {},
222 stickyActiveProvider: null,
200 });223 });
201 });224 });
202225
...@@ -253,6 +276,8 @@ describe("composerDraftStore terminal contexts", () => {...@@ -253,6 +276,8 @@ describe("composerDraftStore terminal contexts", () => {
253 draftsByThreadId: {},276 draftsByThreadId: {},
254 draftThreadsByThreadId: {},277 draftThreadsByThreadId: {},
255 projectDraftThreadIdByProjectId: {},278 projectDraftThreadIdByProjectId: {},
279 stickyModelSelectionByProvider: {},
280 stickyActiveProvider: null,
256 });281 });
257 });282 });
258283
...@@ -595,62 +620,58 @@ describe("composerDraftStore project draft thread mapping", () => {...@@ -595,62 +620,58 @@ describe("composerDraftStore project draft thread mapping", () => {
595 });620 });
596});621});
597622
598describe("composerDraftStore modelOptions", () => {623describe("composerDraftStore modelSelection", () => {
599 const threadId = ThreadId.makeUnsafe("thread-model-options");624 const threadId = ThreadId.makeUnsafe("thread-model-options");
600625
601 beforeEach(() => {626 beforeEach(() => {
602 resetComposerDraftStore();627 resetComposerDraftStore();
603 });628 });
604629
605 it("stores provider-scoped model options in the draft", () => {630 it("stores a model selection in the draft", () => {
606 const store = useComposerDraftStore.getState();631 const store = useComposerDraftStore.getState();
607 store.setModelOptions(threadId, {632 store.setModelSelection(
608 codex: {633 threadId,
634 modelSelection("codex", "gpt-5.3-codex", {
609 reasoningEffort: "xhigh",635 reasoningEffort: "xhigh",
610 fastMode: true,636 fastMode: true,
611 },637 }),
612 claudeAgent: {638 );
613 thinking: false,
614 },
615 });
616639
617 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({640 expect(
618 codex: {641 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
642 ).toEqual(
643 modelSelection("codex", "gpt-5.3-codex", {
619 reasoningEffort: "xhigh",644 reasoningEffort: "xhigh",
620 fastMode: true,645 fastMode: true,
621 },646 }),
622 claudeAgent: {647 );
623 thinking: false,
624 },
625 });
626 });648 });
627649
628 it("drops default-only model options from the draft", () => {650 it("keeps default-only model selections on the draft", () => {
629 const store = useComposerDraftStore.getState();651 const store = useComposerDraftStore.getState();
630 store.setModelOptions(threadId, {652 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4"));
631 codex: {
632 reasoningEffort: "high",
633 },
634 claudeAgent: {
635 thinking: true,
636 },
637 });
638653
639 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();654 expect(
655 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
656 ).toEqual(modelSelection("codex", "gpt-5.4"));
640 });657 });
641658
642 it("replaces only the targeted provider model options", () => {659 it("replaces only the targeted provider options on the current model selection", () => {
643 const store = useComposerDraftStore.getState();660 const store = useComposerDraftStore.getState();
644661
645 store.setModelOptions(threadId, {662 store.setModelSelection(
646 codex: {663 threadId,
647 reasoningEffort: "xhigh",664 modelSelection("claudeAgent", "claude-opus-4-6", {
648 },
649 claudeAgent: {
650 effort: "max",665 effort: "max",
651 fastMode: true,666 fastMode: true,
652 },667 }),
653 });668 );
669 store.setStickyModelSelection(
670 modelSelection("claudeAgent", "claude-opus-4-6", {
671 effort: "max",
672 fastMode: true,
673 }),
674 );
654675
655 store.setProviderModelOptions(676 store.setProviderModelOptions(
656 threadId,677 threadId,
...@@ -661,114 +682,166 @@ describe("composerDraftStore modelOptions", () => {...@@ -661,114 +682,166 @@ describe("composerDraftStore modelOptions", () => {
661 { persistSticky: true },682 { persistSticky: true },
662 );683 );
663684
664 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({685 expect(
665 codex: {686 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
666 reasoningEffort: "xhigh",687 .claudeAgent,
667 },688 ).toEqual(
668 claudeAgent: {689 modelSelection("claudeAgent", "claude-opus-4-6", {
669 thinking: false,690 thinking: false,
670 },691 }),
671 });692 );
672 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({693 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual(
673 codex: {694 modelSelection("claudeAgent", "claude-opus-4-6", {
674 reasoningEffort: "xhigh",
675 },
676 claudeAgent: {
677 thinking: false,695 thinking: false,
678 },696 }),
679 });697 );
680 });698 });
681699
682 it("removes only the targeted provider entry when next options normalize empty", () => {700 it("keeps explicit default-state overrides on the selection", () => {
683 const store = useComposerDraftStore.getState();701 const store = useComposerDraftStore.getState();
684702
685 store.setModelOptions(threadId, {703 store.setModelSelection(
686 codex: {704 threadId,
687 reasoningEffort: "xhigh",705 modelSelection("claudeAgent", "claude-opus-4-6", {
688 },
689 claudeAgent: {
690 effort: "max",706 effort: "max",
691 },707 }),
692 });708 );
693709
694 store.setProviderModelOptions(threadId, "claudeAgent", {710 store.setProviderModelOptions(threadId, "claudeAgent", {
695 thinking: true,711 thinking: true,
696 });712 });
697713
698 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({714 expect(
699 codex: {715 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
700 reasoningEffort: "xhigh",716 .claudeAgent,
701 },717 ).toEqual(
702 });718 modelSelection("claudeAgent", "claude-opus-4-6", {
703 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({});719 thinking: true,
720 }),
721 );
722 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider).toEqual({});
704 });723 });
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", () => {
707 const store = useComposerDraftStore.getState();726 const store = useComposerDraftStore.getState();
708727
709 store.setModelOptions(threadId, {728 store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true }));
710 codex: {
711 fastMode: true,
712 },
713 });
714729
715 store.setProviderModelOptions(threadId, "codex", {730 store.setProviderModelOptions(threadId, "codex", {
716 reasoningEffort: "high",731 reasoningEffort: "high",
717 fastMode: false,732 fastMode: false,
718 });733 });
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 );
721 });743 });
722744
723 it("updates only the draft when sticky persistence is omitted", () => {745 it("updates only the draft when sticky persistence is omitted", () => {
724 const store = useComposerDraftStore.getState();746 const store = useComposerDraftStore.getState();
725747
726 store.setStickyModelOptions({748 store.setStickyModelSelection(
727 codex: {749 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
728 fastMode: true,750 );
729 },751 store.setModelSelection(
730 });752 threadId,
731 store.setModelOptions(threadId, {753 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
732 codex: {754 );
733 fastMode: true,
734 },
735 claudeAgent: {
736 effort: "max",
737 },
738 });
739755
740 store.setProviderModelOptions(threadId, "claudeAgent", {756 store.setProviderModelOptions(threadId, "claudeAgent", {
741 thinking: false,757 thinking: false,
742 });758 });
743759
744 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({760 expect(
745 codex: {761 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
746 fastMode: true,762 .claudeAgent,
747 },763 ).toEqual(
748 claudeAgent: {764 modelSelection("claudeAgent", "claude-opus-4-6", {
749 thinking: false,765 thinking: false,
750 },766 }),
751 });767 );
752 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({768 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual(
753 codex: {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 {
754 fastMode: true,823 fastMode: true,
755 },824 },
756 });825 { persistSticky: true },
826 );
827
828 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual(
829 modelSelection("codex", "gpt-5.4", {
830 fastMode: true,
831 }),
832 );
757 });833 });
758834
759 it("updates only the draft when sticky persistence is disabled", () => {835 it("updates only the draft when sticky persistence is disabled", () => {
760 const store = useComposerDraftStore.getState();836 const store = useComposerDraftStore.getState();
761837
762 store.setStickyModelOptions({838 store.setStickyModelSelection(
763 claudeAgent: {839 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
764 effort: "max",840 );
765 },841 store.setModelSelection(
766 });842 threadId,
767 store.setModelOptions(threadId, {843 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
768 claudeAgent: {844 );
769 effort: "max",
770 },
771 });
772845
773 store.setProviderModelOptions(846 store.setProviderModelOptions(
774 threadId,847 threadId,
...@@ -779,34 +852,35 @@ describe("composerDraftStore modelOptions", () => {...@@ -779,34 +852,35 @@ describe("composerDraftStore modelOptions", () => {
779 { persistSticky: false },852 { persistSticky: false },
780 );853 );
781854
782 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({855 expect(
783 claudeAgent: {856 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider
857 .claudeAgent,
858 ).toEqual(
859 modelSelection("claudeAgent", "claude-opus-4-6", {
784 thinking: false,860 thinking: false,
785 },861 }),
786 });862 );
787 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({863 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual(
788 claudeAgent: {864 modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }),
789 effort: "max",865 );
790 },
791 });
792 });866 });
793});867});
794868
795describe("composerDraftStore setModel", () => {869describe("composerDraftStore setModelSelection", () => {
796 const threadId = ThreadId.makeUnsafe("thread-model");870 const threadId = ThreadId.makeUnsafe("thread-model");
797871
798 beforeEach(() => {872 beforeEach(() => {
799 resetComposerDraftStore();873 resetComposerDraftStore();
800 });874 });
801875
802 it("keeps explicit DEFAULT_MODEL overrides instead of coercing to null", () => {876 it("keeps explicit model overrides instead of coercing to null", () => {
803 const store = useComposerDraftStore.getState();877 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(881 expect(
808 "gpt-5.3-codex",882 useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex,
809 );883 ).toEqual(modelSelection("codex", "gpt-5.3-codex"));
810 });884 });
811});885});
812886
...@@ -815,63 +889,74 @@ describe("composerDraftStore sticky composer settings", () => {...@@ -815,63 +889,74 @@ describe("composerDraftStore sticky composer settings", () => {
815 resetComposerDraftStore();889 resetComposerDraftStore();
816 });890 });
817891
818 it("stores sticky model and codex model options", () => {892 it("stores a sticky model selection", () => {
819 const store = useComposerDraftStore.getState();893 const store = useComposerDraftStore.getState();
820894
821 store.setStickyModel("gpt-5.3-codex");895 store.setStickyModelSelection(
822 store.setStickyModelOptions({896 modelSelection("codex", "gpt-5.3-codex", {
823 codex: {
824 reasoningEffort: "medium",897 reasoningEffort: "medium",
825 fastMode: true,898 fastMode: true,
826 },899 }),
827 });900 );
828901
829 expect(useComposerDraftStore.getState()).toMatchObject({902 expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual(
830 stickyModel: "gpt-5.3-codex",903 modelSelection("codex", "gpt-5.3-codex", {
831 stickyModelOptions: {904 reasoningEffort: "medium",
832 codex: {905 fastMode: true,
833 reasoningEffort: "medium",906 }),
834 fastMode: true,907 );
835 },908 expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex");
836 },
837 });
838 });909 });
839910
840 it("normalizes empty sticky model options", () => {911 it("normalizes empty sticky model options by dropping selection options", () => {
841 const store = useComposerDraftStore.getState();912 const store = useComposerDraftStore.getState();
842913
843 store.setStickyModelOptions({914 store.setStickyModelSelection(modelSelection("codex", "gpt-5.4"));
844 codex: {915
845 fastMode: false,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"),
846 },932 },
933 activeProvider: "claudeAgent",
847 });934 });
848
849 expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({});
850 });935 });
851});936});
852937
853describe("composerDraftStore setProvider", () => {938describe("composerDraftStore provider-scoped option updates", () => {
854 const threadId = ThreadId.makeUnsafe("thread-provider");939 const threadId = ThreadId.makeUnsafe("thread-provider");
855940
856 beforeEach(() => {941 beforeEach(() => {
857 resetComposerDraftStore();942 resetComposerDraftStore();
858 });943 });
859944
860 it("persists provider-only selection even when prompt/model are empty", () => {945 it("retains off-provider option memory without changing the active selection", () => {
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", () => {
869 const store = useComposerDraftStore.getState();946 const store = useComposerDraftStore.getState();
870947 store.setModelSelection(
871 store.setProvider(threadId, "codex");948 threadId,
872 store.setProvider(threadId, null);949 modelSelection("codex", "gpt-5.3-codex", {
873950 reasoningEffort: "medium",
874 expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined();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");
875 });960 });
876});961});
877962
apps/web/src/composerDraftStore.ts+583-180
...@@ -2,7 +2,8 @@ import {...@@ -2,7 +2,8 @@ import {
2 CODEX_REASONING_EFFORT_OPTIONS,2 CODEX_REASONING_EFFORT_OPTIONS,
3 type ClaudeCodeEffort,3 type ClaudeCodeEffort,
4 type CodexReasoningEffort,4 type CodexReasoningEffort,
5 DEFAULT_REASONING_EFFORT_BY_PROVIDER,5 type ModelSlug,
6 ModelSelection,
6 ProjectId,7 ProjectId,
7 ProviderInteractionMode,8 ProviderInteractionMode,
8 ProviderKind,9 ProviderKind,
...@@ -13,8 +14,14 @@ import {...@@ -13,8 +14,14 @@ import {
13import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
14import * as Equal from "effect/Equal";15import * as Equal from "effect/Equal";
15import { DeepMutable } from "effect/Types";16import { 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";
17import { getLocalStorageItem } from "./hooks/useLocalStorage";23import { getLocalStorageItem } from "./hooks/useLocalStorage";
24import { resolveAppModelSelection } from "./appSettings";
18import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";25import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types";
19import {26import {
20 type TerminalContextDraft,27 type TerminalContextDraft,
...@@ -26,7 +33,7 @@ import { createJSONStorage, persist } from "zustand/middleware";...@@ -26,7 +33,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
26import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";33import { createDebouncedStorage, createMemoryStorage } from "./lib/storage";
2734
28export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";35export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1";
29const COMPOSER_DRAFT_STORAGE_VERSION = 2;36const COMPOSER_DRAFT_STORAGE_VERSION = 3;
30const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);37const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]);
31export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;38export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type;
3239
...@@ -73,9 +80,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({...@@ -73,9 +80,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({
73 prompt: Schema.String,80 prompt: Schema.String,
74 attachments: Schema.Array(PersistedComposerImageAttachment),81 attachments: Schema.Array(PersistedComposerImageAttachment),
75 terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),82 terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)),
76 provider: Schema.optionalKey(ProviderKind),83 modelSelectionByProvider: Schema.optionalKey(
77 model: Schema.optionalKey(Schema.String),84 Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)),
78 modelOptions: Schema.optionalKey(ProviderModelOptions),85 ),
86 activeProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)),
79 runtimeMode: Schema.optionalKey(RuntimeMode),87 runtimeMode: Schema.optionalKey(RuntimeMode),
80 interactionMode: Schema.optionalKey(ProviderInteractionMode),88 interactionMode: Schema.optionalKey(ProviderInteractionMode),
81});89});
...@@ -88,7 +96,38 @@ const LegacyCodexFields = Schema.Struct({...@@ -88,7 +96,38 @@ const LegacyCodexFields = Schema.Struct({
88});96});
89type LegacyCodexFields = typeof LegacyCodexFields.Type;97type 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
93const PersistedDraftThreadState = Schema.Struct({132const PersistedDraftThreadState = Schema.Struct({
94 projectId: ProjectId,133 projectId: ProjectId,
...@@ -105,8 +144,10 @@ const PersistedComposerDraftStoreState = Schema.Struct({...@@ -105,8 +144,10 @@ const PersistedComposerDraftStoreState = Schema.Struct({
105 draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState),144 draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState),
106 draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState),145 draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState),
107 projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId),146 projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId),
108 stickyModel: Schema.NullOr(Schema.String),147 stickyModelSelectionByProvider: Schema.optionalKey(
109 stickyModelOptions: ProviderModelOptions,148 Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)),
149 ),
150 stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)),
110});151});
111type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;152type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type;
112153
...@@ -115,15 +156,14 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({...@@ -115,15 +156,14 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({
115 state: PersistedComposerDraftStoreState,156 state: PersistedComposerDraftStoreState,
116});157});
117158
118interface ComposerThreadDraftState {159export interface ComposerThreadDraftState {
119 prompt: string;160 prompt: string;
120 images: ComposerImageAttachment[];161 images: ComposerImageAttachment[];
121 nonPersistedImageIds: string[];162 nonPersistedImageIds: string[];
122 persistedAttachments: PersistedComposerImageAttachment[];163 persistedAttachments: PersistedComposerImageAttachment[];
123 terminalContexts: TerminalContextDraft[];164 terminalContexts: TerminalContextDraft[];
124 provider: ProviderKind | null;165 modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
125 model: string | null;166 activeProvider: ProviderKind | null;
126 modelOptions: ProviderModelOptions | null;
127 runtimeMode: RuntimeMode | null;167 runtimeMode: RuntimeMode | null;
128 interactionMode: ProviderInteractionMode | null;168 interactionMode: ProviderInteractionMode | null;
129}169}
...@@ -146,8 +186,8 @@ interface ComposerDraftStoreState {...@@ -146,8 +186,8 @@ interface ComposerDraftStoreState {
146 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;186 draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>;
147 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;187 draftThreadsByThreadId: Record<ThreadId, DraftThreadState>;
148 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;188 projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>;
149 stickyModel: string | null;189 stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>;
150 stickyModelOptions: ProviderModelOptions;190 stickyActiveProvider: ProviderKind | null;
151 getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;191 getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null;
152 getDraftThread: (threadId: ThreadId) => DraftThreadState | null;192 getDraftThread: (threadId: ThreadId) => DraftThreadState | null;
153 setProjectDraftThreadId: (193 setProjectDraftThreadId: (
...@@ -177,16 +217,18 @@ interface ComposerDraftStoreState {...@@ -177,16 +217,18 @@ interface ComposerDraftStoreState {
177 clearProjectDraftThreadId: (projectId: ProjectId) => void;217 clearProjectDraftThreadId: (projectId: ProjectId) => void;
178 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;218 clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void;
179 clearDraftThread: (threadId: ThreadId) => void;219 clearDraftThread: (threadId: ThreadId) => void;
180 setStickyModel: (model: string | null | undefined) => void;220 setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void;
181 setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void;
182 setPrompt: (threadId: ThreadId, prompt: string) => void;221 setPrompt: (threadId: ThreadId, prompt: string) => void;
183 setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;222 setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void;
184 setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void;223 setModelSelection: (
185 setModel: (threadId: ThreadId, model: string | null | undefined) => void;224 threadId: ThreadId,
225 modelSelection: ModelSelection | null | undefined,
226 ) => void;
186 setModelOptions: (227 setModelOptions: (
187 threadId: ThreadId,228 threadId: ThreadId,
188 modelOptions: ProviderModelOptions | null | undefined,229 modelOptions: ProviderModelOptions | null | undefined,
189 ) => void;230 ) => void;
231 applyStickyState: (threadId: ThreadId) => void;
190 setProviderModelOptions: (232 setProviderModelOptions: (
191 threadId: ThreadId,233 threadId: ThreadId,
192 provider: ProviderKind,234 provider: ProviderKind,
...@@ -221,14 +263,42 @@ interface ComposerDraftStoreState {...@@ -221,14 +263,42 @@ interface ComposerDraftStoreState {
221 clearComposerContent: (threadId: ThreadId) => void;263 clearComposerContent: (threadId: ThreadId) => void;
222}264}
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
226const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({296const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({
227 draftsByThreadId: {},297 draftsByThreadId: {},
228 draftThreadsByThreadId: {},298 draftThreadsByThreadId: {},
229 projectDraftThreadIdByProjectId: {},299 projectDraftThreadIdByProjectId: {},
230 stickyModel: null,300 stickyModelSelectionByProvider: {},
231 stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS,301 stickyActiveProvider: null,
232});302});
233303
234const EMPTY_IMAGES: ComposerImageAttachment[] = [];304const EMPTY_IMAGES: ComposerImageAttachment[] = [];
...@@ -238,15 +308,17 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];...@@ -238,15 +308,17 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = [];
238Object.freeze(EMPTY_IMAGES);308Object.freeze(EMPTY_IMAGES);
239Object.freeze(EMPTY_IDS);309Object.freeze(EMPTY_IDS);
240Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);310Object.freeze(EMPTY_PERSISTED_ATTACHMENTS);
311const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderKind, ModelSelection>> =
312 Object.freeze({});
313
241const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({314const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({
242 prompt: "",315 prompt: "",
243 images: EMPTY_IMAGES,316 images: EMPTY_IMAGES,
244 nonPersistedImageIds: EMPTY_IDS,317 nonPersistedImageIds: EMPTY_IDS,
245 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,318 persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS,
246 terminalContexts: EMPTY_TERMINAL_CONTEXTS,319 terminalContexts: EMPTY_TERMINAL_CONTEXTS,
247 provider: null,320 modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER,
248 model: null,321 activeProvider: null,
249 modelOptions: null,
250 runtimeMode: null,322 runtimeMode: null,
251 interactionMode: null,323 interactionMode: null,
252});324});
...@@ -258,9 +330,8 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {...@@ -258,9 +330,8 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
258 nonPersistedImageIds: [],330 nonPersistedImageIds: [],
259 persistedAttachments: [],331 persistedAttachments: [],
260 terminalContexts: [],332 terminalContexts: [],
261 provider: null,333 modelSelectionByProvider: {},
262 model: null,334 activeProvider: null,
263 modelOptions: null,
264 runtimeMode: null,335 runtimeMode: null,
265 interactionMode: null,336 interactionMode: null,
266 };337 };
...@@ -329,9 +400,8 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {...@@ -329,9 +400,8 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean {
329 draft.images.length === 0 &&400 draft.images.length === 0 &&
330 draft.persistedAttachments.length === 0 &&401 draft.persistedAttachments.length === 0 &&
331 draft.terminalContexts.length === 0 &&402 draft.terminalContexts.length === 0 &&
332 draft.provider === null &&403 Object.keys(draft.modelSelectionByProvider).length === 0 &&
333 draft.model === null &&404 draft.activeProvider === null &&
334 draft.modelOptions === null &&
335 draft.runtimeMode === null &&405 draft.runtimeMode === null &&
336 draft.interactionMode === null406 draft.interactionMode === null
337 );407 );
...@@ -370,20 +440,28 @@ function normalizeProviderModelOptions(...@@ -370,20 +440,28 @@ function normalizeProviderModelOptions(
370 ? legacy.effort440 ? legacy.effort
371 : undefined;441 : undefined;
372 const codexFastMode =442 const codexFastMode =
373 codexCandidate?.fastMode === true ||443 codexCandidate?.fastMode === true
374 (provider === "codex" && legacy?.codexFastMode === true) ||444 ? true
375 (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast");445 : codexCandidate?.fastMode === false
446 ? false
447 : (provider === "codex" && legacy?.codexFastMode === true) ||
448 (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast")
449 ? true
450 : undefined;
376 const codex =451 const codex =
377 codexReasoningEffort && codexReasoningEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex452 codexReasoningEffort !== undefined || codexFastMode !== undefined
378 ? {453 ? {
379 reasoningEffort: codexReasoningEffort,454 ...(codexReasoningEffort !== undefined ? { reasoningEffort: codexReasoningEffort } : {}),
380 ...(codexFastMode ? { fastMode: true } : {}),455 ...(codexFastMode !== undefined ? { fastMode: codexFastMode } : {}),
381 }456 }
382 : codexFastMode457 : undefined;
383 ? { fastMode: true }
384 : 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;
387 const claudeEffort: ClaudeCodeEffort | undefined =465 const claudeEffort: ClaudeCodeEffort | undefined =
388 claudeCandidate?.effort === "low" ||466 claudeCandidate?.effort === "low" ||
389 claudeCandidate?.effort === "medium" ||467 claudeCandidate?.effort === "medium" ||
...@@ -392,17 +470,18 @@ function normalizeProviderModelOptions(...@@ -392,17 +470,18 @@ function normalizeProviderModelOptions(
392 claudeCandidate?.effort === "ultrathink"470 claudeCandidate?.effort === "ultrathink"
393 ? claudeCandidate.effort471 ? claudeCandidate.effort
394 : undefined;472 : undefined;
395 const claudeFastMode = claudeCandidate?.fastMode === true;473 const claudeFastMode =
474 claudeCandidate?.fastMode === true
475 ? true
476 : claudeCandidate?.fastMode === false
477 ? false
478 : undefined;
396 const claude =479 const claude =
397 claudeThinking === false ||480 claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined
398 (claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent) ||
399 claudeFastMode
400 ? {481 ? {
401 ...(claudeThinking === false ? { thinking: false } : {}),482 ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}),
402 ...(claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent483 ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}),
403 ? { effort: claudeEffort }484 ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}),
404 : {}),
405 ...(claudeFastMode ? { fastMode: true } : {}),
406 }485 }
407 : undefined;486 : undefined;
408487
...@@ -415,7 +494,73 @@ function normalizeProviderModelOptions(...@@ -415,7 +494,73 @@ function normalizeProviderModelOptions(
415 };494 };
416}495}
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(
419 currentModelOptions: ProviderModelOptions | null | undefined,564 currentModelOptions: ProviderModelOptions | null | undefined,
420 provider: ProviderKind,565 provider: ProviderKind,
421 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,566 nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined,
...@@ -433,6 +578,72 @@ function replaceProviderModelOptions(...@@ -433,6 +578,72 @@ function replaceProviderModelOptions(
433 });578 });
434}579}
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
436function revokeObjectPreviewUrl(previewUrl: string): void {647function revokeObjectPreviewUrl(previewUrl: string): void {
437 if (typeof URL === "undefined") {648 if (typeof URL === "undefined") {
438 return;649 return;
...@@ -619,10 +830,6 @@ function normalizePersistedDraftThreads(...@@ -619,10 +830,6 @@ function normalizePersistedDraftThreads(
619830
620function normalizePersistedDraftsByThreadId(831function normalizePersistedDraftsByThreadId(
621 rawDraftMap: unknown,832 rawDraftMap: unknown,
622 resolveModelOptions: (
623 draftCandidate: PersistedComposerThreadDraftState | LegacyPersistedCodexThreadDraftState,
624 provider: ProviderKind | null,
625 ) => ProviderModelOptions | null,
626): PersistedComposerDraftStoreState["draftsByThreadId"] {833): PersistedComposerDraftStoreState["draftsByThreadId"] {
627 if (!rawDraftMap || typeof rawDraftMap !== "object") {834 if (!rawDraftMap || typeof rawDraftMap !== "object") {
628 return {};835 return {};
...@@ -637,9 +844,7 @@ function normalizePersistedDraftsByThreadId(...@@ -637,9 +844,7 @@ function normalizePersistedDraftsByThreadId(
637 if (!draftValue || typeof draftValue !== "object") {844 if (!draftValue || typeof draftValue !== "object") {
638 continue;845 continue;
639 }846 }
640 const draftCandidate = draftValue as847 const draftCandidate = draftValue as PersistedComposerThreadDraftState;
641 | PersistedComposerThreadDraftState
642 | LegacyPersistedCodexThreadDraftState;
643 const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : "";848 const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : "";
644 const attachments = Array.isArray(draftCandidate.attachments)849 const attachments = Array.isArray(draftCandidate.attachments)
645 ? draftCandidate.attachments.flatMap((entry) => {850 ? draftCandidate.attachments.flatMap((entry) => {
...@@ -653,11 +858,6 @@ function normalizePersistedDraftsByThreadId(...@@ -653,11 +858,6 @@ function normalizePersistedDraftsByThreadId(
653 return normalized ? [normalized] : [];858 return normalized ? [normalized] : [];
654 })859 })
655 : [];860 : [];
656 const provider = normalizeProviderKind(draftCandidate.provider);
657 const model =
658 typeof draftCandidate.model === "string"
659 ? normalizeModelSlug(draftCandidate.model, provider ?? "codex")
660 : null;
661 const runtimeMode =861 const runtimeMode =
662 draftCandidate.runtimeMode === "approval-required" ||862 draftCandidate.runtimeMode === "approval-required" ||
663 draftCandidate.runtimeMode === "full-access"863 draftCandidate.runtimeMode === "full-access"
...@@ -667,18 +867,63 @@ function normalizePersistedDraftsByThreadId(...@@ -667,18 +867,63 @@ function normalizePersistedDraftsByThreadId(
667 draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default"867 draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default"
668 ? draftCandidate.interactionMode868 ? draftCandidate.interactionMode
669 : null;869 : null;
670 const modelOptions = resolveModelOptions(draftCandidate, provider);
671 const prompt = ensureInlineTerminalContextPlaceholders(870 const prompt = ensureInlineTerminalContextPlaceholders(
672 promptCandidate,871 promptCandidate,
673 terminalContexts.length,872 terminalContexts.length,
674 );873 );
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;
675 if (922 if (
676 promptCandidate.length === 0 &&923 promptCandidate.length === 0 &&
677 attachments.length === 0 &&924 attachments.length === 0 &&
678 terminalContexts.length === 0 &&925 terminalContexts.length === 0 &&
679 !provider &&926 !hasModelData &&
680 !model &&
681 modelOptions === null &&
682 !runtimeMode &&927 !runtimeMode &&
683 !interactionMode928 !interactionMode
684 ) {929 ) {
...@@ -688,9 +933,7 @@ function normalizePersistedDraftsByThreadId(...@@ -688,9 +933,7 @@ function normalizePersistedDraftsByThreadId(
688 prompt,933 prompt,
689 attachments,934 attachments,
690 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),935 ...(terminalContexts.length > 0 ? { terminalContexts } : {}),
691 ...(provider ? { provider } : {}),936 ...(hasModelData ? { modelSelectionByProvider, activeProvider } : {}),
692 ...(model ? { model } : {}),
693 ...(modelOptions ? { modelOptions } : {}),
694 ...(runtimeMode ? { runtimeMode } : {}),937 ...(runtimeMode ? { runtimeMode } : {}),
695 ...(interactionMode ? { interactionMode } : {}),938 ...(interactionMode ? { interactionMode } : {}),
696 };939 };
...@@ -701,40 +944,45 @@ function normalizePersistedDraftsByThreadId(...@@ -701,40 +944,45 @@ function normalizePersistedDraftsByThreadId(
701944
702function migratePersistedComposerDraftStoreState(945function migratePersistedComposerDraftStoreState(
703 persistedState: unknown,946 persistedState: unknown,
704 persistedVersion: number,
705): PersistedComposerDraftStoreState {947): PersistedComposerDraftStoreState {
706 if (!persistedState || typeof persistedState !== "object") {948 if (!persistedState || typeof persistedState !== "object") {
707 return EMPTY_PERSISTED_DRAFT_STORE_STATE;949 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
708 }950 }
709 const candidate = persistedState as Record<string, unknown>;951 const candidate = persistedState as LegacyPersistedComposerDraftStoreState;
710 const rawDraftMap = candidate.draftsByThreadId;952 const rawDraftMap = candidate.draftsByThreadId;
711 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;953 const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId;
712 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;954 const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId;
713 const stickyModel =955
714 typeof candidate.stickyModel === "string"956 // Migrate sticky state from v2 (dual) to v3 (consolidated)
715 ? (normalizeModelSlug(candidate.stickyModel, "codex") ?? null)957 const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {};
716 : null;958 const normalizedStickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, {
717 const stickyModelOptions =959 provider: candidate.stickyProvider ?? "codex",
718 normalizeProviderModelOptions(candidate.stickyModelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS;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
719 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =977 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
720 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);978 normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId);
721 const draftsByThreadId = normalizePersistedDraftsByThreadId(979 const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap);
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 );
732 return {980 return {
733 draftsByThreadId,981 draftsByThreadId,
734 draftThreadsByThreadId,982 draftThreadsByThreadId,
735 projectDraftThreadIdByProjectId,983 projectDraftThreadIdByProjectId,
736 stickyModel,984 stickyModelSelectionByProvider,
737 stickyModelOptions,985 stickyActiveProvider,
738 };986 };
739}987}
740988
...@@ -748,13 +996,13 @@ function partializeComposerDraftStoreState(...@@ -748,13 +996,13 @@ function partializeComposerDraftStoreState(
748 if (typeof threadId !== "string" || threadId.length === 0) {996 if (typeof threadId !== "string" || threadId.length === 0) {
749 continue;997 continue;
750 }998 }
999 const hasModelData =
1000 Object.keys(draft.modelSelectionByProvider).length > 0 || draft.activeProvider !== null;
751 if (1001 if (
752 draft.prompt.length === 0 &&1002 draft.prompt.length === 0 &&
753 draft.persistedAttachments.length === 0 &&1003 draft.persistedAttachments.length === 0 &&
754 draft.terminalContexts.length === 0 &&1004 draft.terminalContexts.length === 0 &&
755 draft.provider === null &&1005 !hasModelData &&
756 draft.model === null &&
757 draft.modelOptions === null &&
758 draft.runtimeMode === null &&1006 draft.runtimeMode === null &&
759 draft.interactionMode === null1007 draft.interactionMode === null
760 ) {1008 ) {
...@@ -776,9 +1024,12 @@ function partializeComposerDraftStoreState(...@@ -776,9 +1024,12 @@ function partializeComposerDraftStoreState(
776 })),1024 })),
777 }1025 }
778 : {}),1026 : {}),
779 ...(draft.model ? { model: draft.model } : {}),1027 ...(hasModelData
780 ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}),1028 ? {
781 ...(draft.provider ? { provider: draft.provider } : {}),1029 modelSelectionByProvider: draft.modelSelectionByProvider,
1030 activeProvider: draft.activeProvider,
1031 }
1032 : {}),
782 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),1033 ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}),
783 ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}),1034 ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}),
784 };1035 };
...@@ -788,8 +1039,8 @@ function partializeComposerDraftStoreState(...@@ -788,8 +1039,8 @@ function partializeComposerDraftStoreState(
788 draftsByThreadId: persistedDraftsByThreadId,1039 draftsByThreadId: persistedDraftsByThreadId,
789 draftThreadsByThreadId: state.draftThreadsByThreadId,1040 draftThreadsByThreadId: state.draftThreadsByThreadId,
790 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,1041 projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId,
791 stickyModel: state.stickyModel,1042 stickyModelSelectionByProvider: state.stickyModelSelectionByProvider,
792 stickyModelOptions: state.stickyModelOptions,1043 stickyActiveProvider: state.stickyActiveProvider,
793 };1044 };
794}1045}
7951046
...@@ -799,29 +1050,58 @@ function normalizeCurrentPersistedComposerDraftStoreState(...@@ -799,29 +1050,58 @@ function normalizeCurrentPersistedComposerDraftStoreState(
799 if (!persistedState || typeof persistedState !== "object") {1050 if (!persistedState || typeof persistedState !== "object") {
800 return EMPTY_PERSISTED_DRAFT_STORE_STATE;1051 return EMPTY_PERSISTED_DRAFT_STORE_STATE;
801 }1052 }
802 const normalizedPersistedState = persistedState as Record<string, unknown>;1053 const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState;
803 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =1054 const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } =
804 normalizePersistedDraftThreads(1055 normalizePersistedDraftThreads(
805 normalizedPersistedState.draftThreadsByThreadId,1056 normalizedPersistedState.draftThreadsByThreadId,
806 normalizedPersistedState.projectDraftThreadIdByProjectId,1057 normalizedPersistedState.projectDraftThreadIdByProjectId,
807 );1058 );
808 const stickyModel =1059
809 typeof normalizedPersistedState.stickyModel === "string"1060 // Handle both v3 (modelSelectionByProvider) and v2/legacy formats
810 ? (normalizeModelSlug(normalizedPersistedState.stickyModel, "codex") ?? null)1061 let stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = {};
811 : null;1062 let stickyActiveProvider: ProviderKind | null = null;
812 const stickyModelOptions =1063 if (
813 normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ??1064 normalizedPersistedState.stickyModelSelectionByProvider &&
814 EMPTY_PROVIDER_MODEL_OPTIONS;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
815 return {1099 return {
816 draftsByThreadId: normalizePersistedDraftsByThreadId(1100 draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId),
817 normalizedPersistedState.draftsByThreadId,
818 (draftCandidate, provider) =>
819 normalizeProviderModelOptions(draftCandidate.modelOptions, provider),
820 ),
821 draftThreadsByThreadId,1101 draftThreadsByThreadId,
822 projectDraftThreadIdByProjectId,1102 projectDraftThreadIdByProjectId,
823 stickyModel,1103 stickyModelSelectionByProvider,
824 stickyModelOptions,1104 stickyActiveProvider,
825 };1105 };
826}1106}
8271107
...@@ -948,6 +1228,11 @@ function hydrateImagesFromPersisted(...@@ -948,6 +1228,11 @@ function hydrateImagesFromPersisted(
948function toHydratedThreadDraft(1228function toHydratedThreadDraft(
949 persistedDraft: PersistedComposerThreadDraftState,1229 persistedDraft: PersistedComposerThreadDraftState,
950): ComposerThreadDraftState {1230): 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
951 return {1236 return {
952 prompt: persistedDraft.prompt,1237 prompt: persistedDraft.prompt,
953 images: hydrateImagesFromPersisted(persistedDraft.attachments),1238 images: hydrateImagesFromPersisted(persistedDraft.attachments),
...@@ -958,9 +1243,8 @@ function toHydratedThreadDraft(...@@ -958,9 +1243,8 @@ function toHydratedThreadDraft(
958 ...context,1243 ...context,
959 text: "",1244 text: "",
960 })) ?? [],1245 })) ?? [],
961 provider: persistedDraft.provider ?? null,1246 modelSelectionByProvider,
962 model: persistedDraft.model ?? null,1247 activeProvider,
963 modelOptions: persistedDraft.modelOptions ?? null,
964 runtimeMode: persistedDraft.runtimeMode ?? null,1248 runtimeMode: persistedDraft.runtimeMode ?? null,
965 interactionMode: persistedDraft.interactionMode ?? null,1249 interactionMode: persistedDraft.interactionMode ?? null,
966 };1250 };
...@@ -972,8 +1256,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -972,8 +1256,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
972 draftsByThreadId: {},1256 draftsByThreadId: {},
973 draftThreadsByThreadId: {},1257 draftThreadsByThreadId: {},
974 projectDraftThreadIdByProjectId: {},1258 projectDraftThreadIdByProjectId: {},
975 stickyModel: null,1259 stickyModelSelectionByProvider: {},
976 stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS,1260 stickyActiveProvider: null,
977 getDraftThreadByProjectId: (projectId) => {1261 getDraftThreadByProjectId: (projectId) => {
978 if (projectId.length === 0) {1262 if (projectId.length === 0) {
979 return null;1263 return null;
...@@ -1219,27 +1503,67 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1219,27 +1503,67 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1219 };1503 };
1220 });1504 });
1221 },1505 },
1222 setStickyModel: (model) => {1506 setStickyModelSelection: (modelSelection) => {
1223 const normalizedModel = normalizeModelSlug(model, "codex") ?? null;1507 const normalized = normalizeModelSelection(modelSelection);
1224 set((state) => {1508 set((state) => {
1225 if (state.stickyModel === normalizedModel) {1509 if (!normalized) {
1226 return state;1510 return state;
1227 }1511 }
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 }
1228 return {1521 return {
1229 stickyModel: normalizedModel,1522 stickyModelSelectionByProvider: nextMap,
1523 stickyActiveProvider: normalized.provider,
1230 };1524 };
1231 });1525 });
1232 },1526 },
1233 setStickyModelOptions: (modelOptions) => {1527 applyStickyState: (threadId) => {
1234 const normalizedModelOptions =1528 if (threadId.length === 0) {
1235 normalizeProviderModelOptions(modelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS;1529 return;
1530 }
1236 set((state) => {1531 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) {
1238 return state;1535 return state;
1239 }1536 }
1240 return {1537 const existing = state.draftsByThreadId[threadId];
1241 stickyModelOptions: normalizedModelOptions,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,
1242 };1559 };
1560 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1561 if (shouldRemoveDraft(nextDraft)) {
1562 delete nextDraftsByThreadId[threadId];
1563 } else {
1564 nextDraftsByThreadId[threadId] = nextDraft;
1565 }
1566 return { draftsByThreadId: nextDraftsByThreadId };
1243 });1567 });
1244 },1568 },
1245 setPrompt: (threadId, prompt) => {1569 setPrompt: (threadId, prompt) => {
...@@ -1285,50 +1609,43 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1285,50 +1609,43 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1285 return { draftsByThreadId: nextDraftsByThreadId };1609 return { draftsByThreadId: nextDraftsByThreadId };
1286 });1610 });
1287 },1611 },
1288 setProvider: (threadId, provider) => {1612 setModelSelection: (threadId, modelSelection) => {
1289 if (threadId.length === 0) {1613 if (threadId.length === 0) {
1290 return;1614 return;
1291 }1615 }
1292 const normalizedProvider = normalizeProviderKind(provider);1616 const normalized = normalizeModelSelection(modelSelection);
1293 set((state) => {1617 set((state) => {
1294 const existing = state.draftsByThreadId[threadId];1618 const existing = state.draftsByThreadId[threadId];
1295 if (!existing && normalizedProvider === null) {1619 if (!existing && normalized === null) {
1296 return state;1620 return state;
1297 }1621 }
1298 const base = existing ?? createEmptyThreadDraft();1622 const base = existing ?? createEmptyThreadDraft();
1299 if (base.provider === normalizedProvider) {1623 const nextMap = { ...base.modelSelectionByProvider };
1300 return state;1624 if (normalized) {
1301 }1625 const current = nextMap[normalized.provider];
1302 const nextDraft: ComposerThreadDraftState = {1626 if (normalized.options !== undefined) {
1303 ...base,1627 // Explicit options provided → use them
1304 provider: normalizedProvider,1628 nextMap[normalized.provider] = normalized;
1305 };1629 } else {
1306 const nextDraftsByThreadId = { ...state.draftsByThreadId };1630 // No options in selection → preserve existing options, update provider+model
1307 if (shouldRemoveDraft(nextDraft)) {1631 nextMap[normalized.provider] = {
1308 delete nextDraftsByThreadId[threadId];1632 provider: normalized.provider,
1309 } else {1633 model: normalized.model,
1310 nextDraftsByThreadId[threadId] = nextDraft;1634 ...(current?.options ? { options: current.options } : {}),
1311 }1635 };
1312 return { draftsByThreadId: nextDraftsByThreadId };1636 }
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;
1324 }1637 }
1325 const base = existing ?? createEmptyThreadDraft();1638 const nextActiveProvider = normalized?.provider ?? base.activeProvider;
1326 if (base.model === normalizedModel) {1639 if (
1640 Equal.equals(base.modelSelectionByProvider, nextMap) &&
1641 base.activeProvider === nextActiveProvider
1642 ) {
1327 return state;1643 return state;
1328 }1644 }
1329 const nextDraft: ComposerThreadDraftState = {1645 const nextDraft: ComposerThreadDraftState = {
1330 ...base,1646 ...base,
1331 model: normalizedModel,1647 modelSelectionByProvider: nextMap,
1648 activeProvider: nextActiveProvider,
1332 };1649 };
1333 const nextDraftsByThreadId = { ...state.draftsByThreadId };1650 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1334 if (shouldRemoveDraft(nextDraft)) {1651 if (shouldRemoveDraft(nextDraft)) {
...@@ -1343,20 +1660,37 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1343,20 +1660,37 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1343 if (threadId.length === 0) {1660 if (threadId.length === 0) {
1344 return;1661 return;
1345 }1662 }
1663 const normalizedOpts = normalizeProviderModelOptions(modelOptions);
1346 set((state) => {1664 set((state) => {
1347 const existing = state.draftsByThreadId[threadId];1665 const existing = state.draftsByThreadId[threadId];
1348 const provider = existing?.provider ?? null;1666 if (!existing && normalizedOpts === null) {
1349 const nextModelOptions = normalizeProviderModelOptions(modelOptions, provider);
1350 if (!existing && nextModelOptions === null) {
1351 return state;1667 return state;
1352 }1668 }
1353 const base = existing ?? createEmptyThreadDraft();1669 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)) {
1355 return state;1689 return state;
1356 }1690 }
1357 const nextDraft: ComposerThreadDraftState = {1691 const nextDraft: ComposerThreadDraftState = {
1358 ...base,1692 ...base,
1359 modelOptions: nextModelOptions,1693 modelSelectionByProvider: nextMap,
1360 };1694 };
1361 const nextDraftsByThreadId = { ...state.draftsByThreadId };1695 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1362 if (shouldRemoveDraft(nextDraft)) {1696 if (shouldRemoveDraft(nextDraft)) {
...@@ -1375,29 +1709,67 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1375,29 +1709,67 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1375 if (normalizedProvider === null) {1709 if (normalizedProvider === null) {
1376 return;1710 return;
1377 }1711 }
1712 // Normalize just this provider's options
1713 const normalizedOpts = normalizeProviderModelOptions(
1714 { [normalizedProvider]: nextProviderOptions },
1715 normalizedProvider,
1716 );
1717 const providerOpts = normalizedOpts?.[normalizedProvider];
1718
1378 set((state) => {1719 set((state) => {
1379 const existing = state.draftsByThreadId[threadId];1720 const existing = state.draftsByThreadId[threadId];
1380 const base = existing ?? createEmptyThreadDraft();1721 const base = existing ?? createEmptyThreadDraft();
1381 const nextModelOptions = replaceProviderModelOptions(1722
1382 base.modelOptions,1723 // Update the map entry for this provider
1383 normalizedProvider,1724 const nextMap = { ...base.modelSelectionByProvider };
1384 nextProviderOptions,1725 const currentForProvider = nextMap[normalizedProvider];
1385 );1726 if (providerOpts) {
1386 const nextStickyModelOptions =1727 nextMap[normalizedProvider] = {
1387 options?.persistSticky === true1728 provider: normalizedProvider,
1388 ? (nextModelOptions ?? EMPTY_PROVIDER_MODEL_OPTIONS)1729 model: currentForProvider?.model ?? getDefaultModel(normalizedProvider),
1389 : state.stickyModelOptions;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
1391 if (1762 if (
1392 Equal.equals(base.modelOptions, nextModelOptions) &&1763 Equal.equals(base.modelSelectionByProvider, nextMap) &&
1393 Equal.equals(state.stickyModelOptions, nextStickyModelOptions)1764 Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) &&
1765 state.stickyActiveProvider === nextStickyActiveProvider
1394 ) {1766 ) {
1395 return state;1767 return state;
1396 }1768 }
13971769
1398 const nextDraft: ComposerThreadDraftState = {1770 const nextDraft: ComposerThreadDraftState = {
1399 ...base,1771 ...base,
1400 modelOptions: nextModelOptions,1772 modelSelectionByProvider: nextMap,
1401 };1773 };
1402 const nextDraftsByThreadId = { ...state.draftsByThreadId };1774 const nextDraftsByThreadId = { ...state.draftsByThreadId };
1403 if (shouldRemoveDraft(nextDraft)) {1775 if (shouldRemoveDraft(nextDraft)) {
...@@ -1409,7 +1781,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1409,7 +1781,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1409 return {1781 return {
1410 draftsByThreadId: nextDraftsByThreadId,1782 draftsByThreadId: nextDraftsByThreadId,
1411 ...(options?.persistSticky === true1783 ...(options?.persistSticky === true
1412 ? { stickyModelOptions: nextStickyModelOptions }1784 ? {
1785 stickyModelSelectionByProvider: nextStickyMap,
1786 stickyActiveProvider: nextStickyActiveProvider,
1787 }
1413 : {}),1788 : {}),
1414 };1789 };
1415 });1790 });
...@@ -1768,8 +2143,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(...@@ -1768,8 +2143,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()(
1768 draftsByThreadId,2143 draftsByThreadId,
1769 draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,2144 draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId,
1770 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,2145 projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId,
1771 stickyModel: normalizedPersisted.stickyModel,2146 stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {},
1772 stickyModelOptions: normalizedPersisted.stickyModelOptions,2147 stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null,
1773 };2148 };
1774 },2149 },
1775 },2150 },
...@@ -1780,6 +2155,34 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS...@@ -1780,6 +2155,34 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS
1780 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);2155 return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT);
1781}2156}
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
1783/**2186/**
1784 * Clear draft threads that have been promoted to server threads.2187 * Clear draft threads that have been promoted to server threads.
1785 *2188 *
apps/web/src/hooks/useHandleNewThread.ts+3-14
...@@ -1,7 +1,6 @@...@@ -1,7 +1,6 @@
1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";1import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts";
2import { useNavigate, useParams } from "@tanstack/react-router";2import { useNavigate, useParams } from "@tanstack/react-router";
3import { useCallback } from "react";3import { useCallback } from "react";
4import { inferProviderForModel } from "@t3tools/shared/model";
5import {4import {
6 type DraftThreadEnvMode,5 type DraftThreadEnvMode,
7 type DraftThreadState,6 type DraftThreadState,
...@@ -13,8 +12,6 @@ import { useStore } from "../store";...@@ -13,8 +12,6 @@ import { useStore } from "../store";
13export function useHandleNewThread() {12export function useHandleNewThread() {
14 const projects = useStore((store) => store.projects);13 const projects = useStore((store) => store.projects);
15 const threads = useStore((store) => store.threads);14 const threads = useStore((store) => store.threads);
16 const stickyModel = useComposerDraftStore((store) => store.stickyModel);
17 const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions);
18 const navigate = useNavigate();15 const navigate = useNavigate();
19 const routeThreadId = useParams({16 const routeThreadId = useParams({
20 strict: false,17 strict: false,
...@@ -41,9 +38,7 @@ export function useHandleNewThread() {...@@ -41,9 +38,7 @@ export function useHandleNewThread() {
41 clearProjectDraftThreadId,38 clearProjectDraftThreadId,
42 getDraftThread,39 getDraftThread,
43 getDraftThreadByProjectId,40 getDraftThreadByProjectId,
44 setModel,41 applyStickyState,
45 setModelOptions,
46 setProvider,
47 setDraftThreadContext,42 setDraftThreadContext,
48 setProjectDraftThreadId,43 setProjectDraftThreadId,
49 } = useComposerDraftStore.getState();44 } = useComposerDraftStore.getState();
...@@ -102,13 +97,7 @@ export function useHandleNewThread() {...@@ -102,13 +97,7 @@ export function useHandleNewThread() {
102 envMode: options?.envMode ?? "local",97 envMode: options?.envMode ?? "local",
103 runtimeMode: DEFAULT_RUNTIME_MODE,98 runtimeMode: DEFAULT_RUNTIME_MODE,
104 });99 });
105 if (stickyModel) {100 applyStickyState(threadId);
106 setProvider(threadId, inferProviderForModel(stickyModel));
107 setModel(threadId, stickyModel);
108 }
109 if (Object.keys(stickyModelOptions).length > 0) {
110 setModelOptions(threadId, stickyModelOptions);
111 }
112101
113 await navigate({102 await navigate({
114 to: "/$threadId",103 to: "/$threadId",
...@@ -116,7 +105,7 @@ export function useHandleNewThread() {...@@ -116,7 +105,7 @@ export function useHandleNewThread() {
116 });105 });
117 })();106 })();
118 },107 },
119 [navigate, routeThreadId, stickyModel, stickyModelOptions],108 [navigate, routeThreadId],
120 );109 );
121110
122 return {111 return {
apps/web/src/store.test.ts+50-14
...@@ -16,7 +16,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -16,7 +16,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
16 codexThreadId: null,16 codexThreadId: null,
17 projectId: ProjectId.makeUnsafe("project-1"),17 projectId: ProjectId.makeUnsafe("project-1"),
18 title: "Thread",18 title: "Thread",
19 model: "gpt-5-codex",19 modelSelection: {
20 provider: "codex",
21 model: "gpt-5-codex",
22 },
20 runtimeMode: DEFAULT_RUNTIME_MODE,23 runtimeMode: DEFAULT_RUNTIME_MODE,
21 interactionMode: DEFAULT_INTERACTION_MODE,24 interactionMode: DEFAULT_INTERACTION_MODE,
22 session: null,25 session: null,
...@@ -40,7 +43,10 @@ function makeState(thread: Thread): AppState {...@@ -40,7 +43,10 @@ function makeState(thread: Thread): AppState {
40 id: ProjectId.makeUnsafe("project-1"),43 id: ProjectId.makeUnsafe("project-1"),
41 name: "Project",44 name: "Project",
42 cwd: "/tmp/project",45 cwd: "/tmp/project",
43 model: "gpt-5-codex",46 defaultModelSelection: {
47 provider: "codex",
48 model: "gpt-5-codex",
49 },
44 expanded: true,50 expanded: true,
45 scripts: [],51 scripts: [],
46 },52 },
...@@ -55,7 +61,10 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"...@@ -55,7 +61,10 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads"
55 id: ThreadId.makeUnsafe("thread-1"),61 id: ThreadId.makeUnsafe("thread-1"),
56 projectId: ProjectId.makeUnsafe("project-1"),62 projectId: ProjectId.makeUnsafe("project-1"),
57 title: "Thread",63 title: "Thread",
58 model: "gpt-5.3-codex",64 modelSelection: {
65 provider: "codex",
66 model: "gpt-5.3-codex",
67 },
59 runtimeMode: DEFAULT_RUNTIME_MODE,68 runtimeMode: DEFAULT_RUNTIME_MODE,
60 interactionMode: DEFAULT_INTERACTION_MODE,69 interactionMode: DEFAULT_INTERACTION_MODE,
61 branch: null,70 branch: null,
...@@ -82,7 +91,10 @@ function makeReadModel(thread: OrchestrationReadModel["threads"][number]): Orche...@@ -82,7 +91,10 @@ function makeReadModel(thread: OrchestrationReadModel["threads"][number]): Orche
82 id: ProjectId.makeUnsafe("project-1"),91 id: ProjectId.makeUnsafe("project-1"),
83 title: "Project",92 title: "Project",
84 workspaceRoot: "/tmp/project",93 workspaceRoot: "/tmp/project",
85 defaultModel: "gpt-5.3-codex",94 defaultModelSelection: {
95 provider: "codex",
96 model: "gpt-5.3-codex",
97 },
86 createdAt: "2026-02-27T00:00:00.000Z",98 createdAt: "2026-02-27T00:00:00.000Z",
87 updatedAt: "2026-02-27T00:00:00.000Z",99 updatedAt: "2026-02-27T00:00:00.000Z",
88 deletedAt: null,100 deletedAt: null,
...@@ -100,7 +112,10 @@ function makeReadModelProject(...@@ -100,7 +112,10 @@ function makeReadModelProject(
100 id: ProjectId.makeUnsafe("project-1"),112 id: ProjectId.makeUnsafe("project-1"),
101 title: "Project",113 title: "Project",
102 workspaceRoot: "/tmp/project",114 workspaceRoot: "/tmp/project",
103 defaultModel: "gpt-5.3-codex",115 defaultModelSelection: {
116 provider: "codex",
117 model: "gpt-5.3-codex",
118 },
104 createdAt: "2026-02-27T00:00:00.000Z",119 createdAt: "2026-02-27T00:00:00.000Z",
105 updatedAt: "2026-02-27T00:00:00.000Z",120 updatedAt: "2026-02-27T00:00:00.000Z",
106 deletedAt: null,121 deletedAt: null,
...@@ -159,7 +174,10 @@ describe("store pure functions", () => {...@@ -159,7 +174,10 @@ describe("store pure functions", () => {
159 id: project1,174 id: project1,
160 name: "Project 1",175 name: "Project 1",
161 cwd: "/tmp/project-1",176 cwd: "/tmp/project-1",
162 model: DEFAULT_MODEL_BY_PROVIDER.codex,177 defaultModelSelection: {
178 provider: "codex",
179 model: DEFAULT_MODEL_BY_PROVIDER.codex,
180 },
163 expanded: true,181 expanded: true,
164 scripts: [],182 scripts: [],
165 },183 },
...@@ -167,7 +185,10 @@ describe("store pure functions", () => {...@@ -167,7 +185,10 @@ describe("store pure functions", () => {
167 id: project2,185 id: project2,
168 name: "Project 2",186 name: "Project 2",
169 cwd: "/tmp/project-2",187 cwd: "/tmp/project-2",
170 model: DEFAULT_MODEL_BY_PROVIDER.codex,188 defaultModelSelection: {
189 provider: "codex",
190 model: DEFAULT_MODEL_BY_PROVIDER.codex,
191 },
171 expanded: true,192 expanded: true,
172 scripts: [],193 scripts: [],
173 },194 },
...@@ -175,7 +196,10 @@ describe("store pure functions", () => {...@@ -175,7 +196,10 @@ describe("store pure functions", () => {
175 id: project3,196 id: project3,
176 name: "Project 3",197 name: "Project 3",
177 cwd: "/tmp/project-3",198 cwd: "/tmp/project-3",
178 model: DEFAULT_MODEL_BY_PROVIDER.codex,199 defaultModelSelection: {
200 provider: "codex",
201 model: DEFAULT_MODEL_BY_PROVIDER.codex,
202 },
179 expanded: true,203 expanded: true,
180 scripts: [],204 scripts: [],
181 },205 },
...@@ -195,20 +219,26 @@ describe("store read model sync", () => {...@@ -195,20 +219,26 @@ describe("store read model sync", () => {
195 const initialState = makeState(makeThread());219 const initialState = makeState(makeThread());
196 const readModel = makeReadModel(220 const readModel = makeReadModel(
197 makeReadModelThread({221 makeReadModelThread({
198 model: "claude-opus-4-6",222 modelSelection: {
223 provider: "claudeAgent",
224 model: "claude-opus-4-6",
225 },
199 }),226 }),
200 );227 );
201228
202 const next = syncServerReadModel(initialState, readModel);229 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");
205 });232 });
206233
207 it("resolves claude aliases when session provider is claudeAgent", () => {234 it("resolves claude aliases when session provider is claudeAgent", () => {
208 const initialState = makeState(makeThread());235 const initialState = makeState(makeThread());
209 const readModel = makeReadModel(236 const readModel = makeReadModel(
210 makeReadModelThread({237 makeReadModelThread({
211 model: "sonnet",238 modelSelection: {
239 provider: "claudeAgent",
240 model: "sonnet",
241 },
212 session: {242 session: {
213 threadId: ThreadId.makeUnsafe("thread-1"),243 threadId: ThreadId.makeUnsafe("thread-1"),
214 status: "ready",244 status: "ready",
...@@ -223,7 +253,7 @@ describe("store read model sync", () => {...@@ -223,7 +253,7 @@ describe("store read model sync", () => {
223253
224 const next = syncServerReadModel(initialState, readModel);254 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");
227 });257 });
228258
229 it("preserves project and thread updatedAt timestamps from the read model", () => {259 it("preserves project and thread updatedAt timestamps from the read model", () => {
...@@ -250,7 +280,10 @@ describe("store read model sync", () => {...@@ -250,7 +280,10 @@ describe("store read model sync", () => {
250 id: project2,280 id: project2,
251 name: "Project 2",281 name: "Project 2",
252 cwd: "/tmp/project-2",282 cwd: "/tmp/project-2",
253 model: DEFAULT_MODEL_BY_PROVIDER.codex,283 defaultModelSelection: {
284 provider: "codex",
285 model: DEFAULT_MODEL_BY_PROVIDER.codex,
286 },
254 expanded: true,287 expanded: true,
255 scripts: [],288 scripts: [],
256 },289 },
...@@ -258,7 +291,10 @@ describe("store read model sync", () => {...@@ -258,7 +291,10 @@ describe("store read model sync", () => {
258 id: project1,291 id: project1,
259 name: "Project 1",292 name: "Project 1",
260 cwd: "/tmp/project-1",293 cwd: "/tmp/project-1",
261 model: DEFAULT_MODEL_BY_PROVIDER.codex,294 defaultModelSelection: {
295 provider: "codex",
296 model: DEFAULT_MODEL_BY_PROVIDER.codex,
297 },
262 expanded: true,298 expanded: true,
263 scripts: [],299 scripts: [],
264 },300 },
apps/web/src/store.ts+19-26
...@@ -1,16 +1,11 @@...@@ -1,16 +1,11 @@
1import { Fragment, type ReactNode, createElement, useEffect } from "react";1import { Fragment, type ReactNode, createElement, useEffect } from "react";
2import {2import {
3 DEFAULT_MODEL_BY_PROVIDER,
4 type ProviderKind,3 type ProviderKind,
5 ThreadId,4 ThreadId,
6 type OrchestrationReadModel,5 type OrchestrationReadModel,
7 type OrchestrationSessionStatus,6 type OrchestrationSessionStatus,
8} from "@t3tools/contracts";7} from "@t3tools/contracts";
9import {8import { resolveModelSlugForProvider } from "@t3tools/shared/model";
10 inferProviderForModel,
11 resolveModelSlug,
12 resolveModelSlugForProvider,
13} from "@t3tools/shared/model";
14import { create } from "zustand";9import { create } from "zustand";
15import { type ChatMessage, type Project, type Thread } from "./types";10import { type ChatMessage, type Project, type Thread } from "./types";
16import { Debouncer } from "@tanstack/react-pacer";11import { Debouncer } from "@tanstack/react-pacer";
...@@ -137,9 +132,17 @@ function mapProjectsFromReadModel(...@@ -137,9 +132,17 @@ function mapProjectsFromReadModel(
137 id: project.id,132 id: project.id,
138 name: project.title,133 name: project.title,
139 cwd: project.workspaceRoot,134 cwd: project.workspaceRoot,
140 model:135 defaultModelSelection:
141 existing?.model ??136 existing?.defaultModelSelection ??
142 resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex),137 (project.defaultModelSelection
138 ? {
139 ...project.defaultModelSelection,
140 model: resolveModelSlugForProvider(
141 project.defaultModelSelection.provider,
142 project.defaultModelSelection.model,
143 ),
144 }
145 : null),
143 expanded:146 expanded:
144 existing?.expanded ??147 existing?.expanded ??
145 (persistedExpandedProjectCwds.size > 0148 (persistedExpandedProjectCwds.size > 0
...@@ -196,16 +199,6 @@ function toLegacyProvider(providerName: string | null): ProviderKind {...@@ -196,16 +199,6 @@ function toLegacyProvider(providerName: string | null): ProviderKind {
196 return "codex";199 return "codex";
197}200}
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
209function resolveWsHttpOrigin(): string {202function resolveWsHttpOrigin(): string {
210 if (typeof window === "undefined") return "";203 if (typeof window === "undefined") return "";
211 const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();204 const bridgeWsUrl = window.desktopBridge?.getWsUrl?.();
...@@ -255,13 +248,13 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea...@@ -255,13 +248,13 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea
255 codexThreadId: null,248 codexThreadId: null,
256 projectId: thread.projectId,249 projectId: thread.projectId,
257 title: thread.title,250 title: thread.title,
258 model: resolveModelSlugForProvider(251 modelSelection: {
259 inferProviderForThreadModel({252 ...thread.modelSelection,
260 model: thread.model,253 model: resolveModelSlugForProvider(
261 sessionProviderName: thread.session?.providerName ?? null,254 thread.modelSelection.provider,
262 }),255 thread.modelSelection.model,
263 thread.model,256 ),
264 ),257 },
265 runtimeMode: thread.runtimeMode,258 runtimeMode: thread.runtimeMode,
266 interactionMode: thread.interactionMode,259 interactionMode: thread.interactionMode,
267 session: thread.session260 session: thread.session
apps/web/src/types.ts+4-3
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type {1import type {
2 ModelSelection,
2 OrchestrationLatestTurn,3 OrchestrationLatestTurn,
3 OrchestrationProposedPlanId,4 OrchestrationProposedPlanId,
4 OrchestrationSessionStatus,5 OrchestrationSessionStatus,
...@@ -8,8 +9,8 @@ import type {...@@ -8,8 +9,8 @@ import type {
8 ProjectId,9 ProjectId,
9 TurnId,10 TurnId,
10 MessageId,11 MessageId,
11 CheckpointRef,
12 ProviderKind,12 ProviderKind,
13 CheckpointRef,
13 ProviderInteractionMode,14 ProviderInteractionMode,
14 RuntimeMode,15 RuntimeMode,
15} from "@t3tools/contracts";16} from "@t3tools/contracts";
...@@ -80,7 +81,7 @@ export interface Project {...@@ -80,7 +81,7 @@ export interface Project {
80 id: ProjectId;81 id: ProjectId;
81 name: string;82 name: string;
82 cwd: string;83 cwd: string;
83 model: string;84 defaultModelSelection: ModelSelection | null;
84 expanded: boolean;85 expanded: boolean;
85 createdAt?: string | undefined;86 createdAt?: string | undefined;
86 updatedAt?: string | undefined;87 updatedAt?: string | undefined;
...@@ -92,7 +93,7 @@ export interface Thread {...@@ -92,7 +93,7 @@ export interface Thread {
92 codexThreadId: string | null;93 codexThreadId: string | null;
93 projectId: ProjectId;94 projectId: ProjectId;
94 title: string;95 title: string;
95 model: string;96 modelSelection: ModelSelection;
96 runtimeMode: RuntimeMode;97 runtimeMode: RuntimeMode;
97 interactionMode: ProviderInteractionMode;98 interactionMode: ProviderInteractionMode;
98 session: ThreadSession | null;99 session: ThreadSession | null;
apps/web/src/worktreeCleanup.test.ts+4-1
...@@ -10,7 +10,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {...@@ -10,7 +10,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
10 codexThreadId: null,10 codexThreadId: null,
11 projectId: ProjectId.makeUnsafe("project-1"),11 projectId: ProjectId.makeUnsafe("project-1"),
12 title: "Thread",12 title: "Thread",
13 model: "gpt-5.3-codex",13 modelSelection: {
14 provider: "codex",
15 model: "gpt-5.3-codex",
16 },
14 runtimeMode: DEFAULT_RUNTIME_MODE,17 runtimeMode: DEFAULT_RUNTIME_MODE,
15 interactionMode: DEFAULT_INTERACTION_MODE,18 interactionMode: DEFAULT_INTERACTION_MODE,
16 session: null,19 session: null,
apps/web/src/wsNativeApi.test.ts+5-2
...@@ -269,7 +269,7 @@ describe("wsNativeApi", () => {...@@ -269,7 +269,7 @@ describe("wsNativeApi", () => {
269 projectId: ProjectId.makeUnsafe("project-1"),269 projectId: ProjectId.makeUnsafe("project-1"),
270 title: "Project",270 title: "Project",
271 workspaceRoot: "/tmp/workspace",271 workspaceRoot: "/tmp/workspace",
272 defaultModel: null,272 defaultModelSelection: null,
273 scripts: [],273 scripts: [],
274 createdAt: "2026-02-24T00:00:00.000Z",274 createdAt: "2026-02-24T00:00:00.000Z",
275 updatedAt: "2026-02-24T00:00:00.000Z",275 updatedAt: "2026-02-24T00:00:00.000Z",
...@@ -311,7 +311,10 @@ describe("wsNativeApi", () => {...@@ -311,7 +311,10 @@ describe("wsNativeApi", () => {
311 projectId: ProjectId.makeUnsafe("project-1"),311 projectId: ProjectId.makeUnsafe("project-1"),
312 title: "Project",312 title: "Project",
313 workspaceRoot: "/tmp/project",313 workspaceRoot: "/tmp/project",
314 defaultModel: "gpt-5-codex",314 defaultModelSelection: {
315 provider: "codex",
316 model: "gpt-5-codex",
317 },
315 createdAt: "2026-02-24T00:00:00.000Z",318 createdAt: "2026-02-24T00:00:00.000Z",
316 } as const;319 } as const;
317 await api.orchestration.dispatchCommand(command);320 await api.orchestration.dispatchCommand(command);
packages/contracts/src/model.ts+165-19
...@@ -26,26 +26,166 @@ export const ProviderModelOptions = Schema.Struct({...@@ -26,26 +26,166 @@ export const ProviderModelOptions = Schema.Struct({
26});26});
27export type ProviderModelOptions = typeof ProviderModelOptions.Type;27export 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 = {
30 readonly slug: string;43 readonly slug: string;
31 readonly name: string;44 readonly name: string;
45 readonly capabilities: ModelCapabilities;
32};46};
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 */
34export const MODEL_OPTIONS_BY_PROVIDER = {52export const MODEL_OPTIONS_BY_PROVIDER = {
35 codex: [53 codex: [
36 { slug: "gpt-5.4", name: "GPT-5.4" },54 {
37 { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" },55 slug: "gpt-5.4",
38 { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" },56 name: "GPT-5.4",
39 { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" },57 capabilities: {
40 { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" },58 reasoningEffortLevels: [
41 { slug: "gpt-5.2", name: "GPT-5.2" },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 },
42 ],144 ],
43 claudeAgent: [145 claudeAgent: [
44 { slug: "claude-opus-4-6", name: "Claude Opus 4.6" },146 {
45 { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },147 slug: "claude-opus-4-6",
46 { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" },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 },
47 ],187 ],
48} as const satisfies Record<ProviderKind, readonly ModelOption[]>;188} as const satisfies Record<ProviderKind, readonly ModelDefinition[]>;
49export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER;189export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER;
50190
51type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"];191type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"];
...@@ -85,12 +225,18 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,...@@ -85,12 +225,18 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record<ProviderKind, Record<string,
85 },225 },
86};226};
87227
88export const REASONING_EFFORT_OPTIONS_BY_PROVIDER = {228// ── Model capabilities index ──────────────────────────────────────────
89 codex: CODEX_REASONING_EFFORT_OPTIONS,
90 claudeAgent: CLAUDE_CODE_EFFORT_OPTIONS,
91} as const satisfies Record<ProviderKind, readonly ProviderReasoningEffort[]>;
92229
93export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = {230export const MODEL_CAPABILITIES_INDEX = Object.fromEntries(
94 codex: "high",231 Object.entries(MODEL_OPTIONS_BY_PROVIDER).map(([provider, models]) => [
95 claudeAgent: "high",232 provider,
96} as const satisfies Record<ProviderKind, ProviderReasoningEffort>;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 {...@@ -7,9 +7,12 @@ import {
7 DEFAULT_RUNTIME_MODE,7 DEFAULT_RUNTIME_MODE,
8 OrchestrationGetTurnDiffInput,8 OrchestrationGetTurnDiffInput,
9 OrchestrationLatestTurn,9 OrchestrationLatestTurn,
10 ProjectCreatedPayload,
11 ProjectMetaUpdatedPayload,
10 OrchestrationProposedPlan,12 OrchestrationProposedPlan,
11 OrchestrationSession,13 OrchestrationSession,
12 ProjectCreateCommand,14 ProjectCreateCommand,
15 ThreadMetaUpdatedPayload,
13 ThreadTurnStartCommand,16 ThreadTurnStartCommand,
14 ThreadCreatedPayload,17 ThreadCreatedPayload,
15 ThreadTurnDiff,18 ThreadTurnDiff,
...@@ -19,6 +22,8 @@ import {...@@ -19,6 +22,8 @@ import {
19const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);22const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
20const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);23const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
21const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);24const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
25const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload);
26const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload);
22const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);27const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand);
23const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(28const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect(
24 ThreadTurnStartRequestedPayload,29 ThreadTurnStartRequestedPayload,
...@@ -27,6 +32,7 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa...@@ -27,6 +32,7 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa
27const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan);32const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan);
28const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);33const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession);
29const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload);34const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload);
35const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload);
3036
31it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>37it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
32 Effect.gen(function* () {38 Effect.gen(function* () {
...@@ -75,14 +81,52 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()...@@ -75,14 +81,52 @@ it.effect("trims branded ids and command string fields at decode boundaries", ()
75 projectId: " project-1 ",81 projectId: " project-1 ",
76 title: " Project Title ",82 title: " Project Title ",
77 workspaceRoot: " /tmp/workspace ",83 workspaceRoot: " /tmp/workspace ",
78 defaultModel: " gpt-5.2 ",84 defaultModelSelection: {
85 provider: "codex",
86 model: " gpt-5.2 ",
87 },
79 createdAt: "2026-01-01T00:00:00.000Z",88 createdAt: "2026-01-01T00:00:00.000Z",
80 });89 });
81 assert.strictEqual(parsed.commandId, "cmd-1");90 assert.strictEqual(parsed.commandId, "cmd-1");
82 assert.strictEqual(parsed.projectId, "project-1");91 assert.strictEqual(parsed.projectId, "project-1");
83 assert.strictEqual(parsed.title, "Project Title");92 assert.strictEqual(parsed.title, "Project Title");
84 assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace");93 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");
86 }),130 }),
87);131);
88132
...@@ -116,7 +160,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()...@@ -116,7 +160,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", ()
116 },160 },
117 createdAt: "2026-01-01T00:00:00.000Z",161 createdAt: "2026-01-01T00:00:00.000Z",
118 });162 });
119 assert.strictEqual(parsed.provider, undefined);163 assert.strictEqual(parsed.modelSelection, undefined);
120 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);164 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
121 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);165 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
122 }),166 }),
...@@ -134,11 +178,14 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (...@@ -134,11 +178,14 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", (
134 text: "hello",178 text: "hello",
135 attachments: [],179 attachments: [],
136 },180 },
137 provider: "codex",181 modelSelection: {
182 provider: "codex",
183 model: "gpt-5.4",
184 },
138 runtimeMode: "full-access",185 runtimeMode: "full-access",
139 createdAt: "2026-01-01T00:00:00.000Z",186 createdAt: "2026-01-01T00:00:00.000Z",
140 });187 });
141 assert.strictEqual(parsed.provider, "codex");188 assert.strictEqual(parsed.modelSelection?.provider, "codex");
142 assert.strictEqual(parsed.runtimeMode, "full-access");189 assert.strictEqual(parsed.runtimeMode, "full-access");
143 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);190 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
144 }),191 }),
...@@ -150,7 +197,10 @@ it.effect("decodes thread.created runtime mode for historical events", () =>...@@ -150,7 +197,10 @@ it.effect("decodes thread.created runtime mode for historical events", () =>
150 threadId: "thread-1",197 threadId: "thread-1",
151 projectId: "project-1",198 projectId: "project-1",
152 title: "Thread title",199 title: "Thread title",
153 model: "gpt-5.4",200 modelSelection: {
201 provider: "codex",
202 model: "gpt-5.4",
203 },
154 interactionMode: "default",204 interactionMode: "default",
155 branch: null,205 branch: null,
156 worktreePath: null,206 worktreePath: null,
...@@ -159,6 +209,21 @@ it.effect("decodes thread.created runtime mode for historical events", () =>...@@ -159,6 +209,21 @@ it.effect("decodes thread.created runtime mode for historical events", () =>
159 });209 });
160210
161 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);211 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");
162 }),227 }),
163);228);
164229
...@@ -174,19 +239,19 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>...@@ -174,19 +239,19 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () =>
174 text: "hello",239 text: "hello",
175 attachments: [],240 attachments: [],
176 },241 },
177 provider: "codex",242 modelSelection: {
178 model: "gpt-5.3-codex",243 provider: "codex",
179 modelOptions: {244 model: "gpt-5.3-codex",
180 codex: {245 options: {
181 reasoningEffort: "high",246 reasoningEffort: "high",
182 fastMode: true,247 fastMode: true,
183 },248 },
184 },249 },
185 createdAt: "2026-01-01T00:00:00.000Z",250 createdAt: "2026-01-01T00:00:00.000Z",
186 });251 });
187 assert.strictEqual(parsed.provider, "codex");252 assert.strictEqual(parsed.modelSelection?.provider, "codex");
188 assert.strictEqual(parsed.modelOptions?.codex?.reasoningEffort, "high");253 assert.strictEqual(parsed.modelSelection?.options?.reasoningEffort, "high");
189 assert.strictEqual(parsed.modelOptions?.codex?.fastMode, true);254 assert.strictEqual(parsed.modelSelection?.options?.fastMode, true);
190 }),255 }),
191);256);
192257
...@@ -224,7 +289,7 @@ it.effect(...@@ -224,7 +289,7 @@ it.effect(
224 messageId: "msg-1",289 messageId: "msg-1",
225 createdAt: "2026-01-01T00:00:00.000Z",290 createdAt: "2026-01-01T00:00:00.000Z",
226 });291 });
227 assert.strictEqual(parsed.provider, undefined);292 assert.strictEqual(parsed.modelSelection, undefined);
228 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);293 assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE);
229 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);294 assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE);
230 assert.strictEqual(parsed.sourceProposedPlan, undefined);295 assert.strictEqual(parsed.sourceProposedPlan, undefined);
packages/contracts/src/orchestration.ts+31-20
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { Option, Schema, SchemaIssue, Struct } from "effect";1import { Option, Schema, SchemaIssue, Struct } from "effect";
2import { ProviderModelOptions } from "./model";2import { ClaudeModelOptions, CodexModelOptions } from "./model";
3import {3import {
4 ApprovalRequestId,4 ApprovalRequestId,
5 CheckpointRef,5 CheckpointRef,
...@@ -44,6 +44,23 @@ export const ProviderSandboxMode = Schema.Literals([...@@ -44,6 +44,23 @@ export const ProviderSandboxMode = Schema.Literals([
44export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;44export type ProviderSandboxMode = typeof ProviderSandboxMode.Type;
45export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex";45export 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
47export const CodexProviderStartOptions = Schema.Struct({64export const CodexProviderStartOptions = Schema.Struct({
48 binaryPath: Schema.optional(TrimmedNonEmptyString),65 binaryPath: Schema.optional(TrimmedNonEmptyString),
49 homePath: Schema.optional(TrimmedNonEmptyString),66 homePath: Schema.optional(TrimmedNonEmptyString),
...@@ -144,7 +161,7 @@ export const OrchestrationProject = Schema.Struct({...@@ -144,7 +161,7 @@ export const OrchestrationProject = Schema.Struct({
144 id: ProjectId,161 id: ProjectId,
145 title: TrimmedNonEmptyString,162 title: TrimmedNonEmptyString,
146 workspaceRoot: TrimmedNonEmptyString,163 workspaceRoot: TrimmedNonEmptyString,
147 defaultModel: Schema.NullOr(TrimmedNonEmptyString),164 defaultModelSelection: Schema.NullOr(ModelSelection),
148 scripts: Schema.Array(ProjectScript),165 scripts: Schema.Array(ProjectScript),
149 createdAt: IsoDateTime,166 createdAt: IsoDateTime,
150 updatedAt: IsoDateTime,167 updatedAt: IsoDateTime,
...@@ -273,7 +290,7 @@ export const OrchestrationThread = Schema.Struct({...@@ -273,7 +290,7 @@ export const OrchestrationThread = Schema.Struct({
273 id: ThreadId,290 id: ThreadId,
274 projectId: ProjectId,291 projectId: ProjectId,
275 title: TrimmedNonEmptyString,292 title: TrimmedNonEmptyString,
276 model: TrimmedNonEmptyString,293 modelSelection: ModelSelection,
277 runtimeMode: RuntimeMode,294 runtimeMode: RuntimeMode,
278 interactionMode: ProviderInteractionMode.pipe(295 interactionMode: ProviderInteractionMode.pipe(
279 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),296 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
...@@ -306,7 +323,7 @@ export const ProjectCreateCommand = Schema.Struct({...@@ -306,7 +323,7 @@ export const ProjectCreateCommand = Schema.Struct({
306 projectId: ProjectId,323 projectId: ProjectId,
307 title: TrimmedNonEmptyString,324 title: TrimmedNonEmptyString,
308 workspaceRoot: TrimmedNonEmptyString,325 workspaceRoot: TrimmedNonEmptyString,
309 defaultModel: Schema.optional(TrimmedNonEmptyString),326 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
310 createdAt: IsoDateTime,327 createdAt: IsoDateTime,
311});328});
312329
...@@ -316,7 +333,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({...@@ -316,7 +333,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({
316 projectId: ProjectId,333 projectId: ProjectId,
317 title: Schema.optional(TrimmedNonEmptyString),334 title: Schema.optional(TrimmedNonEmptyString),
318 workspaceRoot: Schema.optional(TrimmedNonEmptyString),335 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
319 defaultModel: Schema.optional(TrimmedNonEmptyString),336 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
320 scripts: Schema.optional(Schema.Array(ProjectScript)),337 scripts: Schema.optional(Schema.Array(ProjectScript)),
321});338});
322339
...@@ -332,7 +349,7 @@ const ThreadCreateCommand = Schema.Struct({...@@ -332,7 +349,7 @@ const ThreadCreateCommand = Schema.Struct({
332 threadId: ThreadId,349 threadId: ThreadId,
333 projectId: ProjectId,350 projectId: ProjectId,
334 title: TrimmedNonEmptyString,351 title: TrimmedNonEmptyString,
335 model: TrimmedNonEmptyString,352 modelSelection: ModelSelection,
336 runtimeMode: RuntimeMode,353 runtimeMode: RuntimeMode,
337 interactionMode: ProviderInteractionMode.pipe(354 interactionMode: ProviderInteractionMode.pipe(
338 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),355 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
...@@ -353,7 +370,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({...@@ -353,7 +370,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({
353 commandId: CommandId,370 commandId: CommandId,
354 threadId: ThreadId,371 threadId: ThreadId,
355 title: Schema.optional(TrimmedNonEmptyString),372 title: Schema.optional(TrimmedNonEmptyString),
356 model: Schema.optional(TrimmedNonEmptyString),373 modelSelection: Schema.optional(ModelSelection),
357 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),374 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
358 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),375 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
359});376});
...@@ -384,9 +401,7 @@ export const ThreadTurnStartCommand = Schema.Struct({...@@ -384,9 +401,7 @@ export const ThreadTurnStartCommand = Schema.Struct({
384 text: Schema.String,401 text: Schema.String,
385 attachments: Schema.Array(ChatAttachment),402 attachments: Schema.Array(ChatAttachment),
386 }),403 }),
387 provider: Schema.optional(ProviderKind),404 modelSelection: Schema.optional(ModelSelection),
388 model: Schema.optional(TrimmedNonEmptyString),
389 modelOptions: Schema.optional(ProviderModelOptions),
390 providerOptions: Schema.optional(ProviderStartOptions),405 providerOptions: Schema.optional(ProviderStartOptions),
391 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),406 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
392 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),407 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
...@@ -407,9 +422,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({...@@ -407,9 +422,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({
407 text: Schema.String,422 text: Schema.String,
408 attachments: Schema.Array(UploadChatAttachment),423 attachments: Schema.Array(UploadChatAttachment),
409 }),424 }),
410 provider: Schema.optional(ProviderKind),425 modelSelection: Schema.optional(ModelSelection),
411 model: Schema.optional(TrimmedNonEmptyString),
412 modelOptions: Schema.optional(ProviderModelOptions),
413 providerOptions: Schema.optional(ProviderStartOptions),426 providerOptions: Schema.optional(ProviderStartOptions),
414 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),427 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
415 runtimeMode: RuntimeMode,428 runtimeMode: RuntimeMode,
...@@ -610,7 +623,7 @@ export const ProjectCreatedPayload = Schema.Struct({...@@ -610,7 +623,7 @@ export const ProjectCreatedPayload = Schema.Struct({
610 projectId: ProjectId,623 projectId: ProjectId,
611 title: TrimmedNonEmptyString,624 title: TrimmedNonEmptyString,
612 workspaceRoot: TrimmedNonEmptyString,625 workspaceRoot: TrimmedNonEmptyString,
613 defaultModel: Schema.NullOr(TrimmedNonEmptyString),626 defaultModelSelection: Schema.NullOr(ModelSelection),
614 scripts: Schema.Array(ProjectScript),627 scripts: Schema.Array(ProjectScript),
615 createdAt: IsoDateTime,628 createdAt: IsoDateTime,
616 updatedAt: IsoDateTime,629 updatedAt: IsoDateTime,
...@@ -620,7 +633,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({...@@ -620,7 +633,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({
620 projectId: ProjectId,633 projectId: ProjectId,
621 title: Schema.optional(TrimmedNonEmptyString),634 title: Schema.optional(TrimmedNonEmptyString),
622 workspaceRoot: Schema.optional(TrimmedNonEmptyString),635 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
623 defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),636 defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)),
624 scripts: Schema.optional(Schema.Array(ProjectScript)),637 scripts: Schema.optional(Schema.Array(ProjectScript)),
625 updatedAt: IsoDateTime,638 updatedAt: IsoDateTime,
626});639});
...@@ -634,7 +647,7 @@ export const ThreadCreatedPayload = Schema.Struct({...@@ -634,7 +647,7 @@ export const ThreadCreatedPayload = Schema.Struct({
634 threadId: ThreadId,647 threadId: ThreadId,
635 projectId: ProjectId,648 projectId: ProjectId,
636 title: TrimmedNonEmptyString,649 title: TrimmedNonEmptyString,
637 model: TrimmedNonEmptyString,650 modelSelection: ModelSelection,
638 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),651 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
639 interactionMode: ProviderInteractionMode.pipe(652 interactionMode: ProviderInteractionMode.pipe(
640 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),653 Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE),
...@@ -653,7 +666,7 @@ export const ThreadDeletedPayload = Schema.Struct({...@@ -653,7 +666,7 @@ export const ThreadDeletedPayload = Schema.Struct({
653export const ThreadMetaUpdatedPayload = Schema.Struct({666export const ThreadMetaUpdatedPayload = Schema.Struct({
654 threadId: ThreadId,667 threadId: ThreadId,
655 title: Schema.optional(TrimmedNonEmptyString),668 title: Schema.optional(TrimmedNonEmptyString),
656 model: Schema.optional(TrimmedNonEmptyString),669 modelSelection: Schema.optional(ModelSelection),
657 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),670 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
658 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),671 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
659 updatedAt: IsoDateTime,672 updatedAt: IsoDateTime,
...@@ -688,9 +701,7 @@ export const ThreadMessageSentPayload = Schema.Struct({...@@ -688,9 +701,7 @@ export const ThreadMessageSentPayload = Schema.Struct({
688export const ThreadTurnStartRequestedPayload = Schema.Struct({701export const ThreadTurnStartRequestedPayload = Schema.Struct({
689 threadId: ThreadId,702 threadId: ThreadId,
690 messageId: MessageId,703 messageId: MessageId,
691 provider: Schema.optional(ProviderKind),704 modelSelection: Schema.optional(ModelSelection),
692 model: Schema.optional(TrimmedNonEmptyString),
693 modelOptions: Schema.optional(ProviderModelOptions),
694 providerOptions: Schema.optional(ProviderStartOptions),705 providerOptions: Schema.optional(ProviderStartOptions),
695 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),706 assistantDeliveryMode: Schema.optional(AssistantDeliveryMode),
696 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),707 runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)),
packages/contracts/src/provider.test.ts+46-24
...@@ -12,9 +12,10 @@ describe("ProviderSessionStartInput", () => {...@@ -12,9 +12,10 @@ describe("ProviderSessionStartInput", () => {
12 threadId: "thread-1",12 threadId: "thread-1",
13 provider: "codex",13 provider: "codex",
14 cwd: "/tmp/workspace",14 cwd: "/tmp/workspace",
15 model: "gpt-5.3-codex",15 modelSelection: {
16 modelOptions: {16 provider: "codex",
17 codex: {17 model: "gpt-5.3-codex",
18 options: {
18 reasoningEffort: "high",19 reasoningEffort: "high",
19 fastMode: true,20 fastMode: true,
20 },21 },
...@@ -28,8 +29,13 @@ describe("ProviderSessionStartInput", () => {...@@ -28,8 +29,13 @@ describe("ProviderSessionStartInput", () => {
28 },29 },
29 });30 });
30 expect(parsed.runtimeMode).toBe("full-access");31 expect(parsed.runtimeMode).toBe("full-access");
31 expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("high");32 expect(parsed.modelSelection?.provider).toBe("codex");
32 expect(parsed.modelOptions?.codex?.fastMode).toBe(true);33 expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex");
34 if (parsed.modelSelection?.provider !== "codex") {
35 throw new Error("Expected codex modelSelection");
36 }
37 expect(parsed.modelSelection.options?.reasoningEffort).toBe("high");
38 expect(parsed.modelSelection.options?.fastMode).toBe(true);
33 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");39 expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex");
34 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");40 expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex");
35 });41 });
...@@ -48,9 +54,10 @@ describe("ProviderSessionStartInput", () => {...@@ -48,9 +54,10 @@ describe("ProviderSessionStartInput", () => {
48 threadId: "thread-1",54 threadId: "thread-1",
49 provider: "claudeAgent",55 provider: "claudeAgent",
50 cwd: "/tmp/workspace",56 cwd: "/tmp/workspace",
51 model: "claude-sonnet-4-6",57 modelSelection: {
52 modelOptions: {58 provider: "claudeAgent",
53 claudeAgent: {59 model: "claude-sonnet-4-6",
60 options: {
54 thinking: true,61 thinking: true,
55 effort: "max",62 effort: "max",
56 fastMode: true,63 fastMode: true,
...@@ -66,9 +73,14 @@ describe("ProviderSessionStartInput", () => {...@@ -66,9 +73,14 @@ describe("ProviderSessionStartInput", () => {
66 runtimeMode: "full-access",73 runtimeMode: "full-access",
67 });74 });
68 expect(parsed.provider).toBe("claudeAgent");75 expect(parsed.provider).toBe("claudeAgent");
69 expect(parsed.modelOptions?.claudeAgent?.thinking).toBe(true);76 expect(parsed.modelSelection?.provider).toBe("claudeAgent");
70 expect(parsed.modelOptions?.claudeAgent?.effort).toBe("max");77 expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6");
71 expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true);78 if (parsed.modelSelection?.provider !== "claudeAgent") {
79 throw new Error("Expected claude modelSelection");
80 }
81 expect(parsed.modelSelection.options?.thinking).toBe(true);
82 expect(parsed.modelSelection.options?.effort).toBe("max");
83 expect(parsed.modelSelection.options?.fastMode).toBe(true);
72 expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude");84 expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude");
73 expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan");85 expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan");
74 expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000);86 expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000);
...@@ -77,36 +89,46 @@ describe("ProviderSessionStartInput", () => {...@@ -77,36 +89,46 @@ describe("ProviderSessionStartInput", () => {
77});89});
7890
79describe("ProviderSendTurnInput", () => {91describe("ProviderSendTurnInput", () => {
80 it("accepts provider-scoped model options", () => {92 it("accepts codex modelSelection", () => {
81 const parsed = decodeProviderSendTurnInput({93 const parsed = decodeProviderSendTurnInput({
82 threadId: "thread-1",94 threadId: "thread-1",
83 model: "gpt-5.3-codex",95 modelSelection: {
84 modelOptions: {96 provider: "codex",
85 codex: {97 model: "gpt-5.3-codex",
98 options: {
86 reasoningEffort: "xhigh",99 reasoningEffort: "xhigh",
87 fastMode: true,100 fastMode: true,
88 },101 },
89 },102 },
90 });103 });
91104
92 expect(parsed.model).toBe("gpt-5.3-codex");105 expect(parsed.modelSelection?.provider).toBe("codex");
93 expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh");106 expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex");
94 expect(parsed.modelOptions?.codex?.fastMode).toBe(true);107 if (parsed.modelSelection?.provider !== "codex") {
108 throw new Error("Expected codex modelSelection");
109 }
110 expect(parsed.modelSelection.options?.reasoningEffort).toBe("xhigh");
111 expect(parsed.modelSelection.options?.fastMode).toBe(true);
95 });112 });
96113
97 it("accepts claude provider effort options including ultrathink", () => {114 it("accepts claude modelSelection including ultrathink", () => {
98 const parsed = decodeProviderSendTurnInput({115 const parsed = decodeProviderSendTurnInput({
99 threadId: "thread-1",116 threadId: "thread-1",
100 model: "claude-sonnet-4-6",117 modelSelection: {
101 modelOptions: {118 provider: "claudeAgent",
102 claudeAgent: {119 model: "claude-sonnet-4-6",
120 options: {
103 effort: "ultrathink",121 effort: "ultrathink",
104 fastMode: true,122 fastMode: true,
105 },123 },
106 },124 },
107 });125 });
108126
109 expect(parsed.modelOptions?.claudeAgent?.effort).toBe("ultrathink");127 expect(parsed.modelSelection?.provider).toBe("claudeAgent");
110 expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true);128 if (parsed.modelSelection?.provider !== "claudeAgent") {
129 throw new Error("Expected claude modelSelection");
130 }
131 expect(parsed.modelSelection.options?.effort).toBe("ultrathink");
132 expect(parsed.modelSelection.options?.fastMode).toBe(true);
111 });133 });
112});134});
packages/contracts/src/provider.ts+3-5
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";2import { TrimmedNonEmptyString } from "./baseSchemas";
3import { ProviderModelOptions } from "./model";
4import {3import {
5 ApprovalRequestId,4 ApprovalRequestId,
6 EventId,5 EventId,
...@@ -11,6 +10,7 @@ import {...@@ -11,6 +10,7 @@ import {
11} from "./baseSchemas";10} from "./baseSchemas";
12import {11import {
13 ChatAttachment,12 ChatAttachment,
13 ModelSelection,
14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,14 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
15 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,15 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
16 ProviderApprovalDecision,16 ProviderApprovalDecision,
...@@ -51,8 +51,7 @@ export const ProviderSessionStartInput = Schema.Struct({...@@ -51,8 +51,7 @@ export const ProviderSessionStartInput = Schema.Struct({
51 threadId: ThreadId,51 threadId: ThreadId,
52 provider: Schema.optional(ProviderKind),52 provider: Schema.optional(ProviderKind),
53 cwd: Schema.optional(TrimmedNonEmptyString),53 cwd: Schema.optional(TrimmedNonEmptyString),
54 model: Schema.optional(TrimmedNonEmptyString),54 modelSelection: Schema.optional(ModelSelection),
55 modelOptions: Schema.optional(ProviderModelOptions),
56 resumeCursor: Schema.optional(Schema.Unknown),55 resumeCursor: Schema.optional(Schema.Unknown),
57 approvalPolicy: Schema.optional(ProviderApprovalPolicy),56 approvalPolicy: Schema.optional(ProviderApprovalPolicy),
58 sandboxMode: Schema.optional(ProviderSandboxMode),57 sandboxMode: Schema.optional(ProviderSandboxMode),
...@@ -69,8 +68,7 @@ export const ProviderSendTurnInput = Schema.Struct({...@@ -69,8 +68,7 @@ export const ProviderSendTurnInput = Schema.Struct({
69 attachments: Schema.optional(68 attachments: Schema.optional(
70 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),69 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
71 ),70 ),
72 model: Schema.optional(TrimmedNonEmptyString),71 modelSelection: Schema.optional(ModelSelection),
73 modelOptions: Schema.optional(ProviderModelOptions),
74 interactionMode: Schema.optional(ProviderInteractionMode),72 interactionMode: Schema.optional(ProviderInteractionMode),
75});73});
76export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;74export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type;
packages/shared/src/model.test.ts+68-90
...@@ -2,33 +2,25 @@ import { describe, expect, it } from "vitest";...@@ -2,33 +2,25 @@ import { describe, expect, it } from "vitest";
2import {2import {
3 DEFAULT_MODEL,3 DEFAULT_MODEL,
4 DEFAULT_MODEL_BY_PROVIDER,4 DEFAULT_MODEL_BY_PROVIDER,
5 DEFAULT_REASONING_EFFORT_BY_PROVIDER,
6 MODEL_OPTIONS,5 MODEL_OPTIONS,
7 MODEL_OPTIONS_BY_PROVIDER,6 MODEL_OPTIONS_BY_PROVIDER,
8 REASONING_EFFORT_OPTIONS_BY_PROVIDER,7 CODEX_REASONING_EFFORT_OPTIONS,
9} from "@t3tools/contracts";8} from "@t3tools/contracts";
109
11import {10import {
12 applyClaudePromptEffortPrefix,11 applyClaudePromptEffortPrefix,
13 getEffectiveClaudeCodeEffort,
14 getDefaultModel,12 getDefaultModel,
15 getDefaultReasoningEffort,13 getModelCapabilities,
16 getModelOptions,14 getModelOptions,
17 getReasoningEffortOptions,
18 inferProviderForModel,
19 isClaudeUltrathinkPrompt,15 isClaudeUltrathinkPrompt,
20 normalizeClaudeModelOptions,16 normalizeClaudeModelOptions,
21 normalizeCodexModelOptions,17 normalizeCodexModelOptions,
22 normalizeModelSlug,18 normalizeModelSlug,
23 resolveReasoningEffortForProvider,
24 resolveSelectableModel,19 resolveSelectableModel,
25 resolveModelSlug,20 resolveModelSlug,
26 resolveModelSlugForProvider,21 resolveModelSlugForProvider,
27 supportsClaudeAdaptiveReasoning,22 getDefaultEffort,
28 supportsClaudeFastMode,23 hasEffortLevel,
29 supportsClaudeMaxEffort,
30 supportsClaudeThinkingToggle,
31 supportsClaudeUltrathinkKeyword,
32} from "./model";24} from "./model";
3325
34describe("normalizeModelSlug", () => {26describe("normalizeModelSlug", () => {
...@@ -159,13 +151,16 @@ describe("resolveSelectableModel", () => {...@@ -159,13 +151,16 @@ describe("resolveSelectableModel", () => {
159 });151 });
160});152});
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
163 it("returns codex reasoning options for codex", () => {158 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]);
165 });160 });
166161
167 it("returns claude effort options for Opus 4.6", () => {162 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([
169 "low",164 "low",
170 "medium",165 "medium",
171 "high",166 "high",
...@@ -175,7 +170,7 @@ describe("getReasoningEffortOptions", () => {...@@ -175,7 +170,7 @@ describe("getReasoningEffortOptions", () => {
175 });170 });
176171
177 it("returns claude effort options for Sonnet 4.6", () => {172 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([
179 "low",174 "low",
180 "medium",175 "medium",
181 "high",176 "high",
...@@ -184,45 +179,37 @@ describe("getReasoningEffortOptions", () => {...@@ -184,45 +179,37 @@ describe("getReasoningEffortOptions", () => {
184 });179 });
185180
186 it("returns no claude effort options for Haiku 4.5", () => {181 it("returns no claude effort options for Haiku 4.5", () => {
187 expect(getReasoningEffortOptions("claudeAgent", "claude-haiku-4-5")).toEqual([]);182 expect(values("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");
201 });183 });
202184
203 it("treats claude-prefixed custom slugs as claude", () => {185 it("co-locates labels with effort values", () => {
204 expect(inferProviderForModel("claude-custom-internal")).toBe("claudeAgent");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" });
205 });193 });
206});194});
207195
208describe("getDefaultReasoningEffort", () => {196describe("getDefaultEffort", () => {
209 it("returns provider-scoped defaults", () => {197 it("returns the default effort from capabilities", () => {
210 expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex);198 expect(getDefaultEffort(getModelCapabilities("codex", "gpt-5.4"))).toBe("high");
211 expect(getDefaultReasoningEffort("claudeAgent")).toBe(199 expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe("high");
212 DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent,200 expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBeNull();
213 );
214 });201 });
215});202});
216203
217describe("resolveReasoningEffortForProvider", () => {204describe("hasEffortLevel", () => {
218 it("accepts provider-scoped effort values", () => {205 it("validates effort against model capabilities", () => {
219 expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh");206 const opusCaps = getModelCapabilities("claudeAgent", "claude-opus-4-6");
220 expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink");207 expect(hasEffortLevel(opusCaps, "max")).toBe(true);
221 });208 expect(hasEffortLevel(opusCaps, "xhigh")).toBe(false);
222209
223 it("rejects effort values from the wrong provider", () => {210 const codexCaps = getModelCapabilities("codex", "gpt-5.4");
224 expect(resolveReasoningEffortForProvider("codex", "max")).toBeNull();211 expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true);
225 expect(resolveReasoningEffortForProvider("claudeAgent", "xhigh")).toBeNull();212 expect(hasEffortLevel(codexCaps, "max")).toBe(false);
226 });213 });
227});214});
228215
...@@ -241,27 +228,17 @@ describe("applyClaudePromptEffortPrefix", () => {...@@ -241,27 +228,17 @@ describe("applyClaudePromptEffortPrefix", () => {
241 });228 });
242});229});
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
256describe("normalizeCodexModelOptions", () => {231describe("normalizeCodexModelOptions", () => {
257 it("drops default-only codex options", () => {232 it("drops default-only codex options", () => {
258 expect(233 expect(
259 normalizeCodexModelOptions({ reasoningEffort: "high", fastMode: false }),234 normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "high", fastMode: false }),
260 ).toBeUndefined();235 ).toBeUndefined();
261 });236 });
262237
263 it("preserves non-default codex options", () => {238 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({
265 reasoningEffort: "xhigh",242 reasoningEffort: "xhigh",
266 fastMode: true,243 fastMode: true,
267 });244 });
...@@ -290,49 +267,50 @@ describe("normalizeClaudeModelOptions", () => {...@@ -290,49 +267,50 @@ describe("normalizeClaudeModelOptions", () => {
290 });267 });
291});268});
292269
293describe("supportsClaudeAdaptiveReasoning", () => {270describe("getModelCapabilities Claude capability flags", () => {
294 it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => {271 it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => {
295 expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true);272 const has = (m: string | undefined) =>
296 expect(supportsClaudeAdaptiveReasoning("claude-sonnet-4-6")).toBe(true);273 getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0;
297 expect(supportsClaudeAdaptiveReasoning("claude-haiku-4-5")).toBe(false);274 expect(has("claude-opus-4-6")).toBe(true);
298 expect(supportsClaudeAdaptiveReasoning(undefined)).toBe(false);275 expect(has("claude-sonnet-4-6")).toBe(true);
276 expect(has("claude-haiku-4-5")).toBe(false);
277 expect(has(undefined)).toBe(false);
299 });278 });
300});
301279
302describe("supportsClaudeMaxEffort", () => {
303 it("only enables max effort for Opus 4.6", () => {280 it("only enables max effort for Opus 4.6", () => {
304 expect(supportsClaudeMaxEffort("claude-opus-4-6")).toBe(true);281 const has = (m: string | undefined) =>
305 expect(supportsClaudeMaxEffort("claude-sonnet-4-6")).toBe(false);282 getModelCapabilities("claudeAgent", m).reasoningEffortLevels.some((l) => l.value === "max");
306 expect(supportsClaudeMaxEffort("claude-haiku-4-5")).toBe(false);283 expect(has("claude-opus-4-6")).toBe(true);
307 expect(supportsClaudeMaxEffort(undefined)).toBe(false);284 expect(has("claude-sonnet-4-6")).toBe(false);
285 expect(has("claude-haiku-4-5")).toBe(false);
286 expect(has(undefined)).toBe(false);
308 });287 });
309});
310288
311describe("supportsClaudeFastMode", () => {
312 it("only enables Claude fast mode for Opus 4.6", () => {289 it("only enables Claude fast mode for Opus 4.6", () => {
313 expect(supportsClaudeFastMode("claude-opus-4-6")).toBe(true);290 const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).supportsFastMode;
314 expect(supportsClaudeFastMode("opus")).toBe(true);291 expect(has("claude-opus-4-6")).toBe(true);
315 expect(supportsClaudeFastMode("claude-sonnet-4-6")).toBe(false);292 expect(has("opus")).toBe(true);
316 expect(supportsClaudeFastMode("claude-haiku-4-5")).toBe(false);293 expect(has("claude-sonnet-4-6")).toBe(false);
317 expect(supportsClaudeFastMode(undefined)).toBe(false);294 expect(has("claude-haiku-4-5")).toBe(false);
295 expect(has(undefined)).toBe(false);
318 });296 });
319});
320297
321describe("supportsClaudeUltrathinkKeyword", () => {
322 it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => {298 it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => {
323 expect(supportsClaudeUltrathinkKeyword("claude-opus-4-6")).toBe(true);299 const has = (m: string | undefined) =>
324 expect(supportsClaudeUltrathinkKeyword("claude-sonnet-4-6")).toBe(true);300 getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0;
325 expect(supportsClaudeUltrathinkKeyword("claude-haiku-4-5")).toBe(false);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);
326 });304 });
327});
328305
329describe("supportsClaudeThinkingToggle", () => {
330 it("only enables the Claude thinking toggle for Haiku 4.5", () => {306 it("only enables the Claude thinking toggle for Haiku 4.5", () => {
331 expect(supportsClaudeThinkingToggle("claude-opus-4-6")).toBe(false);307 const has = (m: string | undefined) =>
332 expect(supportsClaudeThinkingToggle("claude-sonnet-4-6")).toBe(false);308 getModelCapabilities("claudeAgent", m).supportsThinkingToggle;
333 expect(supportsClaudeThinkingToggle("claude-haiku-4-5")).toBe(true);309 expect(has("claude-opus-4-6")).toBe(false);
334 expect(supportsClaudeThinkingToggle("haiku")).toBe(true);310 expect(has("claude-sonnet-4-6")).toBe(false);
335 expect(supportsClaudeThinkingToggle(undefined)).toBe(false);311 expect(has("claude-haiku-4-5")).toBe(true);
312 expect(has("haiku")).toBe(true);
313 expect(has(undefined)).toBe(false);
336 });314 });
337});315});
338316
packages/shared/src/model.ts+42-124
...@@ -1,18 +1,15 @@...@@ -1,18 +1,15 @@
1import {1import {
2 CLAUDE_CODE_EFFORT_OPTIONS,
3 CODEX_REASONING_EFFORT_OPTIONS,
4 DEFAULT_MODEL_BY_PROVIDER,2 DEFAULT_MODEL_BY_PROVIDER,
5 DEFAULT_REASONING_EFFORT_BY_PROVIDER,3 MODEL_CAPABILITIES_INDEX,
6 MODEL_OPTIONS_BY_PROVIDER,4 MODEL_OPTIONS_BY_PROVIDER,
7 MODEL_SLUG_ALIASES_BY_PROVIDER,5 MODEL_SLUG_ALIASES_BY_PROVIDER,
8 REASONING_EFFORT_OPTIONS_BY_PROVIDER,
9 type ClaudeModelOptions,6 type ClaudeModelOptions,
10 type ClaudeCodeEffort,7 type ClaudeCodeEffort,
11 type CodexModelOptions,8 type CodexModelOptions,
12 type CodexReasoningEffort,9 type ModelCapabilities,
13 type ModelSlug,10 type ModelSlug,
14 type ProviderReasoningEffort,
15 type ProviderKind,11 type ProviderKind,
12 CodexReasoningEffort,
16} from "@t3tools/contracts";13} from "@t3tools/contracts";
1714
18const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = {15const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = {
...@@ -20,10 +17,6 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> =...@@ -20,10 +17,6 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> =
20 codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)),17 codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)),
21};18};
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
27export interface SelectableModelOption {20export interface SelectableModelOption {
28 slug: string;21 slug: string;
29 name: string;22 name: string;
...@@ -37,25 +30,34 @@ export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {...@@ -37,25 +30,34 @@ export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug {
37 return DEFAULT_MODEL_BY_PROVIDER[provider];30 return DEFAULT_MODEL_BY_PROVIDER[provider];
38}31}
3932
40export function supportsClaudeFastMode(model: string | null | undefined): boolean {33// ── Effort helpers ────────────────────────────────────────────────────
41 return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL;
42}
4334
44export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean {35/** Check whether a capabilities object includes a given effort value. */
45 const normalized = normalizeModelSlug(model, "claudeAgent");36export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean {
46 return normalized === CLAUDE_OPUS_4_6_MODEL || normalized === CLAUDE_SONNET_4_6_MODEL;37 return caps.reasoningEffortLevels.some((l) => l.value === value);
47}38}
4839
49export function supportsClaudeMaxEffort(model: string | null | undefined): boolean {40/** Return the default effort value for a capabilities object, or null if none. */
50 return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL;41export function getDefaultEffort(caps: ModelCapabilities): string | null {
42 return caps.reasoningEffortLevels.find((l) => l.isDefault)?.value ?? null;
51}43}
5244
53export function supportsClaudeUltrathinkKeyword(model: string | null | undefined): boolean {45// ── Data-driven capability resolver ───────────────────────────────────
54 return supportsClaudeAdaptiveReasoning(model);
55}
5646
57export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean {47export function getModelCapabilities(
58 return normalizeModelSlug(model, "claudeAgent") === CLAUDE_HAIKU_4_5_MODEL;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 };
59}61}
6062
61export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {63export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean {
...@@ -136,102 +138,20 @@ export function resolveModelSlugForProvider(...@@ -136,102 +138,20 @@ export function resolveModelSlugForProvider(
136 return resolveModelSlug(model, provider);138 return resolveModelSlug(model, provider);
137}139}
138140
139export function inferProviderForModel(141/** Trim a string, returning null for empty/missing values. */
140 model: string | null | undefined,142export function trimOrNull<T extends string>(value: T | null | undefined): T | null {
141 fallback: ProviderKind = "codex",143 if (typeof value !== "string") return null;
142): ProviderKind {144 const trimmed = value.trim() as T;
143 const normalizedClaude = normalizeModelSlug(model, "claudeAgent");145 return trimmed || null;
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;
226}146}
227147
228export function normalizeCodexModelOptions(148export function normalizeCodexModelOptions(
149 model: string | null | undefined,
229 modelOptions: CodexModelOptions | null | undefined,150 modelOptions: CodexModelOptions | null | undefined,
230): CodexModelOptions | undefined {151): CodexModelOptions | undefined {
231 const defaultReasoningEffort = getDefaultReasoningEffort("codex");152 const caps = getModelCapabilities("codex", model);
232 const reasoningEffort =153 const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort;
233 resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ??154 const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort;
234 defaultReasoningEffort;
235 const fastModeEnabled = modelOptions?.fastMode === true;155 const fastModeEnabled = modelOptions?.fastMode === true;
236 const nextOptions: CodexModelOptions = {156 const nextOptions: CodexModelOptions = {
237 ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}),157 ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}),
...@@ -244,20 +164,20 @@ export function normalizeClaudeModelOptions(...@@ -244,20 +164,20 @@ export function normalizeClaudeModelOptions(
244 model: string | null | undefined,164 model: string | null | undefined,
245 modelOptions: ClaudeModelOptions | null | undefined,165 modelOptions: ClaudeModelOptions | null | undefined,
246): ClaudeModelOptions | undefined {166): ClaudeModelOptions | undefined {
247 const reasoningOptions = getReasoningEffortOptions("claudeAgent", model);167 const caps = getModelCapabilities("claudeAgent", model);
248 const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent");168 const defaultReasoningEffort = getDefaultEffort(caps);
249 const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort);169 const resolvedEffort = trimOrNull(modelOptions?.effort);
170 const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? "");
250 const effort =171 const effort =
251 resolvedEffort &&172 resolvedEffort &&
252 resolvedEffort !== "ultrathink" &&173 !isPromptInjected &&
253 reasoningOptions.includes(resolvedEffort) &&174 hasEffortLevel(caps, resolvedEffort) &&
254 resolvedEffort !== defaultReasoningEffort175 resolvedEffort !== defaultReasoningEffort
255 ? resolvedEffort176 ? resolvedEffort
256 : undefined;177 : undefined;
257 const thinking =178 const thinking =
258 supportsClaudeThinkingToggle(model) && modelOptions?.thinking === false ? false : undefined;179 caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined;
259 const fastMode =180 const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined;
260 supportsClaudeFastMode(model) && modelOptions?.fastMode === true ? true : undefined;
261 const nextOptions: ClaudeModelOptions = {181 const nextOptions: ClaudeModelOptions = {
262 ...(thinking === false ? { thinking: false } : {}),182 ...(thinking === false ? { thinking: false } : {}),
263 ...(effort ? { effort } : {}),183 ...(effort ? { effort } : {}),
...@@ -282,5 +202,3 @@ export function applyClaudePromptEffortPrefix(...@@ -282,5 +202,3 @@ export function applyClaudePromptEffortPrefix(
282 }202 }
283 return `Ultrathink:\n${trimmed}`;203 return `Ultrathink:\n${trimmed}`;
284}204}
285
286export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS };