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