| 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,6 +11,7 @@ import { |
| 11 | ProjectId, | 11 | ProjectId, |
| 12 | ProviderKind, | 12 | ProviderKind, |
| 13 | ThreadId, | 13 | ThreadId, |
| 14 | ModelSelection, | ||
| 14 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 15 | import { assert, it } from "@effect/vitest"; | 16 | import { assert, it } from "@effect/vitest"; |
| 16 | import { Effect, Option, Schema } from "effect"; | 17 | import { Effect, Option, Schema } from "effect"; |
| ... | @@ -115,7 +116,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => | ... | @@ -115,7 +116,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 115 | projectId: PROJECT_ID, | 116 | projectId: PROJECT_ID, |
| 116 | title: "Integration Project", | 117 | title: "Integration Project", |
| 117 | workspaceRoot: harness.workspaceDir, | 118 | workspaceRoot: harness.workspaceDir, |
| 118 | defaultModel, | 119 | defaultModelSelection: { |
| 120 | provider, | ||
| 121 | model: defaultModel, | ||
| 122 | }, | ||
| 119 | createdAt, | 123 | createdAt, |
| 120 | }); | 124 | }); |
| 121 | 125 | ||
| ... | @@ -125,7 +129,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => | ... | @@ -125,7 +129,10 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => |
| 125 | threadId: THREAD_ID, | 129 | threadId: THREAD_ID, |
| 126 | projectId: PROJECT_ID, | 130 | projectId: PROJECT_ID, |
| 127 | title: "Integration Thread", | 131 | title: "Integration Thread", |
| 128 | model: defaultModel, | 132 | modelSelection: { |
| 133 | provider, | ||
| 134 | model: defaultModel, | ||
| 135 | }, | ||
| 129 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 136 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 130 | runtimeMode: "approval-required", | 137 | runtimeMode: "approval-required", |
| 131 | branch: null, | 138 | branch: null, |
| ... | @@ -139,7 +146,7 @@ const startTurn = (input: { | ... | @@ -139,7 +146,7 @@ const startTurn = (input: { |
| 139 | readonly commandId: string; | 146 | readonly commandId: string; |
| 140 | readonly messageId: string; | 147 | readonly messageId: string; |
| 141 | readonly text: string; | 148 | readonly text: string; |
| 142 | readonly provider?: IntegrationProvider; | 149 | readonly modelSelection?: ModelSelection; |
| 143 | }) => | 150 | }) => |
| 144 | input.harness.engine.dispatch({ | 151 | input.harness.engine.dispatch({ |
| 145 | type: "thread.turn.start", | 152 | type: "thread.turn.start", |
| ... | @@ -151,7 +158,11 @@ const startTurn = (input: { | ... | @@ -151,7 +158,11 @@ const startTurn = (input: { |
| 151 | text: input.text, | 158 | text: input.text, |
| 152 | attachments: [], | 159 | attachments: [], |
| 153 | }, | 160 | }, |
| 154 | ...(input.provider !== undefined ? { provider: input.provider } : {}), | 161 | ...(input.modelSelection !== undefined |
| 162 | ? { | ||
| 163 | modelSelection: input.modelSelection, | ||
| 164 | } | ||
| 165 | : {}), | ||
| 155 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 166 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 156 | runtimeMode: "approval-required", | 167 | runtimeMode: "approval-required", |
| 157 | createdAt: nowIso(), | 168 | createdAt: nowIso(), |
| ... | @@ -254,7 +265,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( | ... | @@ -254,7 +265,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( |
| 254 | projectId: PROJECT_ID, | 265 | projectId: PROJECT_ID, |
| 255 | title: "Integration Project", | 266 | title: "Integration Project", |
| 256 | workspaceRoot: harness.workspaceDir, | 267 | workspaceRoot: harness.workspaceDir, |
| 257 | defaultModel: "gpt-5.3-codex", | 268 | defaultModelSelection: { |
| 269 | provider: "codex", | ||
| 270 | model: "gpt-5.3-codex", | ||
| 271 | }, | ||
| 258 | createdAt, | 272 | createdAt, |
| 259 | }); | 273 | }); |
| 260 | 274 | ||
| ... | @@ -264,7 +278,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( | ... | @@ -264,7 +278,10 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( |
| 264 | threadId: THREAD_ID, | 278 | threadId: THREAD_ID, |
| 265 | projectId: PROJECT_ID, | 279 | projectId: PROJECT_ID, |
| 266 | title: "Integration Thread", | 280 | title: "Integration Thread", |
| 267 | model: "gpt-5.3-codex", | 281 | modelSelection: { |
| 282 | provider: "codex", | ||
| 283 | model: "gpt-5.3-codex", | ||
| 284 | }, | ||
| 268 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 285 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 269 | runtimeMode: "full-access", | 286 | runtimeMode: "full-access", |
| 270 | branch: null, | 287 | branch: null, |
| ... | @@ -922,7 +939,10 @@ it.live("starts a claudeAgent session on first turn when provider is requested", | ... | @@ -922,7 +939,10 @@ it.live("starts a claudeAgent session on first turn when provider is requested", |
| 922 | commandId: "cmd-turn-start-claude-initial", | 939 | commandId: "cmd-turn-start-claude-initial", |
| 923 | messageId: "msg-user-claude-initial", | 940 | messageId: "msg-user-claude-initial", |
| 924 | text: "Use Claude", | 941 | text: "Use Claude", |
| 925 | provider: "claudeAgent", | 942 | modelSelection: { |
| 943 | provider: "claudeAgent", | ||
| 944 | model: "claude-sonnet-4-6", | ||
| 945 | }, | ||
| 926 | }); | 946 | }); |
| 927 | 947 | ||
| 928 | const thread = yield* harness.waitForThread( | 948 | const thread = yield* harness.waitForThread( |
| ... | @@ -976,7 +996,10 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re | ... | @@ -976,7 +996,10 @@ it.live("recovers claudeAgent sessions after provider stopAll using persisted re |
| 976 | commandId: "cmd-turn-start-claude-recover-1", | 996 | commandId: "cmd-turn-start-claude-recover-1", |
| 977 | messageId: "msg-user-claude-recover-1", | 997 | messageId: "msg-user-claude-recover-1", |
| 978 | text: "Before restart", | 998 | text: "Before restart", |
| 979 | provider: "claudeAgent", | 999 | modelSelection: { |
| 1000 | provider: "claudeAgent", | ||
| 1001 | model: "claude-sonnet-4-6", | ||
| 1002 | }, | ||
| 980 | }); | 1003 | }); |
| 981 | 1004 | ||
| 982 | yield* harness.waitForThread( | 1005 | yield* harness.waitForThread( |
| ... | @@ -1083,7 +1106,10 @@ it.live("forwards claudeAgent approval responses to the provider session", () => | ... | @@ -1083,7 +1106,10 @@ it.live("forwards claudeAgent approval responses to the provider session", () => |
| 1083 | commandId: "cmd-turn-start-claude-approval", | 1106 | commandId: "cmd-turn-start-claude-approval", |
| 1084 | messageId: "msg-user-claude-approval", | 1107 | messageId: "msg-user-claude-approval", |
| 1085 | text: "Need approval", | 1108 | text: "Need approval", |
| 1086 | provider: "claudeAgent", | 1109 | modelSelection: { |
| 1110 | provider: "claudeAgent", | ||
| 1111 | model: "claude-sonnet-4-6", | ||
| 1112 | }, | ||
| 1087 | }); | 1113 | }); |
| 1088 | 1114 | ||
| 1089 | const thread = yield* harness.waitForThread(THREAD_ID, (entry) => | 1115 | const thread = yield* harness.waitForThread(THREAD_ID, (entry) => |
| ... | @@ -1152,7 +1178,10 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => | ... | @@ -1152,7 +1178,10 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => |
| 1152 | commandId: "cmd-turn-start-claude-interrupt", | 1178 | commandId: "cmd-turn-start-claude-interrupt", |
| 1153 | messageId: "msg-user-claude-interrupt", | 1179 | messageId: "msg-user-claude-interrupt", |
| 1154 | text: "Start long turn", | 1180 | text: "Start long turn", |
| 1155 | provider: "claudeAgent", | 1181 | modelSelection: { |
| 1182 | provider: "claudeAgent", | ||
| 1183 | model: "claude-sonnet-4-6", | ||
| 1184 | }, | ||
| 1156 | }); | 1185 | }); |
| 1157 | 1186 | ||
| 1158 | const thread = yield* harness.waitForThread( | 1187 | const thread = yield* harness.waitForThread( |
| ... | @@ -1222,7 +1251,10 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", | ... | @@ -1222,7 +1251,10 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", |
| 1222 | commandId: "cmd-turn-start-claude-revert-1", | 1251 | commandId: "cmd-turn-start-claude-revert-1", |
| 1223 | messageId: "msg-user-claude-revert-1", | 1252 | messageId: "msg-user-claude-revert-1", |
| 1224 | text: "First Claude edit", | 1253 | text: "First Claude edit", |
| 1225 | provider: "claudeAgent", | 1254 | modelSelection: { |
| 1255 | provider: "claudeAgent", | ||
| 1256 | model: "claude-sonnet-4-6", | ||
| 1257 | }, | ||
| 1226 | }); | 1258 | }); |
| 1227 | 1259 | ||
| 1228 | yield* harness.waitForThread( | 1260 | yield* harness.waitForThread( |
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+5-2| ... | @@ -31,7 +31,7 @@ function makeSnapshot(input: { | ... | @@ -31,7 +31,7 @@ function makeSnapshot(input: { |
| 31 | id: input.projectId, | 31 | id: input.projectId, |
| 32 | title: "Project", | 32 | title: "Project", |
| 33 | workspaceRoot: input.workspaceRoot, | 33 | workspaceRoot: input.workspaceRoot, |
| 34 | defaultModel: null, | 34 | defaultModelSelection: null, |
| 35 | scripts: [], | 35 | scripts: [], |
| 36 | createdAt: "2026-01-01T00:00:00.000Z", | 36 | createdAt: "2026-01-01T00:00:00.000Z", |
| 37 | updatedAt: "2026-01-01T00:00:00.000Z", | 37 | updatedAt: "2026-01-01T00:00:00.000Z", |
| ... | @@ -43,7 +43,10 @@ function makeSnapshot(input: { | ... | @@ -43,7 +43,10 @@ function makeSnapshot(input: { |
| 43 | id: input.threadId, | 43 | id: input.threadId, |
| 44 | projectId: input.projectId, | 44 | projectId: input.projectId, |
| 45 | title: "Thread", | 45 | title: "Thread", |
| 46 | model: "gpt-5-codex", | 46 | modelSelection: { |
| 47 | provider: "codex", | ||
| 48 | model: "gpt-5-codex", | ||
| 49 | }, | ||
| 47 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 50 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 48 | runtimeMode: "full-access", | 51 | runtimeMode: "full-access", |
| 49 | branch: null, | 52 | branch: null, |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+8-2| ... | @@ -282,7 +282,10 @@ describe("CheckpointReactor", () => { | ... | @@ -282,7 +282,10 @@ describe("CheckpointReactor", () => { |
| 282 | projectId: asProjectId("project-1"), | 282 | projectId: asProjectId("project-1"), |
| 283 | title: "Test Project", | 283 | title: "Test Project", |
| 284 | workspaceRoot: options?.projectWorkspaceRoot ?? cwd, | 284 | workspaceRoot: options?.projectWorkspaceRoot ?? cwd, |
| 285 | defaultModel: "gpt-5-codex", | 285 | defaultModelSelection: { |
| 286 | provider: "codex", | ||
| 287 | model: "gpt-5-codex", | ||
| 288 | }, | ||
| 286 | createdAt, | 289 | createdAt, |
| 287 | }), | 290 | }), |
| 288 | ); | 291 | ); |
| ... | @@ -293,7 +296,10 @@ describe("CheckpointReactor", () => { | ... | @@ -293,7 +296,10 @@ describe("CheckpointReactor", () => { |
| 293 | threadId: ThreadId.makeUnsafe("thread-1"), | 296 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 294 | projectId: asProjectId("project-1"), | 297 | projectId: asProjectId("project-1"), |
| 295 | title: "Thread", | 298 | title: "Thread", |
| 296 | model: "gpt-5-codex", | 299 | modelSelection: { |
| 300 | provider: "codex", | ||
| 301 | model: "gpt-5-codex", | ||
| 302 | }, | ||
| 297 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 303 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 298 | runtimeMode: "approval-required", | 304 | runtimeMode: "approval-required", |
| 299 | branch: null, | 305 | branch: null, |
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+72-18| ... | @@ -72,7 +72,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -72,7 +72,10 @@ describe("OrchestrationEngine", () => { |
| 72 | projectId: asProjectId("project-1"), | 72 | projectId: asProjectId("project-1"), |
| 73 | title: "Project 1", | 73 | title: "Project 1", |
| 74 | workspaceRoot: "/tmp/project-1", | 74 | workspaceRoot: "/tmp/project-1", |
| 75 | defaultModel: "gpt-5-codex", | 75 | defaultModelSelection: { |
| 76 | provider: "codex", | ||
| 77 | model: "gpt-5-codex", | ||
| 78 | }, | ||
| 76 | createdAt, | 79 | createdAt, |
| 77 | }), | 80 | }), |
| 78 | ); | 81 | ); |
| ... | @@ -83,7 +86,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -83,7 +86,10 @@ describe("OrchestrationEngine", () => { |
| 83 | threadId: ThreadId.makeUnsafe("thread-1"), | 86 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 84 | projectId: asProjectId("project-1"), | 87 | projectId: asProjectId("project-1"), |
| 85 | title: "Thread", | 88 | title: "Thread", |
| 86 | model: "gpt-5-codex", | 89 | modelSelection: { |
| 90 | provider: "codex", | ||
| 91 | model: "gpt-5-codex", | ||
| 92 | }, | ||
| 87 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 93 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 88 | runtimeMode: "approval-required", | 94 | runtimeMode: "approval-required", |
| 89 | branch: null, | 95 | branch: null, |
| ... | @@ -126,7 +132,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -126,7 +132,10 @@ describe("OrchestrationEngine", () => { |
| 126 | projectId: asProjectId("project-replay"), | 132 | projectId: asProjectId("project-replay"), |
| 127 | title: "Replay Project", | 133 | title: "Replay Project", |
| 128 | workspaceRoot: "/tmp/project-replay", | 134 | workspaceRoot: "/tmp/project-replay", |
| 129 | defaultModel: "gpt-5-codex", | 135 | defaultModelSelection: { |
| 136 | provider: "codex", | ||
| 137 | model: "gpt-5-codex", | ||
| 138 | }, | ||
| 130 | createdAt, | 139 | createdAt, |
| 131 | }), | 140 | }), |
| 132 | ); | 141 | ); |
| ... | @@ -137,7 +146,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -137,7 +146,10 @@ describe("OrchestrationEngine", () => { |
| 137 | threadId: ThreadId.makeUnsafe("thread-replay"), | 146 | threadId: ThreadId.makeUnsafe("thread-replay"), |
| 138 | projectId: asProjectId("project-replay"), | 147 | projectId: asProjectId("project-replay"), |
| 139 | title: "replay", | 148 | title: "replay", |
| 140 | model: "gpt-5-codex", | 149 | modelSelection: { |
| 150 | provider: "codex", | ||
| 151 | model: "gpt-5-codex", | ||
| 152 | }, | ||
| 141 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 153 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 142 | runtimeMode: "approval-required", | 154 | runtimeMode: "approval-required", |
| 143 | branch: null, | 155 | branch: null, |
| ... | @@ -178,7 +190,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -178,7 +190,10 @@ describe("OrchestrationEngine", () => { |
| 178 | projectId: asProjectId("project-stream"), | 190 | projectId: asProjectId("project-stream"), |
| 179 | title: "Stream Project", | 191 | title: "Stream Project", |
| 180 | workspaceRoot: "/tmp/project-stream", | 192 | workspaceRoot: "/tmp/project-stream", |
| 181 | defaultModel: "gpt-5-codex", | 193 | defaultModelSelection: { |
| 194 | provider: "codex", | ||
| 195 | model: "gpt-5-codex", | ||
| 196 | }, | ||
| 182 | createdAt, | 197 | createdAt, |
| 183 | }), | 198 | }), |
| 184 | ); | 199 | ); |
| ... | @@ -199,7 +214,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -199,7 +214,10 @@ describe("OrchestrationEngine", () => { |
| 199 | threadId: ThreadId.makeUnsafe("thread-stream"), | 214 | threadId: ThreadId.makeUnsafe("thread-stream"), |
| 200 | projectId: asProjectId("project-stream"), | 215 | projectId: asProjectId("project-stream"), |
| 201 | title: "domain-stream", | 216 | title: "domain-stream", |
| 202 | model: "gpt-5-codex", | 217 | modelSelection: { |
| 218 | provider: "codex", | ||
| 219 | model: "gpt-5-codex", | ||
| 220 | }, | ||
| 203 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 221 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 204 | runtimeMode: "approval-required", | 222 | runtimeMode: "approval-required", |
| 205 | branch: null, | 223 | branch: null, |
| ... | @@ -233,7 +251,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -233,7 +251,10 @@ describe("OrchestrationEngine", () => { |
| 233 | projectId: asProjectId("project-turn-diff"), | 251 | projectId: asProjectId("project-turn-diff"), |
| 234 | title: "Turn Diff Project", | 252 | title: "Turn Diff Project", |
| 235 | workspaceRoot: "/tmp/project-turn-diff", | 253 | workspaceRoot: "/tmp/project-turn-diff", |
| 236 | defaultModel: "gpt-5-codex", | 254 | defaultModelSelection: { |
| 255 | provider: "codex", | ||
| 256 | model: "gpt-5-codex", | ||
| 257 | }, | ||
| 237 | createdAt, | 258 | createdAt, |
| 238 | }), | 259 | }), |
| 239 | ); | 260 | ); |
| ... | @@ -244,7 +265,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -244,7 +265,10 @@ describe("OrchestrationEngine", () => { |
| 244 | threadId: ThreadId.makeUnsafe("thread-turn-diff"), | 265 | threadId: ThreadId.makeUnsafe("thread-turn-diff"), |
| 245 | projectId: asProjectId("project-turn-diff"), | 266 | projectId: asProjectId("project-turn-diff"), |
| 246 | title: "Turn diff thread", | 267 | title: "Turn diff thread", |
| 247 | model: "gpt-5-codex", | 268 | modelSelection: { |
| 269 | provider: "codex", | ||
| 270 | model: "gpt-5-codex", | ||
| 271 | }, | ||
| 248 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 272 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 249 | runtimeMode: "approval-required", | 273 | runtimeMode: "approval-required", |
| 250 | branch: null, | 274 | branch: null, |
| ... | @@ -344,7 +368,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -344,7 +368,10 @@ describe("OrchestrationEngine", () => { |
| 344 | projectId: asProjectId("project-flaky"), | 368 | projectId: asProjectId("project-flaky"), |
| 345 | title: "Flaky Project", | 369 | title: "Flaky Project", |
| 346 | workspaceRoot: "/tmp/project-flaky", | 370 | workspaceRoot: "/tmp/project-flaky", |
| 347 | defaultModel: "gpt-5-codex", | 371 | defaultModelSelection: { |
| 372 | provider: "codex", | ||
| 373 | model: "gpt-5-codex", | ||
| 374 | }, | ||
| 348 | createdAt, | 375 | createdAt, |
| 349 | }), | 376 | }), |
| 350 | ); | 377 | ); |
| ... | @@ -357,7 +384,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -357,7 +384,10 @@ describe("OrchestrationEngine", () => { |
| 357 | threadId: ThreadId.makeUnsafe("thread-flaky-fail"), | 384 | threadId: ThreadId.makeUnsafe("thread-flaky-fail"), |
| 358 | projectId: asProjectId("project-flaky"), | 385 | projectId: asProjectId("project-flaky"), |
| 359 | title: "flaky-fail", | 386 | title: "flaky-fail", |
| 360 | model: "gpt-5-codex", | 387 | modelSelection: { |
| 388 | provider: "codex", | ||
| 389 | model: "gpt-5-codex", | ||
| 390 | }, | ||
| 361 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 391 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 362 | runtimeMode: "approval-required", | 392 | runtimeMode: "approval-required", |
| 363 | branch: null, | 393 | branch: null, |
| ... | @@ -374,7 +404,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -374,7 +404,10 @@ describe("OrchestrationEngine", () => { |
| 374 | threadId: ThreadId.makeUnsafe("thread-flaky-ok"), | 404 | threadId: ThreadId.makeUnsafe("thread-flaky-ok"), |
| 375 | projectId: asProjectId("project-flaky"), | 405 | projectId: asProjectId("project-flaky"), |
| 376 | title: "flaky-ok", | 406 | title: "flaky-ok", |
| 377 | model: "gpt-5-codex", | 407 | modelSelection: { |
| 408 | provider: "codex", | ||
| 409 | model: "gpt-5-codex", | ||
| 410 | }, | ||
| 378 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 411 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 379 | runtimeMode: "approval-required", | 412 | runtimeMode: "approval-required", |
| 380 | branch: null, | 413 | branch: null, |
| ... | @@ -428,7 +461,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -428,7 +461,10 @@ describe("OrchestrationEngine", () => { |
| 428 | projectId: asProjectId("project-atomic"), | 461 | projectId: asProjectId("project-atomic"), |
| 429 | title: "Atomic Project", | 462 | title: "Atomic Project", |
| 430 | workspaceRoot: "/tmp/project-atomic", | 463 | workspaceRoot: "/tmp/project-atomic", |
| 431 | defaultModel: "gpt-5-codex", | 464 | defaultModelSelection: { |
| 465 | provider: "codex", | ||
| 466 | model: "gpt-5-codex", | ||
| 467 | }, | ||
| 432 | createdAt, | 468 | createdAt, |
| 433 | }), | 469 | }), |
| 434 | ); | 470 | ); |
| ... | @@ -439,7 +475,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -439,7 +475,10 @@ describe("OrchestrationEngine", () => { |
| 439 | threadId: ThreadId.makeUnsafe("thread-atomic"), | 475 | threadId: ThreadId.makeUnsafe("thread-atomic"), |
| 440 | projectId: asProjectId("project-atomic"), | 476 | projectId: asProjectId("project-atomic"), |
| 441 | title: "atomic", | 477 | title: "atomic", |
| 442 | model: "gpt-5-codex", | 478 | modelSelection: { |
| 479 | provider: "codex", | ||
| 480 | model: "gpt-5-codex", | ||
| 481 | }, | ||
| 443 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 482 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 444 | runtimeMode: "approval-required", | 483 | runtimeMode: "approval-required", |
| 445 | branch: null, | 484 | branch: null, |
| ... | @@ -563,7 +602,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -563,7 +602,10 @@ describe("OrchestrationEngine", () => { |
| 563 | projectId: asProjectId("project-sync"), | 602 | projectId: asProjectId("project-sync"), |
| 564 | title: "Sync Project", | 603 | title: "Sync Project", |
| 565 | workspaceRoot: "/tmp/project-sync", | 604 | workspaceRoot: "/tmp/project-sync", |
| 566 | defaultModel: "gpt-5-codex", | 605 | defaultModelSelection: { |
| 606 | provider: "codex", | ||
| 607 | model: "gpt-5-codex", | ||
| 608 | }, | ||
| 567 | createdAt, | 609 | createdAt, |
| 568 | }), | 610 | }), |
| 569 | ); | 611 | ); |
| ... | @@ -574,7 +616,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -574,7 +616,10 @@ describe("OrchestrationEngine", () => { |
| 574 | threadId: ThreadId.makeUnsafe("thread-sync"), | 616 | threadId: ThreadId.makeUnsafe("thread-sync"), |
| 575 | projectId: asProjectId("project-sync"), | 617 | projectId: asProjectId("project-sync"), |
| 576 | title: "sync-before", | 618 | title: "sync-before", |
| 577 | model: "gpt-5-codex", | 619 | modelSelection: { |
| 620 | provider: "codex", | ||
| 621 | model: "gpt-5-codex", | ||
| 622 | }, | ||
| 578 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 623 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 579 | runtimeMode: "approval-required", | 624 | runtimeMode: "approval-required", |
| 580 | branch: null, | 625 | branch: null, |
| ... | @@ -642,7 +687,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -642,7 +687,10 @@ describe("OrchestrationEngine", () => { |
| 642 | projectId: asProjectId("project-duplicate"), | 687 | projectId: asProjectId("project-duplicate"), |
| 643 | title: "Duplicate Project", | 688 | title: "Duplicate Project", |
| 644 | workspaceRoot: "/tmp/project-duplicate", | 689 | workspaceRoot: "/tmp/project-duplicate", |
| 645 | defaultModel: "gpt-5-codex", | 690 | defaultModelSelection: { |
| 691 | provider: "codex", | ||
| 692 | model: "gpt-5-codex", | ||
| 693 | }, | ||
| 646 | createdAt, | 694 | createdAt, |
| 647 | }), | 695 | }), |
| 648 | ); | 696 | ); |
| ... | @@ -654,7 +702,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -654,7 +702,10 @@ describe("OrchestrationEngine", () => { |
| 654 | threadId: ThreadId.makeUnsafe("thread-duplicate"), | 702 | threadId: ThreadId.makeUnsafe("thread-duplicate"), |
| 655 | projectId: asProjectId("project-duplicate"), | 703 | projectId: asProjectId("project-duplicate"), |
| 656 | title: "duplicate", | 704 | title: "duplicate", |
| 657 | model: "gpt-5-codex", | 705 | modelSelection: { |
| 706 | provider: "codex", | ||
| 707 | model: "gpt-5-codex", | ||
| 708 | }, | ||
| 658 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 709 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 659 | runtimeMode: "approval-required", | 710 | runtimeMode: "approval-required", |
| 660 | branch: null, | 711 | branch: null, |
| ... | @@ -671,7 +722,10 @@ describe("OrchestrationEngine", () => { | ... | @@ -671,7 +722,10 @@ describe("OrchestrationEngine", () => { |
| 671 | threadId: ThreadId.makeUnsafe("thread-duplicate"), | 722 | threadId: ThreadId.makeUnsafe("thread-duplicate"), |
| 672 | projectId: asProjectId("project-duplicate"), | 723 | projectId: asProjectId("project-duplicate"), |
| 673 | title: "duplicate", | 724 | title: "duplicate", |
| 674 | model: "gpt-5-codex", | 725 | modelSelection: { |
| 726 | provider: "codex", | ||
| 727 | model: "gpt-5-codex", | ||
| 728 | }, | ||
| 675 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 729 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 676 | runtimeMode: "approval-required", | 730 | runtimeMode: "approval-required", |
| 677 | branch: null, | 731 | branch: null, |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+65-26| ... | @@ -68,7 +68,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -68,7 +68,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 68 | projectId: ProjectId.makeUnsafe("project-1"), | 68 | projectId: ProjectId.makeUnsafe("project-1"), |
| 69 | title: "Project 1", | 69 | title: "Project 1", |
| 70 | workspaceRoot: "/tmp/project-1", | 70 | workspaceRoot: "/tmp/project-1", |
| 71 | defaultModel: null, | 71 | defaultModelSelection: null, |
| 72 | scripts: [], | 72 | scripts: [], |
| 73 | createdAt: now, | 73 | createdAt: now, |
| 74 | updatedAt: now, | 74 | updatedAt: now, |
| ... | @@ -89,7 +89,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -89,7 +89,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 89 | threadId: ThreadId.makeUnsafe("thread-1"), | 89 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 90 | projectId: ProjectId.makeUnsafe("project-1"), | 90 | projectId: ProjectId.makeUnsafe("project-1"), |
| 91 | title: "Thread 1", | 91 | title: "Thread 1", |
| 92 | model: "gpt-5-codex", | 92 | modelSelection: { |
| 93 | provider: "codex", | ||
| 94 | model: "gpt-5-codex", | ||
| 95 | }, | ||
| 93 | runtimeMode: "full-access", | 96 | runtimeMode: "full-access", |
| 94 | branch: null, | 97 | branch: null, |
| 95 | worktreePath: null, | 98 | worktreePath: null, |
| ... | @@ -337,7 +340,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -337,7 +340,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 337 | projectId: ProjectId.makeUnsafe("project-clear-attachments"), | 340 | projectId: ProjectId.makeUnsafe("project-clear-attachments"), |
| 338 | title: "Project Clear Attachments", | 341 | title: "Project Clear Attachments", |
| 339 | workspaceRoot: "/tmp/project-clear-attachments", | 342 | workspaceRoot: "/tmp/project-clear-attachments", |
| 340 | defaultModel: null, | 343 | defaultModelSelection: null, |
| 341 | scripts: [], | 344 | scripts: [], |
| 342 | createdAt: now, | 345 | createdAt: now, |
| 343 | updatedAt: now, | 346 | updatedAt: now, |
| ... | @@ -358,7 +361,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -358,7 +361,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 358 | threadId: ThreadId.makeUnsafe("thread-clear-attachments"), | 361 | threadId: ThreadId.makeUnsafe("thread-clear-attachments"), |
| 359 | projectId: ProjectId.makeUnsafe("project-clear-attachments"), | 362 | projectId: ProjectId.makeUnsafe("project-clear-attachments"), |
| 360 | title: "Thread Clear Attachments", | 363 | title: "Thread Clear Attachments", |
| 361 | model: "gpt-5-codex", | 364 | modelSelection: { |
| 365 | provider: "codex", | ||
| 366 | model: "gpt-5-codex", | ||
| 367 | }, | ||
| 362 | runtimeMode: "full-access", | 368 | runtimeMode: "full-access", |
| 363 | branch: null, | 369 | branch: null, |
| 364 | worktreePath: null, | 370 | worktreePath: null, |
| ... | @@ -462,7 +468,7 @@ it.layer( | ... | @@ -462,7 +468,7 @@ it.layer( |
| 462 | projectId: ProjectId.makeUnsafe("project-overwrite"), | 468 | projectId: ProjectId.makeUnsafe("project-overwrite"), |
| 463 | title: "Project Overwrite", | 469 | title: "Project Overwrite", |
| 464 | workspaceRoot: "/tmp/project-overwrite", | 470 | workspaceRoot: "/tmp/project-overwrite", |
| 465 | defaultModel: null, | 471 | defaultModelSelection: null, |
| 466 | scripts: [], | 472 | scripts: [], |
| 467 | createdAt: now, | 473 | createdAt: now, |
| 468 | updatedAt: now, | 474 | updatedAt: now, |
| ... | @@ -483,7 +489,10 @@ it.layer( | ... | @@ -483,7 +489,10 @@ it.layer( |
| 483 | threadId: ThreadId.makeUnsafe("thread-overwrite"), | 489 | threadId: ThreadId.makeUnsafe("thread-overwrite"), |
| 484 | projectId: ProjectId.makeUnsafe("project-overwrite"), | 490 | projectId: ProjectId.makeUnsafe("project-overwrite"), |
| 485 | title: "Thread Overwrite", | 491 | title: "Thread Overwrite", |
| 486 | model: "gpt-5-codex", | 492 | modelSelection: { |
| 493 | provider: "codex", | ||
| 494 | model: "gpt-5-codex", | ||
| 495 | }, | ||
| 487 | runtimeMode: "full-access", | 496 | runtimeMode: "full-access", |
| 488 | branch: null, | 497 | branch: null, |
| 489 | worktreePath: null, | 498 | worktreePath: null, |
| ... | @@ -607,7 +616,7 @@ it.layer( | ... | @@ -607,7 +616,7 @@ it.layer( |
| 607 | projectId: ProjectId.makeUnsafe("project-rollback"), | 616 | projectId: ProjectId.makeUnsafe("project-rollback"), |
| 608 | title: "Project Rollback", | 617 | title: "Project Rollback", |
| 609 | workspaceRoot: "/tmp/project-rollback", | 618 | workspaceRoot: "/tmp/project-rollback", |
| 610 | defaultModel: null, | 619 | defaultModelSelection: null, |
| 611 | scripts: [], | 620 | scripts: [], |
| 612 | createdAt: now, | 621 | createdAt: now, |
| 613 | updatedAt: now, | 622 | updatedAt: now, |
| ... | @@ -628,7 +637,10 @@ it.layer( | ... | @@ -628,7 +637,10 @@ it.layer( |
| 628 | threadId: ThreadId.makeUnsafe("thread-rollback"), | 637 | threadId: ThreadId.makeUnsafe("thread-rollback"), |
| 629 | projectId: ProjectId.makeUnsafe("project-rollback"), | 638 | projectId: ProjectId.makeUnsafe("project-rollback"), |
| 630 | title: "Thread Rollback", | 639 | title: "Thread Rollback", |
| 631 | model: "gpt-5-codex", | 640 | modelSelection: { |
| 641 | provider: "codex", | ||
| 642 | model: "gpt-5-codex", | ||
| 643 | }, | ||
| 632 | runtimeMode: "full-access", | 644 | runtimeMode: "full-access", |
| 633 | branch: null, | 645 | branch: null, |
| 634 | worktreePath: null, | 646 | worktreePath: null, |
| ... | @@ -733,7 +745,7 @@ it.layer( | ... | @@ -733,7 +745,7 @@ it.layer( |
| 733 | projectId: ProjectId.makeUnsafe("project-revert-files"), | 745 | projectId: ProjectId.makeUnsafe("project-revert-files"), |
| 734 | title: "Project Revert Files", | 746 | title: "Project Revert Files", |
| 735 | workspaceRoot: "/tmp/project-revert-files", | 747 | workspaceRoot: "/tmp/project-revert-files", |
| 736 | defaultModel: null, | 748 | defaultModelSelection: null, |
| 737 | scripts: [], | 749 | scripts: [], |
| 738 | createdAt: now, | 750 | createdAt: now, |
| 739 | updatedAt: now, | 751 | updatedAt: now, |
| ... | @@ -754,7 +766,10 @@ it.layer( | ... | @@ -754,7 +766,10 @@ it.layer( |
| 754 | threadId, | 766 | threadId, |
| 755 | projectId: ProjectId.makeUnsafe("project-revert-files"), | 767 | projectId: ProjectId.makeUnsafe("project-revert-files"), |
| 756 | title: "Thread Revert Files", | 768 | title: "Thread Revert Files", |
| 757 | model: "gpt-5-codex", | 769 | modelSelection: { |
| 770 | provider: "codex", | ||
| 771 | model: "gpt-5-codex", | ||
| 772 | }, | ||
| 758 | runtimeMode: "full-access", | 773 | runtimeMode: "full-access", |
| 759 | branch: null, | 774 | branch: null, |
| 760 | worktreePath: null, | 775 | worktreePath: null, |
| ... | @@ -938,7 +953,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta | ... | @@ -938,7 +953,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta |
| 938 | projectId: ProjectId.makeUnsafe("project-delete-files"), | 953 | projectId: ProjectId.makeUnsafe("project-delete-files"), |
| 939 | title: "Project Delete Files", | 954 | title: "Project Delete Files", |
| 940 | workspaceRoot: "/tmp/project-delete-files", | 955 | workspaceRoot: "/tmp/project-delete-files", |
| 941 | defaultModel: null, | 956 | defaultModelSelection: null, |
| 942 | scripts: [], | 957 | scripts: [], |
| 943 | createdAt: now, | 958 | createdAt: now, |
| 944 | updatedAt: now, | 959 | updatedAt: now, |
| ... | @@ -959,7 +974,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta | ... | @@ -959,7 +974,10 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta |
| 959 | threadId, | 974 | threadId, |
| 960 | projectId: ProjectId.makeUnsafe("project-delete-files"), | 975 | projectId: ProjectId.makeUnsafe("project-delete-files"), |
| 961 | title: "Thread Delete Files", | 976 | title: "Thread Delete Files", |
| 962 | model: "gpt-5-codex", | 977 | modelSelection: { |
| 978 | provider: "codex", | ||
| 979 | model: "gpt-5-codex", | ||
| 980 | }, | ||
| 963 | runtimeMode: "full-access", | 981 | runtimeMode: "full-access", |
| 964 | branch: null, | 982 | branch: null, |
| 965 | worktreePath: null, | 983 | worktreePath: null, |
| ... | @@ -1098,7 +1116,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1098,7 +1116,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1098 | projectId: ProjectId.makeUnsafe("project-a"), | 1116 | projectId: ProjectId.makeUnsafe("project-a"), |
| 1099 | title: "Project A", | 1117 | title: "Project A", |
| 1100 | workspaceRoot: "/tmp/project-a", | 1118 | workspaceRoot: "/tmp/project-a", |
| 1101 | defaultModel: null, | 1119 | defaultModelSelection: null, |
| 1102 | scripts: [], | 1120 | scripts: [], |
| 1103 | createdAt: now, | 1121 | createdAt: now, |
| 1104 | updatedAt: now, | 1122 | updatedAt: now, |
| ... | @@ -1119,7 +1137,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1119,7 +1137,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1119 | threadId: ThreadId.makeUnsafe("thread-a"), | 1137 | threadId: ThreadId.makeUnsafe("thread-a"), |
| 1120 | projectId: ProjectId.makeUnsafe("project-a"), | 1138 | projectId: ProjectId.makeUnsafe("project-a"), |
| 1121 | title: "Thread A", | 1139 | title: "Thread A", |
| 1122 | model: "gpt-5-codex", | 1140 | modelSelection: { |
| 1141 | provider: "codex", | ||
| 1142 | model: "gpt-5-codex", | ||
| 1143 | }, | ||
| 1123 | runtimeMode: "full-access", | 1144 | runtimeMode: "full-access", |
| 1124 | branch: null, | 1145 | branch: null, |
| 1125 | worktreePath: null, | 1146 | worktreePath: null, |
| ... | @@ -1222,7 +1243,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1222,7 +1243,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1222 | projectId: ProjectId.makeUnsafe("project-empty"), | 1243 | projectId: ProjectId.makeUnsafe("project-empty"), |
| 1223 | title: "Project Empty", | 1244 | title: "Project Empty", |
| 1224 | workspaceRoot: "/tmp/project-empty", | 1245 | workspaceRoot: "/tmp/project-empty", |
| 1225 | defaultModel: null, | 1246 | defaultModelSelection: null, |
| 1226 | scripts: [], | 1247 | scripts: [], |
| 1227 | createdAt: now, | 1248 | createdAt: now, |
| 1228 | updatedAt: now, | 1249 | updatedAt: now, |
| ... | @@ -1243,7 +1264,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1243,7 +1264,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1243 | threadId: ThreadId.makeUnsafe("thread-empty"), | 1264 | threadId: ThreadId.makeUnsafe("thread-empty"), |
| 1244 | projectId: ProjectId.makeUnsafe("project-empty"), | 1265 | projectId: ProjectId.makeUnsafe("project-empty"), |
| 1245 | title: "Thread Empty", | 1266 | title: "Thread Empty", |
| 1246 | model: "gpt-5-codex", | 1267 | modelSelection: { |
| 1268 | provider: "codex", | ||
| 1269 | model: "gpt-5-codex", | ||
| 1270 | }, | ||
| 1247 | runtimeMode: "full-access", | 1271 | runtimeMode: "full-access", |
| 1248 | branch: null, | 1272 | branch: null, |
| 1249 | worktreePath: null, | 1273 | worktreePath: null, |
| ... | @@ -1359,7 +1383,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1359,7 +1383,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1359 | projectId: ProjectId.makeUnsafe("project-conflict"), | 1383 | projectId: ProjectId.makeUnsafe("project-conflict"), |
| 1360 | title: "Project Conflict", | 1384 | title: "Project Conflict", |
| 1361 | workspaceRoot: "/tmp/project-conflict", | 1385 | workspaceRoot: "/tmp/project-conflict", |
| 1362 | defaultModel: null, | 1386 | defaultModelSelection: null, |
| 1363 | scripts: [], | 1387 | scripts: [], |
| 1364 | createdAt: "2026-02-26T13:00:00.000Z", | 1388 | createdAt: "2026-02-26T13:00:00.000Z", |
| 1365 | updatedAt: "2026-02-26T13:00:00.000Z", | 1389 | updatedAt: "2026-02-26T13:00:00.000Z", |
| ... | @@ -1380,7 +1404,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1380,7 +1404,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1380 | threadId: ThreadId.makeUnsafe("thread-conflict"), | 1404 | threadId: ThreadId.makeUnsafe("thread-conflict"), |
| 1381 | projectId: ProjectId.makeUnsafe("project-conflict"), | 1405 | projectId: ProjectId.makeUnsafe("project-conflict"), |
| 1382 | title: "Thread Conflict", | 1406 | title: "Thread Conflict", |
| 1383 | model: "gpt-5-codex", | 1407 | modelSelection: { |
| 1408 | provider: "codex", | ||
| 1409 | model: "gpt-5-codex", | ||
| 1410 | }, | ||
| 1384 | runtimeMode: "full-access", | 1411 | runtimeMode: "full-access", |
| 1385 | branch: null, | 1412 | branch: null, |
| 1386 | worktreePath: null, | 1413 | worktreePath: null, |
| ... | @@ -1500,7 +1527,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1500,7 +1527,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1500 | projectId: ProjectId.makeUnsafe("project-revert"), | 1527 | projectId: ProjectId.makeUnsafe("project-revert"), |
| 1501 | title: "Project Revert", | 1528 | title: "Project Revert", |
| 1502 | workspaceRoot: "/tmp/project-revert", | 1529 | workspaceRoot: "/tmp/project-revert", |
| 1503 | defaultModel: null, | 1530 | defaultModelSelection: null, |
| 1504 | scripts: [], | 1531 | scripts: [], |
| 1505 | createdAt: "2026-02-26T12:00:00.000Z", | 1532 | createdAt: "2026-02-26T12:00:00.000Z", |
| 1506 | updatedAt: "2026-02-26T12:00:00.000Z", | 1533 | updatedAt: "2026-02-26T12:00:00.000Z", |
| ... | @@ -1521,7 +1548,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1521,7 +1548,10 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { |
| 1521 | threadId: ThreadId.makeUnsafe("thread-revert"), | 1548 | threadId: ThreadId.makeUnsafe("thread-revert"), |
| 1522 | projectId: ProjectId.makeUnsafe("project-revert"), | 1549 | projectId: ProjectId.makeUnsafe("project-revert"), |
| 1523 | title: "Thread Revert", | 1550 | title: "Thread Revert", |
| 1524 | model: "gpt-5-codex", | 1551 | modelSelection: { |
| 1552 | provider: "codex", | ||
| 1553 | model: "gpt-5-codex", | ||
| 1554 | }, | ||
| 1525 | runtimeMode: "full-access", | 1555 | runtimeMode: "full-access", |
| 1526 | branch: null, | 1556 | branch: null, |
| 1527 | worktreePath: null, | 1557 | worktreePath: null, |
| ... | @@ -1837,7 +1867,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -1837,7 +1867,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 1837 | projectId: ProjectId.makeUnsafe("project-live"), | 1867 | projectId: ProjectId.makeUnsafe("project-live"), |
| 1838 | title: "Live Project", | 1868 | title: "Live Project", |
| 1839 | workspaceRoot: "/tmp/project-live", | 1869 | workspaceRoot: "/tmp/project-live", |
| 1840 | defaultModel: "gpt-5-codex", | 1870 | defaultModelSelection: { |
| 1871 | provider: "codex", | ||
| 1872 | model: "gpt-5-codex", | ||
| 1873 | }, | ||
| 1841 | createdAt, | 1874 | createdAt, |
| 1842 | }); | 1875 | }); |
| 1843 | 1876 | ||
| ... | @@ -1872,7 +1905,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -1872,7 +1905,10 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 1872 | projectId: ProjectId.makeUnsafe("project-scripts"), | 1905 | projectId: ProjectId.makeUnsafe("project-scripts"), |
| 1873 | title: "Scripts Project", | 1906 | title: "Scripts Project", |
| 1874 | workspaceRoot: "/tmp/project-scripts", | 1907 | workspaceRoot: "/tmp/project-scripts", |
| 1875 | defaultModel: "gpt-5-codex", | 1908 | defaultModelSelection: { |
| 1909 | provider: "codex", | ||
| 1910 | model: "gpt-5-codex", | ||
| 1911 | }, | ||
| 1876 | createdAt, | 1912 | createdAt, |
| 1877 | }); | 1913 | }); |
| 1878 | 1914 | ||
| ... | @@ -1889,16 +1925,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -1889,16 +1925,19 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 1889 | runOnWorktreeCreate: false, | 1925 | runOnWorktreeCreate: false, |
| 1890 | }, | 1926 | }, |
| 1891 | ], | 1927 | ], |
| 1892 | defaultModel: "gpt-5", | 1928 | defaultModelSelection: { |
| 1929 | provider: "codex", | ||
| 1930 | model: "gpt-5", | ||
| 1931 | }, | ||
| 1893 | }); | 1932 | }); |
| 1894 | 1933 | ||
| 1895 | const projectRows = yield* sql<{ | 1934 | const projectRows = yield* sql<{ |
| 1896 | readonly scriptsJson: string; | 1935 | readonly scriptsJson: string; |
| 1897 | readonly defaultModel: string; | 1936 | readonly defaultModelSelection: string; |
| 1898 | }>` | 1937 | }>` |
| 1899 | SELECT | 1938 | SELECT |
| 1900 | scripts_json AS "scriptsJson", | 1939 | scripts_json AS "scriptsJson", |
| 1901 | default_model AS "defaultModel" | 1940 | default_model_selection_json AS "defaultModelSelection" |
| 1902 | FROM projection_projects | 1941 | FROM projection_projects |
| 1903 | WHERE project_id = 'project-scripts' | 1942 | WHERE project_id = 'project-scripts' |
| 1904 | `; | 1943 | `; |
| ... | @@ -1906,7 +1945,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { | ... | @@ -1906,7 +1945,7 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { |
| 1906 | { | 1945 | { |
| 1907 | scriptsJson: | 1946 | scriptsJson: |
| 1908 | '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', | 1947 | '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', |
| 1909 | defaultModel: "gpt-5", | 1948 | defaultModelSelection: '{"provider":"codex","model":"gpt-5"}', |
| 1910 | }, | 1949 | }, |
| 1911 | ]); | 1950 | ]); |
| 1912 | }), | 1951 | }), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+7-5| ... | @@ -362,7 +362,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -362,7 +362,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 362 | projectId: event.payload.projectId, | 362 | projectId: event.payload.projectId, |
| 363 | title: event.payload.title, | 363 | title: event.payload.title, |
| 364 | workspaceRoot: event.payload.workspaceRoot, | 364 | workspaceRoot: event.payload.workspaceRoot, |
| 365 | defaultModel: event.payload.defaultModel, | 365 | defaultModelSelection: event.payload.defaultModelSelection, |
| 366 | scripts: event.payload.scripts, | 366 | scripts: event.payload.scripts, |
| 367 | createdAt: event.payload.createdAt, | 367 | createdAt: event.payload.createdAt, |
| 368 | updatedAt: event.payload.updatedAt, | 368 | updatedAt: event.payload.updatedAt, |
| ... | @@ -383,8 +383,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -383,8 +383,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 383 | ...(event.payload.workspaceRoot !== undefined | 383 | ...(event.payload.workspaceRoot !== undefined |
| 384 | ? { workspaceRoot: event.payload.workspaceRoot } | 384 | ? { workspaceRoot: event.payload.workspaceRoot } |
| 385 | : {}), | 385 | : {}), |
| 386 | ...(event.payload.defaultModel !== undefined | 386 | ...(event.payload.defaultModelSelection !== undefined |
| 387 | ? { defaultModel: event.payload.defaultModel } | 387 | ? { defaultModelSelection: event.payload.defaultModelSelection } |
| 388 | : {}), | 388 | : {}), |
| 389 | ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), | 389 | ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), |
| 390 | updatedAt: event.payload.updatedAt, | 390 | updatedAt: event.payload.updatedAt, |
| ... | @@ -420,7 +420,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -420,7 +420,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 420 | threadId: event.payload.threadId, | 420 | threadId: event.payload.threadId, |
| 421 | projectId: event.payload.projectId, | 421 | projectId: event.payload.projectId, |
| 422 | title: event.payload.title, | 422 | title: event.payload.title, |
| 423 | model: event.payload.model, | 423 | modelSelection: event.payload.modelSelection, |
| 424 | runtimeMode: event.payload.runtimeMode, | 424 | runtimeMode: event.payload.runtimeMode, |
| 425 | interactionMode: event.payload.interactionMode, | 425 | interactionMode: event.payload.interactionMode, |
| 426 | branch: event.payload.branch, | 426 | branch: event.payload.branch, |
| ... | @@ -442,7 +442,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -442,7 +442,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 442 | yield* projectionThreadRepository.upsert({ | 442 | yield* projectionThreadRepository.upsert({ |
| 443 | ...existingRow.value, | 443 | ...existingRow.value, |
| 444 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), | 444 | ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), |
| 445 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), | 445 | ...(event.payload.modelSelection !== undefined |
| 446 | ? { modelSelection: event.payload.modelSelection } | ||
| 447 | : {}), | ||
| 446 | ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), | 448 | ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), |
| 447 | ...(event.payload.worktreePath !== undefined | 449 | ...(event.payload.worktreePath !== undefined |
| 448 | ? { worktreePath: event.payload.worktreePath } | 450 | ? { worktreePath: event.payload.worktreePath } |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+12-6| ... | @@ -34,7 +34,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -34,7 +34,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 34 | project_id, | 34 | project_id, |
| 35 | title, | 35 | title, |
| 36 | workspace_root, | 36 | workspace_root, |
| 37 | default_model, | 37 | default_model_selection_json, |
| 38 | scripts_json, | 38 | scripts_json, |
| 39 | created_at, | 39 | created_at, |
| 40 | updated_at, | 40 | updated_at, |
| ... | @@ -44,7 +44,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -44,7 +44,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 44 | 'project-1', | 44 | 'project-1', |
| 45 | 'Project 1', | 45 | 'Project 1', |
| 46 | '/tmp/project-1', | 46 | '/tmp/project-1', |
| 47 | 'gpt-5-codex', | 47 | '{"provider":"codex","model":"gpt-5-codex"}', |
| 48 | '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', | 48 | '[{"id":"script-1","name":"Build","command":"bun run build","icon":"build","runOnWorktreeCreate":false}]', |
| 49 | '2026-02-24T00:00:00.000Z', | 49 | '2026-02-24T00:00:00.000Z', |
| 50 | '2026-02-24T00:00:01.000Z', | 50 | '2026-02-24T00:00:01.000Z', |
| ... | @@ -57,7 +57,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -57,7 +57,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 57 | thread_id, | 57 | thread_id, |
| 58 | project_id, | 58 | project_id, |
| 59 | title, | 59 | title, |
| 60 | model, | 60 | model_selection_json, |
| 61 | branch, | 61 | branch, |
| 62 | worktree_path, | 62 | worktree_path, |
| 63 | latest_turn_id, | 63 | latest_turn_id, |
| ... | @@ -69,7 +69,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -69,7 +69,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 69 | 'thread-1', | 69 | 'thread-1', |
| 70 | 'project-1', | 70 | 'project-1', |
| 71 | 'Thread 1', | 71 | 'Thread 1', |
| 72 | 'gpt-5-codex', | 72 | '{"provider":"codex","model":"gpt-5-codex"}', |
| 73 | NULL, | 73 | NULL, |
| 74 | NULL, | 74 | NULL, |
| 75 | 'turn-1', | 75 | 'turn-1', |
| ... | @@ -234,7 +234,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -234,7 +234,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 234 | id: asProjectId("project-1"), | 234 | id: asProjectId("project-1"), |
| 235 | title: "Project 1", | 235 | title: "Project 1", |
| 236 | workspaceRoot: "/tmp/project-1", | 236 | workspaceRoot: "/tmp/project-1", |
| 237 | defaultModel: "gpt-5-codex", | 237 | defaultModelSelection: { |
| 238 | provider: "codex", | ||
| 239 | model: "gpt-5-codex", | ||
| 240 | }, | ||
| 238 | scripts: [ | 241 | scripts: [ |
| 239 | { | 242 | { |
| 240 | id: "script-1", | 243 | id: "script-1", |
| ... | @@ -254,7 +257,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { | ... | @@ -254,7 +257,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| 254 | id: ThreadId.makeUnsafe("thread-1"), | 257 | id: ThreadId.makeUnsafe("thread-1"), |
| 255 | projectId: asProjectId("project-1"), | 258 | projectId: asProjectId("project-1"), |
| 256 | title: "Thread 1", | 259 | title: "Thread 1", |
| 257 | model: "gpt-5-codex", | 260 | modelSelection: { |
| 261 | provider: "codex", | ||
| 262 | model: "gpt-5-codex", | ||
| 263 | }, | ||
| 258 | interactionMode: "default", | 264 | interactionMode: "default", |
| 259 | runtimeMode: "full-access", | 265 | runtimeMode: "full-access", |
| 260 | branch: null, | 266 | branch: null, |
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+13-7| ... | @@ -17,6 +17,7 @@ import { | ... | @@ -17,6 +17,7 @@ import { |
| 17 | type OrchestrationSession, | 17 | type OrchestrationSession, |
| 18 | type OrchestrationThread, | 18 | type OrchestrationThread, |
| 19 | type OrchestrationThreadActivity, | 19 | type OrchestrationThreadActivity, |
| 20 | ModelSelection, | ||
| 20 | } from "@t3tools/contracts"; | 21 | } from "@t3tools/contracts"; |
| 21 | import { Effect, Layer, Schema, Struct } from "effect"; | 22 | import { Effect, Layer, Schema, Struct } from "effect"; |
| 22 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 23 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| ... | @@ -45,6 +46,7 @@ import { | ... | @@ -45,6 +46,7 @@ import { |
| 45 | const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); | 46 | const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); |
| 46 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | 47 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( |
| 47 | Struct.assign({ | 48 | Struct.assign({ |
| 49 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), | ||
| 48 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), | 50 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), |
| 49 | }), | 51 | }), |
| 50 | ); | 52 | ); |
| ... | @@ -55,7 +57,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( | ... | @@ -55,7 +57,11 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( |
| 55 | }), | 57 | }), |
| 56 | ); | 58 | ); |
| 57 | const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; | 59 | const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; |
| 58 | const ProjectionThreadDbRowSchema = ProjectionThread; | 60 | const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( |
| 61 | Struct.assign({ | ||
| 62 | modelSelection: Schema.fromJsonString(ModelSelection), | ||
| 63 | }), | ||
| 64 | ); | ||
| 59 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( | 65 | const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( |
| 60 | Struct.assign({ | 66 | Struct.assign({ |
| 61 | payload: Schema.fromJsonString(Schema.Unknown), | 67 | payload: Schema.fromJsonString(Schema.Unknown), |
| ... | @@ -141,7 +147,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -141,7 +147,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 141 | project_id AS "projectId", | 147 | project_id AS "projectId", |
| 142 | title, | 148 | title, |
| 143 | workspace_root AS "workspaceRoot", | 149 | workspace_root AS "workspaceRoot", |
| 144 | default_model AS "defaultModel", | 150 | default_model_selection_json AS "defaultModelSelection", |
| 145 | scripts_json AS "scripts", | 151 | scripts_json AS "scripts", |
| 146 | created_at AS "createdAt", | 152 | created_at AS "createdAt", |
| 147 | updated_at AS "updatedAt", | 153 | updated_at AS "updatedAt", |
| ... | @@ -160,7 +166,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -160,7 +166,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 160 | thread_id AS "threadId", | 166 | thread_id AS "threadId", |
| 161 | project_id AS "projectId", | 167 | project_id AS "projectId", |
| 162 | title, | 168 | title, |
| 163 | model, | 169 | model_selection_json AS "modelSelection", |
| 164 | runtime_mode AS "runtimeMode", | 170 | runtime_mode AS "runtimeMode", |
| 165 | interaction_mode AS "interactionMode", | 171 | interaction_mode AS "interactionMode", |
| 166 | branch, | 172 | branch, |
| ... | @@ -531,22 +537,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { | ... | @@ -531,22 +537,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 531 | }); | 537 | }); |
| 532 | } | 538 | } |
| 533 | 539 | ||
| 534 | const projects: Array<OrchestrationProject> = projectRows.map((row) => ({ | 540 | const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({ |
| 535 | id: row.projectId, | 541 | id: row.projectId, |
| 536 | title: row.title, | 542 | title: row.title, |
| 537 | workspaceRoot: row.workspaceRoot, | 543 | workspaceRoot: row.workspaceRoot, |
| 538 | defaultModel: row.defaultModel, | 544 | defaultModelSelection: row.defaultModelSelection, |
| 539 | scripts: row.scripts, | 545 | scripts: row.scripts, |
| 540 | createdAt: row.createdAt, | 546 | createdAt: row.createdAt, |
| 541 | updatedAt: row.updatedAt, | 547 | updatedAt: row.updatedAt, |
| 542 | deletedAt: row.deletedAt, | 548 | deletedAt: row.deletedAt, |
| 543 | })); | 549 | })); |
| 544 | 550 | ||
| 545 | const threads: Array<OrchestrationThread> = threadRows.map((row) => ({ | 551 | const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({ |
| 546 | id: row.threadId, | 552 | id: row.threadId, |
| 547 | projectId: row.projectId, | 553 | projectId: row.projectId, |
| 548 | title: row.title, | 554 | title: row.title, |
| 549 | model: row.model, | 555 | modelSelection: row.modelSelection, |
| 550 | runtimeMode: row.runtimeMode, | 556 | runtimeMode: row.runtimeMode, |
| 551 | interactionMode: row.interactionMode, | 557 | interactionMode: row.interactionMode, |
| 552 | branch: row.branch, | 558 | branch: row.branch, |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+159-98| ... | @@ -2,7 +2,7 @@ import fs from "node:fs"; | ... | @@ -2,7 +2,7 @@ import fs from "node:fs"; |
| 2 | import os from "node:os"; | 2 | import os from "node:os"; |
| 3 | import path from "node:path"; | 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 | import { | 6 | import { |
| 7 | ApprovalRequestId, | 7 | ApprovalRequestId, |
| 8 | CommandId, | 8 | CommandId, |
| ... | @@ -93,37 +93,27 @@ describe("ProviderCommandReactor", () => { | ... | @@ -93,37 +93,27 @@ describe("ProviderCommandReactor", () => { |
| 93 | 93 | ||
| 94 | async function createHarness(input?: { | 94 | async function createHarness(input?: { |
| 95 | readonly baseDir?: string; | 95 | readonly baseDir?: string; |
| 96 | readonly threadModel?: string; | 96 | readonly threadModelSelection?: ModelSelection; |
| 97 | readonly sessionModelSwitch?: "unsupported" | "in-session"; | ||
| 97 | }) { | 98 | }) { |
| 98 | const now = new Date().toISOString(); | 99 | const now = new Date().toISOString(); |
| 99 | const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); | 100 | const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-")); |
| 100 | createdBaseDirs.add(baseDir); | 101 | createdBaseDirs.add(baseDir); |
| 101 | const { stateDir } = deriveServerPathsSync(baseDir, undefined); | 102 | const { stateDir } = deriveServerPathsSync(baseDir, undefined); |
| 102 | createdStateDirs.add(stateDir); | 103 | createdStateDirs.add(stateDir); |
| 103 | const threadModel = input?.threadModel ?? "gpt-5-codex"; | ||
| 104 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 104 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 105 | let nextSessionIndex = 1; | 105 | let nextSessionIndex = 1; |
| 106 | const runtimeSessions: Array<ProviderSession> = []; | 106 | const runtimeSessions: Array<ProviderSession> = []; |
| 107 | const modelSelection = input?.threadModelSelection ?? { | ||
| 108 | provider: "codex", | ||
| 109 | model: "gpt-5-codex", | ||
| 110 | }; | ||
| 107 | const startSession = vi.fn((_: unknown, input: unknown) => { | 111 | const startSession = vi.fn((_: unknown, input: unknown) => { |
| 108 | const sessionIndex = nextSessionIndex++; | 112 | const sessionIndex = nextSessionIndex++; |
| 109 | const provider = | ||
| 110 | typeof input === "object" && | ||
| 111 | input !== null && | ||
| 112 | "provider" in input && | ||
| 113 | (input.provider === "codex" || input.provider === "claudeAgent") | ||
| 114 | ? input.provider | ||
| 115 | : "codex"; | ||
| 116 | const resumeCursor = | 113 | const resumeCursor = |
| 117 | typeof input === "object" && input !== null && "resumeCursor" in input | 114 | typeof input === "object" && input !== null && "resumeCursor" in input |
| 118 | ? input.resumeCursor | 115 | ? input.resumeCursor |
| 119 | : undefined; | 116 | : undefined; |
| 120 | const model = | ||
| 121 | typeof input === "object" && | ||
| 122 | input !== null && | ||
| 123 | "model" in input && | ||
| 124 | typeof input.model === "string" | ||
| 125 | ? input.model | ||
| 126 | : undefined; | ||
| 127 | const threadId = | 117 | const threadId = |
| 128 | typeof input === "object" && | 118 | typeof input === "object" && |
| 129 | input !== null && | 119 | input !== null && |
| ... | @@ -132,7 +122,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -132,7 +122,7 @@ describe("ProviderCommandReactor", () => { |
| 132 | ? ThreadId.makeUnsafe(input.threadId) | 122 | ? ThreadId.makeUnsafe(input.threadId) |
| 133 | : ThreadId.makeUnsafe(`thread-${sessionIndex}`); | 123 | : ThreadId.makeUnsafe(`thread-${sessionIndex}`); |
| 134 | const session: ProviderSession = { | 124 | const session: ProviderSession = { |
| 135 | provider, | 125 | provider: modelSelection.provider, |
| 136 | status: "ready" as const, | 126 | status: "ready" as const, |
| 137 | runtimeMode: | 127 | runtimeMode: |
| 138 | typeof input === "object" && | 128 | typeof input === "object" && |
| ... | @@ -141,7 +131,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -141,7 +131,7 @@ describe("ProviderCommandReactor", () => { |
| 141 | (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access") | 131 | (input.runtimeMode === "approval-required" || input.runtimeMode === "full-access") |
| 142 | ? input.runtimeMode | 132 | ? input.runtimeMode |
| 143 | : "full-access", | 133 | : "full-access", |
| 144 | ...(model !== undefined ? { model } : {}), | 134 | ...(modelSelection.model !== undefined ? { model: modelSelection.model } : {}), |
| 145 | threadId, | 135 | threadId, |
| 146 | resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, | 136 | resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` }, |
| 147 | createdAt: now, | 137 | createdAt: now, |
| ... | @@ -203,9 +193,9 @@ describe("ProviderCommandReactor", () => { | ... | @@ -203,9 +193,9 @@ describe("ProviderCommandReactor", () => { |
| 203 | respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], | 193 | respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], |
| 204 | stopSession: stopSession as ProviderServiceShape["stopSession"], | 194 | stopSession: stopSession as ProviderServiceShape["stopSession"], |
| 205 | listSessions: () => Effect.succeed(runtimeSessions), | 195 | listSessions: () => Effect.succeed(runtimeSessions), |
| 206 | getCapabilities: (provider) => | 196 | getCapabilities: (_provider) => |
| 207 | Effect.succeed({ | 197 | Effect.succeed({ |
| 208 | sessionModelSwitch: provider === "codex" ? "in-session" : "in-session", | 198 | sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", |
| 209 | }), | 199 | }), |
| 210 | rollbackConversation: () => unsupported(), | 200 | rollbackConversation: () => unsupported(), |
| 211 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | 201 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), |
| ... | @@ -242,7 +232,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -242,7 +232,7 @@ describe("ProviderCommandReactor", () => { |
| 242 | projectId: asProjectId("project-1"), | 232 | projectId: asProjectId("project-1"), |
| 243 | title: "Provider Project", | 233 | title: "Provider Project", |
| 244 | workspaceRoot: "/tmp/provider-project", | 234 | workspaceRoot: "/tmp/provider-project", |
| 245 | defaultModel: threadModel, | 235 | defaultModelSelection: modelSelection, |
| 246 | createdAt: now, | 236 | createdAt: now, |
| 247 | }), | 237 | }), |
| 248 | ); | 238 | ); |
| ... | @@ -253,7 +243,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -253,7 +243,7 @@ describe("ProviderCommandReactor", () => { |
| 253 | threadId: ThreadId.makeUnsafe("thread-1"), | 243 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 254 | projectId: asProjectId("project-1"), | 244 | projectId: asProjectId("project-1"), |
| 255 | title: "Thread", | 245 | title: "Thread", |
| 256 | model: threadModel, | 246 | modelSelection: modelSelection, |
| 257 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 247 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 258 | runtimeMode: "approval-required", | 248 | runtimeMode: "approval-required", |
| 259 | branch: null, | 249 | branch: null, |
| ... | @@ -303,7 +293,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -303,7 +293,10 @@ describe("ProviderCommandReactor", () => { |
| 303 | expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1")); | 293 | expect(harness.startSession.mock.calls[0]?.[0]).toEqual(ThreadId.makeUnsafe("thread-1")); |
| 304 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | 294 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 305 | cwd: "/tmp/provider-project", | 295 | cwd: "/tmp/provider-project", |
| 306 | model: "gpt-5-codex", | 296 | modelSelection: { |
| 297 | provider: "codex", | ||
| 298 | model: "gpt-5-codex", | ||
| 299 | }, | ||
| 307 | runtimeMode: "approval-required", | 300 | runtimeMode: "approval-required", |
| 308 | }); | 301 | }); |
| 309 | 302 | ||
| ... | @@ -328,10 +321,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -328,10 +321,10 @@ describe("ProviderCommandReactor", () => { |
| 328 | text: "hello fast mode", | 321 | text: "hello fast mode", |
| 329 | attachments: [], | 322 | attachments: [], |
| 330 | }, | 323 | }, |
| 331 | provider: "codex", | 324 | modelSelection: { |
| 332 | model: "gpt-5.3-codex", | 325 | provider: "codex", |
| 333 | modelOptions: { | 326 | model: "gpt-5.3-codex", |
| 334 | codex: { | 327 | options: { |
| 335 | reasoningEffort: "high", | 328 | reasoningEffort: "high", |
| 336 | fastMode: true, | 329 | fastMode: true, |
| 337 | }, | 330 | }, |
| ... | @@ -345,9 +338,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -345,9 +338,10 @@ describe("ProviderCommandReactor", () => { |
| 345 | await waitFor(() => harness.startSession.mock.calls.length === 1); | 338 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 346 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | 339 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 347 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | 340 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 348 | model: "gpt-5.3-codex", | 341 | modelSelection: { |
| 349 | modelOptions: { | 342 | provider: "codex", |
| 350 | codex: { | 343 | model: "gpt-5.3-codex", |
| 344 | options: { | ||
| 351 | reasoningEffort: "high", | 345 | reasoningEffort: "high", |
| 352 | fastMode: true, | 346 | fastMode: true, |
| 353 | }, | 347 | }, |
| ... | @@ -355,9 +349,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -355,9 +349,10 @@ describe("ProviderCommandReactor", () => { |
| 355 | }); | 349 | }); |
| 356 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ | 350 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ |
| 357 | threadId: ThreadId.makeUnsafe("thread-1"), | 351 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 358 | model: "gpt-5.3-codex", | 352 | modelSelection: { |
| 359 | modelOptions: { | 353 | provider: "codex", |
| 360 | codex: { | 354 | model: "gpt-5.3-codex", |
| 355 | options: { | ||
| 361 | reasoningEffort: "high", | 356 | reasoningEffort: "high", |
| 362 | fastMode: true, | 357 | fastMode: true, |
| 363 | }, | 358 | }, |
| ... | @@ -366,7 +361,9 @@ describe("ProviderCommandReactor", () => { | ... | @@ -366,7 +361,9 @@ describe("ProviderCommandReactor", () => { |
| 366 | }); | 361 | }); |
| 367 | 362 | ||
| 368 | it("forwards claude effort options through session start and turn send", async () => { | 363 | it("forwards claude effort options through session start and turn send", async () => { |
| 369 | const harness = await createHarness({ threadModel: "claude-sonnet-4-6" }); | 364 | const harness = await createHarness({ |
| 365 | threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | ||
| 366 | }); | ||
| 370 | const now = new Date().toISOString(); | 367 | const now = new Date().toISOString(); |
| 371 | 368 | ||
| 372 | await Effect.runPromise( | 369 | await Effect.runPromise( |
| ... | @@ -380,10 +377,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -380,10 +377,10 @@ describe("ProviderCommandReactor", () => { |
| 380 | text: "hello with effort", | 377 | text: "hello with effort", |
| 381 | attachments: [], | 378 | attachments: [], |
| 382 | }, | 379 | }, |
| 383 | provider: "claudeAgent", | 380 | modelSelection: { |
| 384 | model: "claude-sonnet-4-6", | 381 | provider: "claudeAgent", |
| 385 | modelOptions: { | 382 | model: "claude-sonnet-4-6", |
| 386 | claudeAgent: { | 383 | options: { |
| 387 | effort: "max", | 384 | effort: "max", |
| 388 | }, | 385 | }, |
| 389 | }, | 386 | }, |
| ... | @@ -396,19 +393,20 @@ describe("ProviderCommandReactor", () => { | ... | @@ -396,19 +393,20 @@ describe("ProviderCommandReactor", () => { |
| 396 | await waitFor(() => harness.startSession.mock.calls.length === 1); | 393 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 397 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | 394 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 398 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | 395 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 399 | provider: "claudeAgent", | 396 | modelSelection: { |
| 400 | model: "claude-sonnet-4-6", | 397 | provider: "claudeAgent", |
| 401 | modelOptions: { | 398 | model: "claude-sonnet-4-6", |
| 402 | claudeAgent: { | 399 | options: { |
| 403 | effort: "max", | 400 | effort: "max", |
| 404 | }, | 401 | }, |
| 405 | }, | 402 | }, |
| 406 | }); | 403 | }); |
| 407 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ | 404 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ |
| 408 | threadId: ThreadId.makeUnsafe("thread-1"), | 405 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 409 | model: "claude-sonnet-4-6", | 406 | modelSelection: { |
| 410 | modelOptions: { | 407 | provider: "claudeAgent", |
| 411 | claudeAgent: { | 408 | model: "claude-sonnet-4-6", |
| 409 | options: { | ||
| 412 | effort: "max", | 410 | effort: "max", |
| 413 | }, | 411 | }, |
| 414 | }, | 412 | }, |
| ... | @@ -416,7 +414,9 @@ describe("ProviderCommandReactor", () => { | ... | @@ -416,7 +414,9 @@ describe("ProviderCommandReactor", () => { |
| 416 | }); | 414 | }); |
| 417 | 415 | ||
| 418 | it("forwards claude fast mode options through session start and turn send", async () => { | 416 | it("forwards claude fast mode options through session start and turn send", async () => { |
| 419 | const harness = await createHarness({ threadModel: "claude-opus-4-6" }); | 417 | const harness = await createHarness({ |
| 418 | threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, | ||
| 419 | }); | ||
| 420 | const now = new Date().toISOString(); | 420 | const now = new Date().toISOString(); |
| 421 | 421 | ||
| 422 | await Effect.runPromise( | 422 | await Effect.runPromise( |
| ... | @@ -430,10 +430,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -430,10 +430,10 @@ describe("ProviderCommandReactor", () => { |
| 430 | text: "hello with fast mode", | 430 | text: "hello with fast mode", |
| 431 | attachments: [], | 431 | attachments: [], |
| 432 | }, | 432 | }, |
| 433 | provider: "claudeAgent", | 433 | modelSelection: { |
| 434 | model: "claude-opus-4-6", | 434 | provider: "claudeAgent", |
| 435 | modelOptions: { | 435 | model: "claude-opus-4-6", |
| 436 | claudeAgent: { | 436 | options: { |
| 437 | fastMode: true, | 437 | fastMode: true, |
| 438 | }, | 438 | }, |
| 439 | }, | 439 | }, |
| ... | @@ -446,19 +446,20 @@ describe("ProviderCommandReactor", () => { | ... | @@ -446,19 +446,20 @@ describe("ProviderCommandReactor", () => { |
| 446 | await waitFor(() => harness.startSession.mock.calls.length === 1); | 446 | await waitFor(() => harness.startSession.mock.calls.length === 1); |
| 447 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | 447 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 448 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ | 448 | expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ |
| 449 | provider: "claudeAgent", | 449 | modelSelection: { |
| 450 | model: "claude-opus-4-6", | 450 | provider: "claudeAgent", |
| 451 | modelOptions: { | 451 | model: "claude-opus-4-6", |
| 452 | claudeAgent: { | 452 | options: { |
| 453 | fastMode: true, | 453 | fastMode: true, |
| 454 | }, | 454 | }, |
| 455 | }, | 455 | }, |
| 456 | }); | 456 | }); |
| 457 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ | 457 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ |
| 458 | threadId: ThreadId.makeUnsafe("thread-1"), | 458 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 459 | model: "claude-opus-4-6", | 459 | modelSelection: { |
| 460 | modelOptions: { | 460 | provider: "claudeAgent", |
| 461 | claudeAgent: { | 461 | model: "claude-opus-4-6", |
| 462 | options: { | ||
| 462 | fastMode: true, | 463 | fastMode: true, |
| 463 | }, | 464 | }, |
| 464 | }, | 465 | }, |
| ... | @@ -504,7 +505,9 @@ describe("ProviderCommandReactor", () => { | ... | @@ -504,7 +505,9 @@ describe("ProviderCommandReactor", () => { |
| 504 | }); | 505 | }); |
| 505 | 506 | ||
| 506 | it("rejects a first turn when requested provider conflicts with the thread model", async () => { | 507 | it("rejects a first turn when requested provider conflicts with the thread model", async () => { |
| 507 | const harness = await createHarness(); | 508 | const harness = await createHarness({ |
| 509 | threadModelSelection: { provider: "codex", model: "gpt-5-codex" }, | ||
| 510 | }); | ||
| 508 | const now = new Date().toISOString(); | 511 | const now = new Date().toISOString(); |
| 509 | 512 | ||
| 510 | await Effect.runPromise( | 513 | await Effect.runPromise( |
| ... | @@ -518,7 +521,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -518,7 +521,10 @@ describe("ProviderCommandReactor", () => { |
| 518 | text: "hello claude", | 521 | text: "hello claude", |
| 519 | attachments: [], | 522 | attachments: [], |
| 520 | }, | 523 | }, |
| 521 | provider: "claudeAgent", | 524 | modelSelection: { |
| 525 | provider: "claudeAgent", | ||
| 526 | model: "claude-opus-4-6", | ||
| 527 | }, | ||
| 522 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 528 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 523 | runtimeMode: "approval-required", | 529 | runtimeMode: "approval-required", |
| 524 | createdAt: now, | 530 | createdAt: now, |
| ... | @@ -552,49 +558,53 @@ describe("ProviderCommandReactor", () => { | ... | @@ -552,49 +558,53 @@ describe("ProviderCommandReactor", () => { |
| 552 | }); | 558 | }); |
| 553 | }); | 559 | }); |
| 554 | 560 | ||
| 555 | it("rejects a turn when the requested model belongs to a different provider", async () => { | 561 | it("preserves the active session model when in-session model switching is unsupported", async () => { |
| 556 | const harness = await createHarness(); | 562 | const harness = await createHarness({ sessionModelSwitch: "unsupported" }); |
| 557 | const now = new Date().toISOString(); | 563 | const now = new Date().toISOString(); |
| 558 | 564 | ||
| 559 | await Effect.runPromise( | 565 | await Effect.runPromise( |
| 560 | harness.engine.dispatch({ | 566 | harness.engine.dispatch({ |
| 561 | type: "thread.turn.start", | 567 | type: "thread.turn.start", |
| 562 | commandId: CommandId.makeUnsafe("cmd-turn-start-model-provider-mismatch"), | 568 | commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-1"), |
| 563 | threadId: ThreadId.makeUnsafe("thread-1"), | 569 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 564 | message: { | 570 | message: { |
| 565 | messageId: asMessageId("user-message-model-provider-mismatch"), | 571 | messageId: asMessageId("user-message-unsupported-1"), |
| 566 | role: "user", | 572 | role: "user", |
| 567 | text: "hello", | 573 | text: "first", |
| 568 | attachments: [], | 574 | attachments: [], |
| 569 | }, | 575 | }, |
| 570 | model: "claude-sonnet-4-6", | ||
| 571 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 576 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 572 | runtimeMode: "approval-required", | 577 | runtimeMode: "approval-required", |
| 573 | createdAt: now, | 578 | createdAt: now, |
| 574 | }), | 579 | }), |
| 575 | ); | 580 | ); |
| 576 | 581 | ||
| 577 | await waitFor(async () => { | 582 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 578 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | ||
| 579 | const thread = readModel.threads.find( | ||
| 580 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), | ||
| 581 | ); | ||
| 582 | return ( | ||
| 583 | thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed") ?? | ||
| 584 | false | ||
| 585 | ); | ||
| 586 | }); | ||
| 587 | 583 | ||
| 588 | expect(harness.startSession).not.toHaveBeenCalled(); | 584 | await Effect.runPromise( |
| 589 | expect(harness.sendTurn).not.toHaveBeenCalled(); | 585 | harness.engine.dispatch({ |
| 586 | type: "thread.turn.start", | ||
| 587 | commandId: CommandId.makeUnsafe("cmd-turn-start-unsupported-2"), | ||
| 588 | threadId: ThreadId.makeUnsafe("thread-1"), | ||
| 589 | message: { | ||
| 590 | messageId: asMessageId("user-message-unsupported-2"), | ||
| 591 | role: "user", | ||
| 592 | text: "second", | ||
| 593 | attachments: [], | ||
| 594 | }, | ||
| 595 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | ||
| 596 | runtimeMode: "approval-required", | ||
| 597 | createdAt: now, | ||
| 598 | }), | ||
| 599 | ); | ||
| 590 | 600 | ||
| 591 | const readModel = await Effect.runPromise(harness.engine.getReadModel()); | 601 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); |
| 592 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | 602 | |
| 593 | expect( | 603 | expect(harness.sendTurn.mock.calls[1]?.[0]).toMatchObject({ |
| 594 | thread?.activities.find((activity) => activity.kind === "provider.turn.start.failed"), | 604 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 595 | ).toMatchObject({ | 605 | modelSelection: { |
| 596 | payload: { | 606 | provider: "codex", |
| 597 | detail: expect.stringContaining("does not belong to provider 'codex'"), | 607 | model: "gpt-5-codex", |
| 598 | }, | 608 | }, |
| 599 | }); | 609 | }); |
| 600 | }); | 610 | }); |
| ... | @@ -646,7 +656,9 @@ describe("ProviderCommandReactor", () => { | ... | @@ -646,7 +656,9 @@ describe("ProviderCommandReactor", () => { |
| 646 | }); | 656 | }); |
| 647 | 657 | ||
| 648 | it("restarts claude sessions when claude effort changes", async () => { | 658 | it("restarts claude sessions when claude effort changes", async () => { |
| 649 | const harness = await createHarness({ threadModel: "claude-sonnet-4-6" }); | 659 | const harness = await createHarness({ |
| 660 | threadModelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | ||
| 661 | }); | ||
| 650 | const now = new Date().toISOString(); | 662 | const now = new Date().toISOString(); |
| 651 | 663 | ||
| 652 | await Effect.runPromise( | 664 | await Effect.runPromise( |
| ... | @@ -660,10 +672,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -660,10 +672,10 @@ describe("ProviderCommandReactor", () => { |
| 660 | text: "first claude turn", | 672 | text: "first claude turn", |
| 661 | attachments: [], | 673 | attachments: [], |
| 662 | }, | 674 | }, |
| 663 | provider: "claudeAgent", | 675 | modelSelection: { |
| 664 | model: "claude-sonnet-4-6", | 676 | provider: "claudeAgent", |
| 665 | modelOptions: { | 677 | model: "claude-sonnet-4-6", |
| 666 | claudeAgent: { | 678 | options: { |
| 667 | effort: "medium", | 679 | effort: "medium", |
| 668 | }, | 680 | }, |
| 669 | }, | 681 | }, |
| ... | @@ -687,10 +699,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -687,10 +699,10 @@ describe("ProviderCommandReactor", () => { |
| 687 | text: "second claude turn", | 699 | text: "second claude turn", |
| 688 | attachments: [], | 700 | attachments: [], |
| 689 | }, | 701 | }, |
| 690 | provider: "claudeAgent", | 702 | modelSelection: { |
| 691 | model: "claude-sonnet-4-6", | 703 | provider: "claudeAgent", |
| 692 | modelOptions: { | 704 | model: "claude-sonnet-4-6", |
| 693 | claudeAgent: { | 705 | options: { |
| 694 | effort: "max", | 706 | effort: "max", |
| 695 | }, | 707 | }, |
| 696 | }, | 708 | }, |
| ... | @@ -703,10 +715,11 @@ describe("ProviderCommandReactor", () => { | ... | @@ -703,10 +715,11 @@ describe("ProviderCommandReactor", () => { |
| 703 | await waitFor(() => harness.startSession.mock.calls.length === 2); | 715 | await waitFor(() => harness.startSession.mock.calls.length === 2); |
| 704 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); | 716 | await waitFor(() => harness.sendTurn.mock.calls.length === 2); |
| 705 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ | 717 | expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ |
| 706 | provider: "claudeAgent", | ||
| 707 | resumeCursor: { opaque: "resume-1" }, | 718 | resumeCursor: { opaque: "resume-1" }, |
| 708 | modelOptions: { | 719 | modelSelection: { |
| 709 | claudeAgent: { | 720 | provider: "claudeAgent", |
| 721 | model: "claude-sonnet-4-6", | ||
| 722 | options: { | ||
| 710 | effort: "max", | 723 | effort: "max", |
| 711 | }, | 724 | }, |
| 712 | }, | 725 | }, |
| ... | @@ -800,6 +813,51 @@ describe("ProviderCommandReactor", () => { | ... | @@ -800,6 +813,51 @@ describe("ProviderCommandReactor", () => { |
| 800 | expect(thread?.session?.runtimeMode).toBe("approval-required"); | 813 | expect(thread?.session?.runtimeMode).toBe("approval-required"); |
| 801 | }); | 814 | }); |
| 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 | it("rejects provider changes after a thread is already bound to a session provider", async () => { | 861 | it("rejects provider changes after a thread is already bound to a session provider", async () => { |
| 804 | const harness = await createHarness(); | 862 | const harness = await createHarness(); |
| 805 | const now = new Date().toISOString(); | 863 | const now = new Date().toISOString(); |
| ... | @@ -835,7 +893,10 @@ describe("ProviderCommandReactor", () => { | ... | @@ -835,7 +893,10 @@ describe("ProviderCommandReactor", () => { |
| 835 | text: "second", | 893 | text: "second", |
| 836 | attachments: [], | 894 | attachments: [], |
| 837 | }, | 895 | }, |
| 838 | provider: "claudeAgent", | 896 | modelSelection: { |
| 897 | provider: "claudeAgent", | ||
| 898 | model: "claude-opus-4-6", | ||
| 899 | }, | ||
| 839 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 900 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 840 | runtimeMode: "approval-required", | 901 | runtimeMode: "approval-required", |
| 841 | createdAt: now, | 902 | createdAt: now, |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+48-62| ... | @@ -3,8 +3,8 @@ import { | ... | @@ -3,8 +3,8 @@ import { |
| 3 | CommandId, | 3 | CommandId, |
| 4 | DEFAULT_GIT_TEXT_GENERATION_MODEL, | 4 | DEFAULT_GIT_TEXT_GENERATION_MODEL, |
| 5 | EventId, | 5 | EventId, |
| 6 | type ModelSelection, | ||
| 6 | type OrchestrationEvent, | 7 | type OrchestrationEvent, |
| 7 | type ProviderModelOptions, | ||
| 8 | ProviderKind, | 8 | ProviderKind, |
| 9 | type ProviderStartOptions, | 9 | type ProviderStartOptions, |
| 10 | type OrchestrationSession, | 10 | type OrchestrationSession, |
| ... | @@ -13,7 +13,7 @@ import { | ... | @@ -13,7 +13,7 @@ import { |
| 13 | type RuntimeMode, | 13 | type RuntimeMode, |
| 14 | type TurnId, | 14 | type TurnId, |
| 15 | } from "@t3tools/contracts"; | 15 | } from "@t3tools/contracts"; |
| 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 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | 17 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 18 | 18 | ||
| 19 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; | 19 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| ... | @@ -26,7 +26,6 @@ import { | ... | @@ -26,7 +26,6 @@ import { |
| 26 | ProviderCommandReactor, | 26 | ProviderCommandReactor, |
| 27 | type ProviderCommandReactorShape, | 27 | type ProviderCommandReactorShape, |
| 28 | } from "../Services/ProviderCommandReactor.ts"; | 28 | } from "../Services/ProviderCommandReactor.ts"; |
| 29 | import { inferProviderForModel } from "@t3tools/shared/model"; | ||
| 30 | 29 | ||
| 31 | type ProviderIntentEvent = Extract< | 30 | type ProviderIntentEvent = Extract< |
| 32 | OrchestrationEvent, | 31 | OrchestrationEvent, |
| ... | @@ -76,11 +75,6 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; | ... | @@ -76,11 +75,6 @@ const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; |
| 76 | const WORKTREE_BRANCH_PREFIX = "t3code"; | 75 | const WORKTREE_BRANCH_PREFIX = "t3code"; |
| 77 | const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); | 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 | function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean { | 78 | function isUnknownPendingApprovalRequestError(cause: Cause.Cause<ProviderServiceError>): boolean { |
| 85 | const error = Cause.squash(cause); | 79 | const error = Cause.squash(cause); |
| 86 | if (Schema.is(ProviderAdapterRequestError)(error)) { | 80 | if (Schema.is(ProviderAdapterRequestError)(error)) { |
| ... | @@ -158,7 +152,7 @@ const make = Effect.gen(function* () { | ... | @@ -158,7 +152,7 @@ const make = Effect.gen(function* () { |
| 158 | ); | 152 | ); |
| 159 | 153 | ||
| 160 | const threadProviderOptions = new Map<string, ProviderStartOptions>(); | 154 | const threadProviderOptions = new Map<string, ProviderStartOptions>(); |
| 161 | const threadModelOptions = new Map<string, ProviderModelOptions>(); | 155 | const threadModelSelections = new Map<string, ModelSelection>(); |
| 162 | 156 | ||
| 163 | const appendProviderFailureActivity = (input: { | 157 | const appendProviderFailureActivity = (input: { |
| 164 | readonly threadId: ThreadId; | 158 | readonly threadId: ThreadId; |
| ... | @@ -215,9 +209,7 @@ const make = Effect.gen(function* () { | ... | @@ -215,9 +209,7 @@ const make = Effect.gen(function* () { |
| 215 | threadId: ThreadId, | 209 | threadId: ThreadId, |
| 216 | createdAt: string, | 210 | createdAt: string, |
| 217 | options?: { | 211 | options?: { |
| 218 | readonly provider?: ProviderKind; | 212 | readonly modelSelection?: ModelSelection; |
| 219 | readonly model?: string; | ||
| 220 | readonly modelOptions?: ProviderModelOptions; | ||
| 221 | readonly providerOptions?: ProviderStartOptions; | 213 | readonly providerOptions?: ProviderStartOptions; |
| 222 | }, | 214 | }, |
| 223 | ) { | 215 | ) { |
| ... | @@ -233,26 +225,20 @@ const make = Effect.gen(function* () { | ... | @@ -233,26 +225,20 @@ const make = Effect.gen(function* () { |
| 233 | ) | 225 | ) |
| 234 | ? thread.session.providerName | 226 | ? thread.session.providerName |
| 235 | : undefined; | 227 | : undefined; |
| 236 | const threadProvider: ProviderKind = currentProvider ?? inferProviderForModel(thread.model); | 228 | const requestedModelSelection = options?.modelSelection; |
| 237 | if (options?.provider !== undefined && options.provider !== threadProvider) { | 229 | const threadProvider: ProviderKind = currentProvider ?? thread.modelSelection.provider; |
| 238 | return yield* new ProviderAdapterRequestError({ | ||
| 239 | provider: threadProvider, | ||
| 240 | method: "thread.turn.start", | ||
| 241 | detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${options.provider}'.`, | ||
| 242 | }); | ||
| 243 | } | ||
| 244 | if ( | 230 | if ( |
| 245 | options?.model !== undefined && | 231 | requestedModelSelection !== undefined && |
| 246 | inferProviderForModel(options.model, threadProvider) !== threadProvider | 232 | requestedModelSelection.provider !== threadProvider |
| 247 | ) { | 233 | ) { |
| 248 | return yield* new ProviderAdapterRequestError({ | 234 | return yield* new ProviderAdapterRequestError({ |
| 249 | provider: threadProvider, | 235 | provider: threadProvider, |
| 250 | method: "thread.turn.start", | 236 | method: "thread.turn.start", |
| 251 | detail: `Model '${options.model}' does not belong to provider '${threadProvider}' for thread '${threadId}'.`, | 237 | detail: `Thread '${threadId}' is bound to provider '${threadProvider}' and cannot switch to '${requestedModelSelection.provider}'.`, |
| 252 | }); | 238 | }); |
| 253 | } | 239 | } |
| 254 | const preferredProvider: ProviderKind = currentProvider ?? threadProvider; | 240 | const preferredProvider: ProviderKind = currentProvider ?? threadProvider; |
| 255 | const desiredModel = options?.model ?? thread.model; | 241 | const desiredModelSelection = requestedModelSelection ?? thread.modelSelection; |
| 256 | const effectiveCwd = resolveThreadWorkspaceCwd({ | 242 | const effectiveCwd = resolveThreadWorkspaceCwd({ |
| 257 | thread, | 243 | thread, |
| 258 | projects: readModel.projects, | 244 | projects: readModel.projects, |
| ... | @@ -269,12 +255,9 @@ const make = Effect.gen(function* () { | ... | @@ -269,12 +255,9 @@ const make = Effect.gen(function* () { |
| 269 | }) => | 255 | }) => |
| 270 | providerService.startSession(threadId, { | 256 | providerService.startSession(threadId, { |
| 271 | threadId, | 257 | threadId, |
| 272 | ...((input?.provider ?? preferredProvider) | 258 | ...(preferredProvider ? { provider: preferredProvider } : {}), |
| 273 | ? { provider: input?.provider ?? preferredProvider } | ||
| 274 | : {}), | ||
| 275 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), | 259 | ...(effectiveCwd ? { cwd: effectiveCwd } : {}), |
| 276 | ...(desiredModel ? { model: desiredModel } : {}), | 260 | modelSelection: desiredModelSelection, |
| 277 | ...(options?.modelOptions !== undefined ? { modelOptions: options.modelOptions } : {}), | ||
| 278 | ...(options?.providerOptions !== undefined | 261 | ...(options?.providerOptions !== undefined |
| 279 | ? { providerOptions: options.providerOptions } | 262 | ? { providerOptions: options.providerOptions } |
| 280 | : {}), | 263 | : {}), |
| ... | @@ -303,25 +286,28 @@ const make = Effect.gen(function* () { | ... | @@ -303,25 +286,28 @@ const make = Effect.gen(function* () { |
| 303 | if (existingSessionThreadId) { | 286 | if (existingSessionThreadId) { |
| 304 | const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; | 287 | const runtimeModeChanged = thread.runtimeMode !== thread.session?.runtimeMode; |
| 305 | const providerChanged = | 288 | const providerChanged = |
| 306 | options?.provider !== undefined && options.provider !== currentProvider; | 289 | requestedModelSelection !== undefined && |
| 290 | requestedModelSelection.provider !== currentProvider; | ||
| 307 | const activeSession = yield* resolveActiveSession(existingSessionThreadId); | 291 | const activeSession = yield* resolveActiveSession(existingSessionThreadId); |
| 308 | const sessionModelSwitch = | 292 | const sessionModelSwitch = |
| 309 | currentProvider === undefined | 293 | currentProvider === undefined |
| 310 | ? "in-session" | 294 | ? "in-session" |
| 311 | : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; | 295 | : (yield* providerService.getCapabilities(currentProvider)).sessionModelSwitch; |
| 312 | const modelChanged = options?.model !== undefined && options.model !== activeSession?.model; | 296 | const modelChanged = |
| 297 | requestedModelSelection !== undefined && | ||
| 298 | requestedModelSelection.model !== activeSession?.model; | ||
| 313 | const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; | 299 | const shouldRestartForModelChange = modelChanged && sessionModelSwitch === "restart-session"; |
| 314 | const previousModelOptions = threadModelOptions.get(threadId); | 300 | const previousModelSelection = threadModelSelections.get(threadId); |
| 315 | const shouldRestartForModelOptionsChange = | 301 | const shouldRestartForModelSelectionChange = |
| 316 | currentProvider === "claudeAgent" && | 302 | currentProvider === "claudeAgent" && |
| 317 | options?.modelOptions !== undefined && | 303 | requestedModelSelection !== undefined && |
| 318 | !sameModelOptions(previousModelOptions, options.modelOptions); | 304 | !Equal.equals(previousModelSelection, requestedModelSelection); |
| 319 | 305 | ||
| 320 | if ( | 306 | if ( |
| 321 | !runtimeModeChanged && | 307 | !runtimeModeChanged && |
| 322 | !providerChanged && | 308 | !providerChanged && |
| 323 | !shouldRestartForModelChange && | 309 | !shouldRestartForModelChange && |
| 324 | !shouldRestartForModelOptionsChange | 310 | !shouldRestartForModelSelectionChange |
| 325 | ) { | 311 | ) { |
| 326 | return existingSessionThreadId; | 312 | return existingSessionThreadId; |
| 327 | } | 313 | } |
| ... | @@ -334,20 +320,19 @@ const make = Effect.gen(function* () { | ... | @@ -334,20 +320,19 @@ const make = Effect.gen(function* () { |
| 334 | threadId, | 320 | threadId, |
| 335 | existingSessionThreadId, | 321 | existingSessionThreadId, |
| 336 | currentProvider, | 322 | currentProvider, |
| 337 | desiredProvider: options?.provider ?? currentProvider, | 323 | desiredProvider: desiredModelSelection.provider, |
| 338 | currentRuntimeMode: thread.session?.runtimeMode, | 324 | currentRuntimeMode: thread.session?.runtimeMode, |
| 339 | desiredRuntimeMode: thread.runtimeMode, | 325 | desiredRuntimeMode: thread.runtimeMode, |
| 340 | runtimeModeChanged, | 326 | runtimeModeChanged, |
| 341 | providerChanged, | 327 | providerChanged, |
| 342 | modelChanged, | 328 | modelChanged, |
| 343 | shouldRestartForModelChange, | 329 | shouldRestartForModelChange, |
| 344 | shouldRestartForModelOptionsChange, | 330 | shouldRestartForModelSelectionChange, |
| 345 | hasResumeCursor: resumeCursor !== undefined, | 331 | hasResumeCursor: resumeCursor !== undefined, |
| 346 | }); | 332 | }); |
| 347 | const restartedSession = yield* startProviderSession({ | 333 | const restartedSession = yield* startProviderSession( |
| 348 | ...(resumeCursor !== undefined ? { resumeCursor } : {}), | 334 | resumeCursor !== undefined ? { resumeCursor } : undefined, |
| 349 | ...(options?.provider !== undefined ? { provider: options.provider } : {}), | 335 | ); |
| 350 | }); | ||
| 351 | yield* Effect.logInfo("provider command reactor restarted provider session", { | 336 | yield* Effect.logInfo("provider command reactor restarted provider session", { |
| 352 | threadId, | 337 | threadId, |
| 353 | previousSessionId: existingSessionThreadId, | 338 | previousSessionId: existingSessionThreadId, |
| ... | @@ -359,9 +344,7 @@ const make = Effect.gen(function* () { | ... | @@ -359,9 +344,7 @@ const make = Effect.gen(function* () { |
| 359 | return restartedSession.threadId; | 344 | return restartedSession.threadId; |
| 360 | } | 345 | } |
| 361 | 346 | ||
| 362 | const startedSession = yield* startProviderSession( | 347 | const startedSession = yield* startProviderSession(undefined); |
| 363 | options?.provider !== undefined ? { provider: options.provider } : undefined, | ||
| 364 | ); | ||
| 365 | yield* bindSessionToThread(startedSession); | 348 | yield* bindSessionToThread(startedSession); |
| 366 | return startedSession.threadId; | 349 | return startedSession.threadId; |
| 367 | }); | 350 | }); |
| ... | @@ -370,9 +353,7 @@ const make = Effect.gen(function* () { | ... | @@ -370,9 +353,7 @@ const make = Effect.gen(function* () { |
| 370 | readonly threadId: ThreadId; | 353 | readonly threadId: ThreadId; |
| 371 | readonly messageText: string; | 354 | readonly messageText: string; |
| 372 | readonly attachments?: ReadonlyArray<ChatAttachment>; | 355 | readonly attachments?: ReadonlyArray<ChatAttachment>; |
| 373 | readonly provider?: ProviderKind; | 356 | readonly modelSelection?: ModelSelection; |
| 374 | readonly model?: string; | ||
| 375 | readonly modelOptions?: ProviderModelOptions; | ||
| 376 | readonly providerOptions?: ProviderStartOptions; | 357 | readonly providerOptions?: ProviderStartOptions; |
| 377 | readonly interactionMode?: "default" | "plan"; | 358 | readonly interactionMode?: "default" | "plan"; |
| 378 | readonly createdAt: string; | 359 | readonly createdAt: string; |
| ... | @@ -382,16 +363,14 @@ const make = Effect.gen(function* () { | ... | @@ -382,16 +363,14 @@ const make = Effect.gen(function* () { |
| 382 | return; | 363 | return; |
| 383 | } | 364 | } |
| 384 | yield* ensureSessionForThread(input.threadId, input.createdAt, { | 365 | yield* ensureSessionForThread(input.threadId, input.createdAt, { |
| 385 | ...(input.provider !== undefined ? { provider: input.provider } : {}), | 366 | ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), |
| 386 | ...(input.model !== undefined ? { model: input.model } : {}), | ||
| 387 | ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), | ||
| 388 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), | 367 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), |
| 389 | }); | 368 | }); |
| 390 | if (input.providerOptions !== undefined) { | 369 | if (input.providerOptions !== undefined) { |
| 391 | threadProviderOptions.set(input.threadId, input.providerOptions); | 370 | threadProviderOptions.set(input.threadId, input.providerOptions); |
| 392 | } | 371 | } |
| 393 | if (input.modelOptions !== undefined) { | 372 | if (input.modelSelection !== undefined) { |
| 394 | threadModelOptions.set(input.threadId, input.modelOptions); | 373 | threadModelSelections.set(input.threadId, input.modelSelection); |
| 395 | } | 374 | } |
| 396 | const normalizedInput = toNonEmptyProviderInput(input.messageText); | 375 | const normalizedInput = toNonEmptyProviderInput(input.messageText); |
| 397 | const normalizedAttachments = input.attachments ?? []; | 376 | const normalizedAttachments = input.attachments ?? []; |
| ... | @@ -404,14 +383,23 @@ const make = Effect.gen(function* () { | ... | @@ -404,14 +383,23 @@ const make = Effect.gen(function* () { |
| 404 | activeSession === undefined | 383 | activeSession === undefined |
| 405 | ? "in-session" | 384 | ? "in-session" |
| 406 | : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; | 385 | : (yield* providerService.getCapabilities(activeSession.provider)).sessionModelSwitch; |
| 407 | const modelForTurn = sessionModelSwitch === "unsupported" ? activeSession?.model : input.model; | 386 | const requestedModelSelection = |
| 387 | input.modelSelection ?? threadModelSelections.get(input.threadId) ?? thread.modelSelection; | ||
| 388 | const modelForTurn = | ||
| 389 | sessionModelSwitch === "unsupported" | ||
| 390 | ? activeSession?.model !== undefined | ||
| 391 | ? { | ||
| 392 | ...requestedModelSelection, | ||
| 393 | model: activeSession.model, | ||
| 394 | } | ||
| 395 | : requestedModelSelection | ||
| 396 | : input.modelSelection; | ||
| 408 | 397 | ||
| 409 | yield* providerService.sendTurn({ | 398 | yield* providerService.sendTurn({ |
| 410 | threadId: input.threadId, | 399 | threadId: input.threadId, |
| 411 | ...(normalizedInput ? { input: normalizedInput } : {}), | 400 | ...(normalizedInput ? { input: normalizedInput } : {}), |
| 412 | ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), | 401 | ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), |
| 413 | ...(modelForTurn !== undefined ? { model: modelForTurn } : {}), | 402 | ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), |
| 414 | ...(input.modelOptions !== undefined ? { modelOptions: input.modelOptions } : {}), | ||
| 415 | ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), | 403 | ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), |
| 416 | }); | 404 | }); |
| 417 | }); | 405 | }); |
| ... | @@ -524,10 +512,8 @@ const make = Effect.gen(function* () { | ... | @@ -524,10 +512,8 @@ const make = Effect.gen(function* () { |
| 524 | threadId: event.payload.threadId, | 512 | threadId: event.payload.threadId, |
| 525 | messageText: message.text, | 513 | messageText: message.text, |
| 526 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | 514 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), |
| 527 | ...(event.payload.provider !== undefined ? { provider: event.payload.provider } : {}), | 515 | ...(event.payload.modelSelection !== undefined |
| 528 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), | 516 | ? { modelSelection: event.payload.modelSelection } |
| 529 | ...(event.payload.modelOptions !== undefined | ||
| 530 | ? { modelOptions: event.payload.modelOptions } | ||
| 531 | : {}), | 517 | : {}), |
| 532 | ...(event.payload.providerOptions !== undefined | 518 | ...(event.payload.providerOptions !== undefined |
| 533 | ? { providerOptions: event.payload.providerOptions } | 519 | ? { providerOptions: event.payload.providerOptions } |
| ... | @@ -698,12 +684,12 @@ const make = Effect.gen(function* () { | ... | @@ -698,12 +684,12 @@ const make = Effect.gen(function* () { |
| 698 | return; | 684 | return; |
| 699 | } | 685 | } |
| 700 | const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); | 686 | const cachedProviderOptions = threadProviderOptions.get(event.payload.threadId); |
| 701 | const cachedModelOptions = threadModelOptions.get(event.payload.threadId); | 687 | const cachedModelSelection = threadModelSelections.get(event.payload.threadId); |
| 702 | yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { | 688 | yield* ensureSessionForThread(event.payload.threadId, event.occurredAt, { |
| 703 | ...(cachedProviderOptions !== undefined | 689 | ...(cachedProviderOptions !== undefined |
| 704 | ? { providerOptions: cachedProviderOptions } | 690 | ? { providerOptions: cachedProviderOptions } |
| 705 | : {}), | 691 | : {}), |
| 706 | ...(cachedModelOptions !== undefined ? { modelOptions: cachedModelOptions } : {}), | 692 | ...(cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {}), |
| 707 | }); | 693 | }); |
| 708 | return; | 694 | return; |
| 709 | } | 695 | } |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+28-7| ... | @@ -187,7 +187,10 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -187,7 +187,10 @@ describe("ProviderRuntimeIngestion", () => { |
| 187 | projectId: asProjectId("project-1"), | 187 | projectId: asProjectId("project-1"), |
| 188 | title: "Provider Project", | 188 | title: "Provider Project", |
| 189 | workspaceRoot, | 189 | workspaceRoot, |
| 190 | defaultModel: "gpt-5-codex", | 190 | defaultModelSelection: { |
| 191 | provider: "codex", | ||
| 192 | model: "gpt-5-codex", | ||
| 193 | }, | ||
| 191 | createdAt, | 194 | createdAt, |
| 192 | }), | 195 | }), |
| 193 | ); | 196 | ); |
| ... | @@ -198,7 +201,10 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -198,7 +201,10 @@ describe("ProviderRuntimeIngestion", () => { |
| 198 | threadId: ThreadId.makeUnsafe("thread-1"), | 201 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 199 | projectId: asProjectId("project-1"), | 202 | projectId: asProjectId("project-1"), |
| 200 | title: "Thread", | 203 | title: "Thread", |
| 201 | model: "gpt-5-codex", | 204 | modelSelection: { |
| 205 | provider: "codex", | ||
| 206 | model: "gpt-5-codex", | ||
| 207 | }, | ||
| 202 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 208 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 203 | runtimeMode: "approval-required", | 209 | runtimeMode: "approval-required", |
| 204 | branch: null, | 210 | branch: null, |
| ... | @@ -724,7 +730,10 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -724,7 +730,10 @@ describe("ProviderRuntimeIngestion", () => { |
| 724 | threadId: sourceThreadId, | 730 | threadId: sourceThreadId, |
| 725 | projectId: asProjectId("project-1"), | 731 | projectId: asProjectId("project-1"), |
| 726 | title: "Plan Source", | 732 | title: "Plan Source", |
| 727 | model: "gpt-5-codex", | 733 | modelSelection: { |
| 734 | provider: "codex", | ||
| 735 | model: "gpt-5-codex", | ||
| 736 | }, | ||
| 728 | interactionMode: "plan", | 737 | interactionMode: "plan", |
| 729 | runtimeMode: "approval-required", | 738 | runtimeMode: "approval-required", |
| 730 | branch: null, | 739 | branch: null, |
| ... | @@ -756,7 +765,10 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -756,7 +765,10 @@ describe("ProviderRuntimeIngestion", () => { |
| 756 | threadId: targetThreadId, | 765 | threadId: targetThreadId, |
| 757 | projectId: asProjectId("project-1"), | 766 | projectId: asProjectId("project-1"), |
| 758 | title: "Plan Target", | 767 | title: "Plan Target", |
| 759 | model: "gpt-5-codex", | 768 | modelSelection: { |
| 769 | provider: "codex", | ||
| 770 | model: "gpt-5-codex", | ||
| 771 | }, | ||
| 760 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 772 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 761 | runtimeMode: "approval-required", | 773 | runtimeMode: "approval-required", |
| 762 | branch: null, | 774 | branch: null, |
| ... | @@ -905,7 +917,10 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -905,7 +917,10 @@ describe("ProviderRuntimeIngestion", () => { |
| 905 | threadId: sourceThreadId, | 917 | threadId: sourceThreadId, |
| 906 | projectId: asProjectId("project-1"), | 918 | projectId: asProjectId("project-1"), |
| 907 | title: "Plan Source", | 919 | title: "Plan Source", |
| 908 | model: "gpt-5-codex", | 920 | modelSelection: { |
| 921 | provider: "codex", | ||
| 922 | model: "gpt-5-codex", | ||
| 923 | }, | ||
| 909 | interactionMode: "plan", | 924 | interactionMode: "plan", |
| 910 | runtimeMode: "approval-required", | 925 | runtimeMode: "approval-required", |
| 911 | branch: null, | 926 | branch: null, |
| ... | @@ -1055,7 +1070,10 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1055,7 +1070,10 @@ describe("ProviderRuntimeIngestion", () => { |
| 1055 | threadId: sourceThreadId, | 1070 | threadId: sourceThreadId, |
| 1056 | projectId: asProjectId("project-1"), | 1071 | projectId: asProjectId("project-1"), |
| 1057 | title: "Plan Source", | 1072 | title: "Plan Source", |
| 1058 | model: "gpt-5-codex", | 1073 | modelSelection: { |
| 1074 | provider: "codex", | ||
| 1075 | model: "gpt-5-codex", | ||
| 1076 | }, | ||
| 1059 | interactionMode: "plan", | 1077 | interactionMode: "plan", |
| 1060 | runtimeMode: "approval-required", | 1078 | runtimeMode: "approval-required", |
| 1061 | branch: null, | 1079 | branch: null, |
| ... | @@ -1087,7 +1105,10 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -1087,7 +1105,10 @@ describe("ProviderRuntimeIngestion", () => { |
| 1087 | threadId: targetThreadId, | 1105 | threadId: targetThreadId, |
| 1088 | projectId: asProjectId("project-1"), | 1106 | projectId: asProjectId("project-1"), |
| 1089 | title: "Plan Target", | 1107 | title: "Plan Target", |
| 1090 | model: "gpt-5-codex", | 1108 | modelSelection: { |
| 1109 | provider: "codex", | ||
| 1110 | model: "gpt-5-codex", | ||
| 1111 | }, | ||
| 1091 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 1112 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 1092 | runtimeMode: "approval-required", | 1113 | runtimeMode: "approval-required", |
| 1093 | branch: null, | 1114 | branch: null, |
apps/server/src/orchestration/commandInvariants.test.ts+24-6| ... | @@ -28,7 +28,10 @@ const readModel: OrchestrationReadModel = { | ... | @@ -28,7 +28,10 @@ const readModel: OrchestrationReadModel = { |
| 28 | id: ProjectId.makeUnsafe("project-a"), | 28 | id: ProjectId.makeUnsafe("project-a"), |
| 29 | title: "Project A", | 29 | title: "Project A", |
| 30 | workspaceRoot: "/tmp/project-a", | 30 | workspaceRoot: "/tmp/project-a", |
| 31 | defaultModel: "gpt-5-codex", | 31 | defaultModelSelection: { |
| 32 | provider: "codex", | ||
| 33 | model: "gpt-5-codex", | ||
| 34 | }, | ||
| 32 | scripts: [], | 35 | scripts: [], |
| 33 | createdAt: now, | 36 | createdAt: now, |
| 34 | updatedAt: now, | 37 | updatedAt: now, |
| ... | @@ -38,7 +41,10 @@ const readModel: OrchestrationReadModel = { | ... | @@ -38,7 +41,10 @@ const readModel: OrchestrationReadModel = { |
| 38 | id: ProjectId.makeUnsafe("project-b"), | 41 | id: ProjectId.makeUnsafe("project-b"), |
| 39 | title: "Project B", | 42 | title: "Project B", |
| 40 | workspaceRoot: "/tmp/project-b", | 43 | workspaceRoot: "/tmp/project-b", |
| 41 | defaultModel: "gpt-5-codex", | 44 | defaultModelSelection: { |
| 45 | provider: "codex", | ||
| 46 | model: "gpt-5-codex", | ||
| 47 | }, | ||
| 42 | scripts: [], | 48 | scripts: [], |
| 43 | createdAt: now, | 49 | createdAt: now, |
| 44 | updatedAt: now, | 50 | updatedAt: now, |
| ... | @@ -50,7 +56,10 @@ const readModel: OrchestrationReadModel = { | ... | @@ -50,7 +56,10 @@ const readModel: OrchestrationReadModel = { |
| 50 | id: ThreadId.makeUnsafe("thread-1"), | 56 | id: ThreadId.makeUnsafe("thread-1"), |
| 51 | projectId: ProjectId.makeUnsafe("project-a"), | 57 | projectId: ProjectId.makeUnsafe("project-a"), |
| 52 | title: "Thread A", | 58 | title: "Thread A", |
| 53 | model: "gpt-5-codex", | 59 | modelSelection: { |
| 60 | provider: "codex", | ||
| 61 | model: "gpt-5-codex", | ||
| 62 | }, | ||
| 54 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 63 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 55 | runtimeMode: "full-access", | 64 | runtimeMode: "full-access", |
| 56 | branch: null, | 65 | branch: null, |
| ... | @@ -69,7 +78,10 @@ const readModel: OrchestrationReadModel = { | ... | @@ -69,7 +78,10 @@ const readModel: OrchestrationReadModel = { |
| 69 | id: ThreadId.makeUnsafe("thread-2"), | 78 | id: ThreadId.makeUnsafe("thread-2"), |
| 70 | projectId: ProjectId.makeUnsafe("project-b"), | 79 | projectId: ProjectId.makeUnsafe("project-b"), |
| 71 | title: "Thread B", | 80 | title: "Thread B", |
| 72 | model: "gpt-5-codex", | 81 | modelSelection: { |
| 82 | provider: "codex", | ||
| 83 | model: "gpt-5-codex", | ||
| 84 | }, | ||
| 73 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 85 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 74 | runtimeMode: "full-access", | 86 | runtimeMode: "full-access", |
| 75 | branch: null, | 87 | branch: null, |
| ... | @@ -144,7 +156,10 @@ describe("commandInvariants", () => { | ... | @@ -144,7 +156,10 @@ describe("commandInvariants", () => { |
| 144 | threadId: ThreadId.makeUnsafe("thread-3"), | 156 | threadId: ThreadId.makeUnsafe("thread-3"), |
| 145 | projectId: ProjectId.makeUnsafe("project-a"), | 157 | projectId: ProjectId.makeUnsafe("project-a"), |
| 146 | title: "new", | 158 | title: "new", |
| 147 | model: "gpt-5-codex", | 159 | modelSelection: { |
| 160 | provider: "codex", | ||
| 161 | model: "gpt-5-codex", | ||
| 162 | }, | ||
| 148 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 163 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 149 | runtimeMode: "full-access", | 164 | runtimeMode: "full-access", |
| 150 | branch: null, | 165 | branch: null, |
| ... | @@ -165,7 +180,10 @@ describe("commandInvariants", () => { | ... | @@ -165,7 +180,10 @@ describe("commandInvariants", () => { |
| 165 | threadId: ThreadId.makeUnsafe("thread-1"), | 180 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 166 | projectId: ProjectId.makeUnsafe("project-a"), | 181 | projectId: ProjectId.makeUnsafe("project-a"), |
| 167 | title: "dup", | 182 | title: "dup", |
| 168 | model: "gpt-5-codex", | 183 | modelSelection: { |
| 184 | provider: "codex", | ||
| 185 | model: "gpt-5-codex", | ||
| 186 | }, | ||
| 169 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 187 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 170 | runtimeMode: "full-access", | 188 | runtimeMode: "full-access", |
| 171 | branch: null, | 189 | branch: null, |
apps/server/src/orchestration/decider.projectScripts.test.ts+24-15| ... | @@ -59,7 +59,7 @@ describe("decider project scripts", () => { | ... | @@ -59,7 +59,7 @@ describe("decider project scripts", () => { |
| 59 | projectId: asProjectId("project-scripts"), | 59 | projectId: asProjectId("project-scripts"), |
| 60 | title: "Scripts", | 60 | title: "Scripts", |
| 61 | workspaceRoot: "/tmp/scripts", | 61 | workspaceRoot: "/tmp/scripts", |
| 62 | defaultModel: null, | 62 | defaultModelSelection: null, |
| 63 | scripts: [], | 63 | scripts: [], |
| 64 | createdAt: now, | 64 | createdAt: now, |
| 65 | updatedAt: now, | 65 | updatedAt: now, |
| ... | @@ -113,7 +113,7 @@ describe("decider project scripts", () => { | ... | @@ -113,7 +113,7 @@ describe("decider project scripts", () => { |
| 113 | projectId: asProjectId("project-1"), | 113 | projectId: asProjectId("project-1"), |
| 114 | title: "Project", | 114 | title: "Project", |
| 115 | workspaceRoot: "/tmp/project", | 115 | workspaceRoot: "/tmp/project", |
| 116 | defaultModel: null, | 116 | defaultModelSelection: null, |
| 117 | scripts: [], | 117 | scripts: [], |
| 118 | createdAt: now, | 118 | createdAt: now, |
| 119 | updatedAt: now, | 119 | updatedAt: now, |
| ... | @@ -136,7 +136,10 @@ describe("decider project scripts", () => { | ... | @@ -136,7 +136,10 @@ describe("decider project scripts", () => { |
| 136 | threadId: ThreadId.makeUnsafe("thread-1"), | 136 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 137 | projectId: asProjectId("project-1"), | 137 | projectId: asProjectId("project-1"), |
| 138 | title: "Thread", | 138 | title: "Thread", |
| 139 | model: "gpt-5-codex", | 139 | modelSelection: { |
| 140 | provider: "codex", | ||
| 141 | model: "gpt-5-codex", | ||
| 142 | }, | ||
| 140 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 143 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 141 | runtimeMode: "approval-required", | 144 | runtimeMode: "approval-required", |
| 142 | branch: null, | 145 | branch: null, |
| ... | @@ -159,10 +162,10 @@ describe("decider project scripts", () => { | ... | @@ -159,10 +162,10 @@ describe("decider project scripts", () => { |
| 159 | text: "hello", | 162 | text: "hello", |
| 160 | attachments: [], | 163 | attachments: [], |
| 161 | }, | 164 | }, |
| 162 | provider: "codex", | 165 | modelSelection: { |
| 163 | model: "gpt-5.3-codex", | 166 | provider: "codex", |
| 164 | modelOptions: { | 167 | model: "gpt-5.3-codex", |
| 165 | codex: { | 168 | options: { |
| 166 | reasoningEffort: "high", | 169 | reasoningEffort: "high", |
| 167 | fastMode: true, | 170 | fastMode: true, |
| 168 | }, | 171 | }, |
| ... | @@ -189,10 +192,10 @@ describe("decider project scripts", () => { | ... | @@ -189,10 +192,10 @@ describe("decider project scripts", () => { |
| 189 | expect(turnStartEvent.payload).toMatchObject({ | 192 | expect(turnStartEvent.payload).toMatchObject({ |
| 190 | threadId: ThreadId.makeUnsafe("thread-1"), | 193 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 191 | messageId: asMessageId("message-user-1"), | 194 | messageId: asMessageId("message-user-1"), |
| 192 | provider: "codex", | 195 | modelSelection: { |
| 193 | model: "gpt-5.3-codex", | 196 | provider: "codex", |
| 194 | modelOptions: { | 197 | model: "gpt-5.3-codex", |
| 195 | codex: { | 198 | options: { |
| 196 | reasoningEffort: "high", | 199 | reasoningEffort: "high", |
| 197 | fastMode: true, | 200 | fastMode: true, |
| 198 | }, | 201 | }, |
| ... | @@ -220,7 +223,7 @@ describe("decider project scripts", () => { | ... | @@ -220,7 +223,7 @@ describe("decider project scripts", () => { |
| 220 | projectId: asProjectId("project-1"), | 223 | projectId: asProjectId("project-1"), |
| 221 | title: "Project", | 224 | title: "Project", |
| 222 | workspaceRoot: "/tmp/project", | 225 | workspaceRoot: "/tmp/project", |
| 223 | defaultModel: null, | 226 | defaultModelSelection: null, |
| 224 | scripts: [], | 227 | scripts: [], |
| 225 | createdAt: now, | 228 | createdAt: now, |
| 226 | updatedAt: now, | 229 | updatedAt: now, |
| ... | @@ -243,7 +246,10 @@ describe("decider project scripts", () => { | ... | @@ -243,7 +246,10 @@ describe("decider project scripts", () => { |
| 243 | threadId: ThreadId.makeUnsafe("thread-1"), | 246 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 244 | projectId: asProjectId("project-1"), | 247 | projectId: asProjectId("project-1"), |
| 245 | title: "Thread", | 248 | title: "Thread", |
| 246 | model: "gpt-5-codex", | 249 | modelSelection: { |
| 250 | provider: "codex", | ||
| 251 | model: "gpt-5-codex", | ||
| 252 | }, | ||
| 247 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 253 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 248 | runtimeMode: "full-access", | 254 | runtimeMode: "full-access", |
| 249 | branch: null, | 255 | branch: null, |
| ... | @@ -299,7 +305,7 @@ describe("decider project scripts", () => { | ... | @@ -299,7 +305,7 @@ describe("decider project scripts", () => { |
| 299 | projectId: asProjectId("project-1"), | 305 | projectId: asProjectId("project-1"), |
| 300 | title: "Project", | 306 | title: "Project", |
| 301 | workspaceRoot: "/tmp/project", | 307 | workspaceRoot: "/tmp/project", |
| 302 | defaultModel: null, | 308 | defaultModelSelection: null, |
| 303 | scripts: [], | 309 | scripts: [], |
| 304 | createdAt: now, | 310 | createdAt: now, |
| 305 | updatedAt: now, | 311 | updatedAt: now, |
| ... | @@ -322,7 +328,10 @@ describe("decider project scripts", () => { | ... | @@ -322,7 +328,10 @@ describe("decider project scripts", () => { |
| 322 | threadId: ThreadId.makeUnsafe("thread-1"), | 328 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 323 | projectId: asProjectId("project-1"), | 329 | projectId: asProjectId("project-1"), |
| 324 | title: "Thread", | 330 | title: "Thread", |
| 325 | model: "gpt-5-codex", | 331 | modelSelection: { |
| 332 | provider: "codex", | ||
| 333 | model: "gpt-5-codex", | ||
| 334 | }, | ||
| 326 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 335 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 327 | runtimeMode: "approval-required", | 336 | runtimeMode: "approval-required", |
| 328 | branch: null, | 337 | branch: null, |
apps/server/src/orchestration/decider.ts+11-7| ... | @@ -77,7 +77,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -77,7 +77,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 77 | projectId: command.projectId, | 77 | projectId: command.projectId, |
| 78 | title: command.title, | 78 | title: command.title, |
| 79 | workspaceRoot: command.workspaceRoot, | 79 | workspaceRoot: command.workspaceRoot, |
| 80 | defaultModel: command.defaultModel ?? null, | 80 | defaultModelSelection: command.defaultModelSelection ?? null, |
| 81 | scripts: [], | 81 | scripts: [], |
| 82 | createdAt: command.createdAt, | 82 | createdAt: command.createdAt, |
| 83 | updatedAt: command.createdAt, | 83 | updatedAt: command.createdAt, |
| ... | @@ -104,7 +104,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -104,7 +104,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 104 | projectId: command.projectId, | 104 | projectId: command.projectId, |
| 105 | ...(command.title !== undefined ? { title: command.title } : {}), | 105 | ...(command.title !== undefined ? { title: command.title } : {}), |
| 106 | ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), | 106 | ...(command.workspaceRoot !== undefined ? { workspaceRoot: command.workspaceRoot } : {}), |
| 107 | ...(command.defaultModel !== undefined ? { defaultModel: command.defaultModel } : {}), | 107 | ...(command.defaultModelSelection !== undefined |
| 108 | ? { defaultModelSelection: command.defaultModelSelection } | ||
| 109 | : {}), | ||
| 108 | ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), | 110 | ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), |
| 109 | updatedAt: occurredAt, | 111 | updatedAt: occurredAt, |
| 110 | }, | 112 | }, |
| ... | @@ -156,7 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -156,7 +158,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 156 | threadId: command.threadId, | 158 | threadId: command.threadId, |
| 157 | projectId: command.projectId, | 159 | projectId: command.projectId, |
| 158 | title: command.title, | 160 | title: command.title, |
| 159 | model: command.model, | 161 | modelSelection: command.modelSelection, |
| 160 | runtimeMode: command.runtimeMode, | 162 | runtimeMode: command.runtimeMode, |
| 161 | interactionMode: command.interactionMode, | 163 | interactionMode: command.interactionMode, |
| 162 | branch: command.branch, | 164 | branch: command.branch, |
| ... | @@ -207,7 +209,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -207,7 +209,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 207 | payload: { | 209 | payload: { |
| 208 | threadId: command.threadId, | 210 | threadId: command.threadId, |
| 209 | ...(command.title !== undefined ? { title: command.title } : {}), | 211 | ...(command.title !== undefined ? { title: command.title } : {}), |
| 210 | ...(command.model !== undefined ? { model: command.model } : {}), | 212 | ...(command.modelSelection !== undefined |
| 213 | ? { modelSelection: command.modelSelection } | ||
| 214 | : {}), | ||
| 211 | ...(command.branch !== undefined ? { branch: command.branch } : {}), | 215 | ...(command.branch !== undefined ? { branch: command.branch } : {}), |
| 212 | ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), | 216 | ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), |
| 213 | updatedAt: occurredAt, | 217 | updatedAt: occurredAt, |
| ... | @@ -323,9 +327,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" | ... | @@ -323,9 +327,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" |
| 323 | payload: { | 327 | payload: { |
| 324 | threadId: command.threadId, | 328 | threadId: command.threadId, |
| 325 | messageId: command.message.messageId, | 329 | messageId: command.message.messageId, |
| 326 | ...(command.provider !== undefined ? { provider: command.provider } : {}), | 330 | ...(command.modelSelection !== undefined |
| 327 | ...(command.model !== undefined ? { model: command.model } : {}), | 331 | ? { modelSelection: command.modelSelection } |
| 328 | ...(command.modelOptions !== undefined ? { modelOptions: command.modelOptions } : {}), | 332 | : {}), |
| 329 | ...(command.providerOptions !== undefined | 333 | ...(command.providerOptions !== undefined |
| 330 | ? { providerOptions: command.providerOptions } | 334 | ? { providerOptions: command.providerOptions } |
| 331 | : {}), | 335 | : {}), |
apps/server/src/orchestration/projector.test.ts+36-9| ... | @@ -56,7 +56,10 @@ describe("orchestration projector", () => { | ... | @@ -56,7 +56,10 @@ describe("orchestration projector", () => { |
| 56 | threadId: "thread-1", | 56 | threadId: "thread-1", |
| 57 | projectId: "project-1", | 57 | projectId: "project-1", |
| 58 | title: "demo", | 58 | title: "demo", |
| 59 | model: "gpt-5-codex", | 59 | modelSelection: { |
| 60 | provider: "codex", | ||
| 61 | model: "gpt-5-codex", | ||
| 62 | }, | ||
| 60 | runtimeMode: "full-access", | 63 | runtimeMode: "full-access", |
| 61 | branch: null, | 64 | branch: null, |
| 62 | worktreePath: null, | 65 | worktreePath: null, |
| ... | @@ -73,7 +76,10 @@ describe("orchestration projector", () => { | ... | @@ -73,7 +76,10 @@ describe("orchestration projector", () => { |
| 73 | id: "thread-1", | 76 | id: "thread-1", |
| 74 | projectId: "project-1", | 77 | projectId: "project-1", |
| 75 | title: "demo", | 78 | title: "demo", |
| 76 | model: "gpt-5-codex", | 79 | modelSelection: { |
| 80 | provider: "codex", | ||
| 81 | model: "gpt-5-codex", | ||
| 82 | }, | ||
| 77 | runtimeMode: "full-access", | 83 | runtimeMode: "full-access", |
| 78 | interactionMode: "default", | 84 | interactionMode: "default", |
| 79 | branch: null, | 85 | branch: null, |
| ... | @@ -110,7 +116,10 @@ describe("orchestration projector", () => { | ... | @@ -110,7 +116,10 @@ describe("orchestration projector", () => { |
| 110 | // missing required threadId | 116 | // missing required threadId |
| 111 | projectId: "project-1", | 117 | projectId: "project-1", |
| 112 | title: "demo", | 118 | title: "demo", |
| 113 | model: "gpt-5-codex", | 119 | modelSelection: { |
| 120 | provider: "codex", | ||
| 121 | model: "gpt-5-codex", | ||
| 122 | }, | ||
| 114 | branch: null, | 123 | branch: null, |
| 115 | worktreePath: null, | 124 | worktreePath: null, |
| 116 | createdAt: now, | 125 | createdAt: now, |
| ... | @@ -170,7 +179,10 @@ describe("orchestration projector", () => { | ... | @@ -170,7 +179,10 @@ describe("orchestration projector", () => { |
| 170 | threadId: "thread-1", | 179 | threadId: "thread-1", |
| 171 | projectId: "project-1", | 180 | projectId: "project-1", |
| 172 | title: "demo", | 181 | title: "demo", |
| 173 | model: "gpt-5.3-codex", | 182 | modelSelection: { |
| 183 | provider: "codex", | ||
| 184 | model: "gpt-5.3-codex", | ||
| 185 | }, | ||
| 174 | runtimeMode: "full-access", | 186 | runtimeMode: "full-access", |
| 175 | branch: null, | 187 | branch: null, |
| 176 | worktreePath: null, | 188 | worktreePath: null, |
| ... | @@ -233,7 +245,10 @@ describe("orchestration projector", () => { | ... | @@ -233,7 +245,10 @@ describe("orchestration projector", () => { |
| 233 | threadId: "thread-1", | 245 | threadId: "thread-1", |
| 234 | projectId: "project-1", | 246 | projectId: "project-1", |
| 235 | title: "demo", | 247 | title: "demo", |
| 236 | model: "gpt-5.3-codex", | 248 | modelSelection: { |
| 249 | provider: "codex", | ||
| 250 | model: "gpt-5.3-codex", | ||
| 251 | }, | ||
| 237 | runtimeMode: "full-access", | 252 | runtimeMode: "full-access", |
| 238 | branch: null, | 253 | branch: null, |
| 239 | worktreePath: null, | 254 | worktreePath: null, |
| ... | @@ -287,7 +302,10 @@ describe("orchestration projector", () => { | ... | @@ -287,7 +302,10 @@ describe("orchestration projector", () => { |
| 287 | threadId: "thread-1", | 302 | threadId: "thread-1", |
| 288 | projectId: "project-1", | 303 | projectId: "project-1", |
| 289 | title: "demo", | 304 | title: "demo", |
| 290 | model: "gpt-5.3-codex", | 305 | modelSelection: { |
| 306 | provider: "codex", | ||
| 307 | model: "gpt-5.3-codex", | ||
| 308 | }, | ||
| 291 | runtimeMode: "full-access", | 309 | runtimeMode: "full-access", |
| 292 | branch: null, | 310 | branch: null, |
| 293 | worktreePath: null, | 311 | worktreePath: null, |
| ... | @@ -371,7 +389,10 @@ describe("orchestration projector", () => { | ... | @@ -371,7 +389,10 @@ describe("orchestration projector", () => { |
| 371 | threadId: "thread-1", | 389 | threadId: "thread-1", |
| 372 | projectId: "project-1", | 390 | projectId: "project-1", |
| 373 | title: "demo", | 391 | title: "demo", |
| 374 | model: "gpt-5.3-codex", | 392 | modelSelection: { |
| 393 | provider: "codex", | ||
| 394 | model: "gpt-5.3-codex", | ||
| 395 | }, | ||
| 375 | runtimeMode: "full-access", | 396 | runtimeMode: "full-access", |
| 376 | branch: null, | 397 | branch: null, |
| 377 | worktreePath: null, | 398 | worktreePath: null, |
| ... | @@ -583,7 +604,10 @@ describe("orchestration projector", () => { | ... | @@ -583,7 +604,10 @@ describe("orchestration projector", () => { |
| 583 | threadId: "thread-revert", | 604 | threadId: "thread-revert", |
| 584 | projectId: "project-1", | 605 | projectId: "project-1", |
| 585 | title: "demo", | 606 | title: "demo", |
| 586 | model: "gpt-5.3-codex", | 607 | modelSelection: { |
| 608 | provider: "codex", | ||
| 609 | model: "gpt-5.3-codex", | ||
| 610 | }, | ||
| 587 | runtimeMode: "full-access", | 611 | runtimeMode: "full-access", |
| 588 | branch: null, | 612 | branch: null, |
| 589 | worktreePath: null, | 613 | worktreePath: null, |
| ... | @@ -733,7 +757,10 @@ describe("orchestration projector", () => { | ... | @@ -733,7 +757,10 @@ describe("orchestration projector", () => { |
| 733 | threadId: "thread-capped", | 757 | threadId: "thread-capped", |
| 734 | projectId: "project-1", | 758 | projectId: "project-1", |
| 735 | title: "capped", | 759 | title: "capped", |
| 736 | model: "gpt-5-codex", | 760 | modelSelection: { |
| 761 | provider: "codex", | ||
| 762 | model: "gpt-5-codex", | ||
| 763 | }, | ||
| 737 | runtimeMode: "full-access", | 764 | runtimeMode: "full-access", |
| 738 | branch: null, | 765 | branch: null, |
| 739 | worktreePath: null, | 766 | worktreePath: null, |
apps/server/src/orchestration/projector.ts+7-5| ... | @@ -181,7 +181,7 @@ export function projectEvent( | ... | @@ -181,7 +181,7 @@ export function projectEvent( |
| 181 | id: payload.projectId, | 181 | id: payload.projectId, |
| 182 | title: payload.title, | 182 | title: payload.title, |
| 183 | workspaceRoot: payload.workspaceRoot, | 183 | workspaceRoot: payload.workspaceRoot, |
| 184 | defaultModel: payload.defaultModel, | 184 | defaultModelSelection: payload.defaultModelSelection, |
| 185 | scripts: payload.scripts, | 185 | scripts: payload.scripts, |
| 186 | createdAt: payload.createdAt, | 186 | createdAt: payload.createdAt, |
| 187 | updatedAt: payload.updatedAt, | 187 | updatedAt: payload.updatedAt, |
| ... | @@ -211,8 +211,8 @@ export function projectEvent( | ... | @@ -211,8 +211,8 @@ export function projectEvent( |
| 211 | ...(payload.workspaceRoot !== undefined | 211 | ...(payload.workspaceRoot !== undefined |
| 212 | ? { workspaceRoot: payload.workspaceRoot } | 212 | ? { workspaceRoot: payload.workspaceRoot } |
| 213 | : {}), | 213 | : {}), |
| 214 | ...(payload.defaultModel !== undefined | 214 | ...(payload.defaultModelSelection !== undefined |
| 215 | ? { defaultModel: payload.defaultModel } | 215 | ? { defaultModelSelection: payload.defaultModelSelection } |
| 216 | : {}), | 216 | : {}), |
| 217 | ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), | 217 | ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), |
| 218 | updatedAt: payload.updatedAt, | 218 | updatedAt: payload.updatedAt, |
| ... | @@ -252,7 +252,7 @@ export function projectEvent( | ... | @@ -252,7 +252,7 @@ export function projectEvent( |
| 252 | id: payload.threadId, | 252 | id: payload.threadId, |
| 253 | projectId: payload.projectId, | 253 | projectId: payload.projectId, |
| 254 | title: payload.title, | 254 | title: payload.title, |
| 255 | model: payload.model, | 255 | modelSelection: payload.modelSelection, |
| 256 | runtimeMode: payload.runtimeMode, | 256 | runtimeMode: payload.runtimeMode, |
| 257 | interactionMode: payload.interactionMode, | 257 | interactionMode: payload.interactionMode, |
| 258 | branch: payload.branch, | 258 | branch: payload.branch, |
| ... | @@ -295,7 +295,9 @@ export function projectEvent( | ... | @@ -295,7 +295,9 @@ export function projectEvent( |
| 295 | ...nextBase, | 295 | ...nextBase, |
| 296 | threads: updateThread(nextBase.threads, payload.threadId, { | 296 | threads: updateThread(nextBase.threads, payload.threadId, { |
| 297 | ...(payload.title !== undefined ? { title: payload.title } : {}), | 297 | ...(payload.title !== undefined ? { title: payload.title } : {}), |
| 298 | ...(payload.model !== undefined ? { model: payload.model } : {}), | 298 | ...(payload.modelSelection !== undefined |
| 299 | ? { modelSelection: payload.modelSelection } | ||
| 300 | : {}), | ||
| 299 | ...(payload.branch !== undefined ? { branch: payload.branch } : {}), | 301 | ...(payload.branch !== undefined ? { branch: payload.branch } : {}), |
| 300 | ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), | 302 | ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), |
| 301 | updatedAt: payload.updatedAt, | 303 | updatedAt: payload.updatedAt, |
apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+1-1| ... | @@ -35,7 +35,7 @@ layer("OrchestrationEventStore", (it) => { | ... | @@ -35,7 +35,7 @@ layer("OrchestrationEventStore", (it) => { |
| 35 | projectId: ProjectId.makeUnsafe("project-roundtrip"), | 35 | projectId: ProjectId.makeUnsafe("project-roundtrip"), |
| 36 | title: "Roundtrip Project", | 36 | title: "Roundtrip Project", |
| 37 | workspaceRoot: "/tmp/project-roundtrip", | 37 | workspaceRoot: "/tmp/project-roundtrip", |
| 38 | defaultModel: null, | 38 | defaultModelSelection: null, |
| 39 | scripts: [], | 39 | scripts: [], |
| 40 | createdAt: now, | 40 | createdAt: now, |
| 41 | updatedAt: now, | 41 | updatedAt: now, |
apps/server/src/persistence/Layers/ProjectionProjects.ts+47-75| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; |
| 3 | import { Effect, Layer, Option, Schema, Struct } from "effect"; | 3 | import { Effect, Layer, Schema, Struct } from "effect"; |
| 4 | |||
| 5 | import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; | ||
| 6 | 4 | ||
| 5 | import { ModelSelection, ProjectScript } from "@t3tools/contracts"; | ||
| 6 | import { toPersistenceSqlError } from "../Errors.ts"; | ||
| 7 | import { | 7 | import { |
| 8 | DeleteProjectionProjectInput, | 8 | DeleteProjectionProjectInput, |
| 9 | GetProjectionProjectInput, | 9 | GetProjectionProjectInput, |
| ... | @@ -11,69 +11,64 @@ import { | ... | @@ -11,69 +11,64 @@ import { |
| 11 | ProjectionProjectRepository, | 11 | ProjectionProjectRepository, |
| 12 | type ProjectionProjectRepositoryShape, | 12 | type ProjectionProjectRepositoryShape, |
| 13 | } from "../Services/ProjectionProjects.ts"; | 13 | } from "../Services/ProjectionProjects.ts"; |
| 14 | import { ProjectScript } from "@t3tools/contracts"; | ||
| 15 | 14 | ||
| 16 | // Makes sure that the scripts are parsed from the JSON string the DB returns | 15 | const ProjectionProjectDbRow = ProjectionProject.mapFields( |
| 17 | const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( | 16 | Struct.assign({ |
| 18 | Struct.assign({ scripts: Schema.fromJsonString(Schema.Array(ProjectScript)) }), | 17 | defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), |
| 18 | scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), | ||
| 19 | }), | ||
| 19 | ); | 20 | ); |
| 20 | 21 | type ProjectionProjectDbRow = typeof ProjectionProjectDbRow.Type; | |
| 21 | function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { | ||
| 22 | return (cause: unknown) => | ||
| 23 | Schema.isSchemaError(cause) | ||
| 24 | ? toPersistenceDecodeError(decodeOperation)(cause) | ||
| 25 | : toPersistenceSqlError(sqlOperation)(cause); | ||
| 26 | } | ||
| 27 | 22 | ||
| 28 | const makeProjectionProjectRepository = Effect.gen(function* () { | 23 | const makeProjectionProjectRepository = Effect.gen(function* () { |
| 29 | const sql = yield* SqlClient.SqlClient; | 24 | const sql = yield* SqlClient.SqlClient; |
| 30 | 25 | ||
| 31 | const upsertProjectionProjectRow = SqlSchema.void({ | 26 | const upsertProjectionProjectRow = SqlSchema.void({ |
| 32 | Request: ProjectionProjectDbRowSchema, | 27 | Request: ProjectionProject, |
| 33 | execute: (row) => | 28 | execute: (row) => |
| 34 | sql` | 29 | sql` |
| 35 | INSERT INTO projection_projects ( | 30 | INSERT INTO projection_projects ( |
| 36 | project_id, | 31 | project_id, |
| 37 | title, | 32 | title, |
| 38 | workspace_root, | 33 | workspace_root, |
| 39 | default_model, | 34 | default_model_selection_json, |
| 40 | scripts_json, | 35 | scripts_json, |
| 41 | created_at, | 36 | created_at, |
| 42 | updated_at, | 37 | updated_at, |
| 43 | deleted_at | 38 | deleted_at |
| 44 | ) | 39 | ) |
| 45 | VALUES ( | 40 | VALUES ( |
| 46 | ${row.projectId}, | 41 | ${row.projectId}, |
| 47 | ${row.title}, | 42 | ${row.title}, |
| 48 | ${row.workspaceRoot}, | 43 | ${row.workspaceRoot}, |
| 49 | ${row.defaultModel}, | 44 | ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, |
| 50 | ${row.scripts}, | 45 | ${JSON.stringify(row.scripts)}, |
| 51 | ${row.createdAt}, | 46 | ${row.createdAt}, |
| 52 | ${row.updatedAt}, | 47 | ${row.updatedAt}, |
| 53 | ${row.deletedAt} | 48 | ${row.deletedAt} |
| 54 | ) | 49 | ) |
| 55 | ON CONFLICT (project_id) | 50 | ON CONFLICT (project_id) |
| 56 | DO UPDATE SET | 51 | DO UPDATE SET |
| 57 | title = excluded.title, | 52 | title = excluded.title, |
| 58 | workspace_root = excluded.workspace_root, | 53 | workspace_root = excluded.workspace_root, |
| 59 | default_model = excluded.default_model, | 54 | default_model_selection_json = excluded.default_model_selection_json, |
| 60 | scripts_json = excluded.scripts_json, | 55 | scripts_json = excluded.scripts_json, |
| 61 | created_at = excluded.created_at, | 56 | created_at = excluded.created_at, |
| 62 | updated_at = excluded.updated_at, | 57 | updated_at = excluded.updated_at, |
| 63 | deleted_at = excluded.deleted_at | 58 | deleted_at = excluded.deleted_at |
| 64 | `, | 59 | `, |
| 65 | }); | 60 | }); |
| 66 | 61 | ||
| 67 | const getProjectionProjectRow = SqlSchema.findOneOption({ | 62 | const getProjectionProjectRow = SqlSchema.findOneOption({ |
| 68 | Request: GetProjectionProjectInput, | 63 | Request: GetProjectionProjectInput, |
| 69 | Result: ProjectionProjectDbRowSchema, | 64 | Result: ProjectionProjectDbRow, |
| 70 | execute: ({ projectId }) => | 65 | execute: ({ projectId }) => |
| 71 | sql` | 66 | sql` |
| 72 | SELECT | 67 | SELECT |
| 73 | project_id AS "projectId", | 68 | project_id AS "projectId", |
| 74 | title, | 69 | title, |
| 75 | workspace_root AS "workspaceRoot", | 70 | workspace_root AS "workspaceRoot", |
| 76 | default_model AS "defaultModel", | 71 | default_model_selection_json AS "defaultModelSelection", |
| 77 | scripts_json AS "scripts", | 72 | scripts_json AS "scripts", |
| 78 | created_at AS "createdAt", | 73 | created_at AS "createdAt", |
| 79 | updated_at AS "updatedAt", | 74 | updated_at AS "updatedAt", |
| ... | @@ -85,14 +80,14 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -85,14 +80,14 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 85 | 80 | ||
| 86 | const listProjectionProjectRows = SqlSchema.findAll({ | 81 | const listProjectionProjectRows = SqlSchema.findAll({ |
| 87 | Request: Schema.Void, | 82 | Request: Schema.Void, |
| 88 | Result: ProjectionProjectDbRowSchema, | 83 | Result: ProjectionProjectDbRow, |
| 89 | execute: () => | 84 | execute: () => |
| 90 | sql` | 85 | sql` |
| 91 | SELECT | 86 | SELECT |
| 92 | project_id AS "projectId", | 87 | project_id AS "projectId", |
| 93 | title, | 88 | title, |
| 94 | workspace_root AS "workspaceRoot", | 89 | workspace_root AS "workspaceRoot", |
| 95 | default_model AS "defaultModel", | 90 | default_model_selection_json AS "defaultModelSelection", |
| 96 | scripts_json AS "scripts", | 91 | scripts_json AS "scripts", |
| 97 | created_at AS "createdAt", | 92 | created_at AS "createdAt", |
| 98 | updated_at AS "updatedAt", | 93 | updated_at AS "updatedAt", |
| ... | @@ -113,40 +108,17 @@ const makeProjectionProjectRepository = Effect.gen(function* () { | ... | @@ -113,40 +108,17 @@ const makeProjectionProjectRepository = Effect.gen(function* () { |
| 113 | 108 | ||
| 114 | const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) => | 109 | const upsert: ProjectionProjectRepositoryShape["upsert"] = (row) => |
| 115 | upsertProjectionProjectRow(row).pipe( | 110 | upsertProjectionProjectRow(row).pipe( |
| 116 | Effect.mapError( | 111 | Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.upsert:query")), |
| 117 | toPersistenceSqlOrDecodeError( | ||
| 118 | "ProjectionProjectRepository.upsert:query", | ||
| 119 | "ProjectionProjectRepository.upsert:encodeRequest", | ||
| 120 | ), | ||
| 121 | ), | ||
| 122 | ); | 112 | ); |
| 123 | 113 | ||
| 124 | const getById: ProjectionProjectRepositoryShape["getById"] = (input) => | 114 | const getById: ProjectionProjectRepositoryShape["getById"] = (input) => |
| 125 | getProjectionProjectRow(input).pipe( | 115 | getProjectionProjectRow(input).pipe( |
| 126 | Effect.mapError( | 116 | Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.getById:query")), |
| 127 | toPersistenceSqlOrDecodeError( | ||
| 128 | "ProjectionProjectRepository.getById:query", | ||
| 129 | "ProjectionProjectRepository.getById:decodeRow", | ||
| 130 | ), | ||
| 131 | ), | ||
| 132 | Effect.flatMap((rowOption) => | ||
| 133 | Option.match(rowOption, { | ||
| 134 | onNone: () => Effect.succeed(Option.none()), | ||
| 135 | onSome: (row) => | ||
| 136 | Effect.succeed(Option.some(row as Schema.Schema.Type<typeof ProjectionProject>)), | ||
| 137 | }), | ||
| 138 | ), | ||
| 139 | ); | 117 | ); |
| 140 | 118 | ||
| 141 | const listAll: ProjectionProjectRepositoryShape["listAll"] = () => | 119 | const listAll: ProjectionProjectRepositoryShape["listAll"] = () => |
| 142 | listProjectionProjectRows().pipe( | 120 | listProjectionProjectRows().pipe( |
| 143 | Effect.mapError( | 121 | Effect.mapError(toPersistenceSqlError("ProjectionProjectRepository.listAll:query")), |
| 144 | toPersistenceSqlOrDecodeError( | ||
| 145 | "ProjectionProjectRepository.listAll:query", | ||
| 146 | "ProjectionProjectRepository.listAll:decodeRows", | ||
| 147 | ), | ||
| 148 | ), | ||
| 149 | Effect.map((rows) => rows as ReadonlyArray<Schema.Schema.Type<typeof ProjectionProject>>), | ||
| 150 | ); | 122 | ); |
| 151 | 123 | ||
| 152 | const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) => | 124 | const deleteById: ProjectionProjectRepositoryShape["deleteById"] = (input) => |
apps/server/src/persistence/Layers/ProjectionRepositories.test.ts created+122| ... | @@ -0,0 +1,122 @@ | ||
| 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,6 +1,6 @@ |
| 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | 1 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 2 | import * as SqlSchema from "effect/unstable/sql/SqlSchema"; | 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 | import { toPersistenceSqlError } from "../Errors.ts"; | 5 | import { toPersistenceSqlError } from "../Errors.ts"; |
| 6 | import { | 6 | import { |
| ... | @@ -11,6 +11,14 @@ import { | ... | @@ -11,6 +11,14 @@ import { |
| 11 | ProjectionThreadRepository, | 11 | ProjectionThreadRepository, |
| 12 | type ProjectionThreadRepositoryShape, | 12 | type ProjectionThreadRepositoryShape, |
| 13 | } from "../Services/ProjectionThreads.ts"; | 13 | } from "../Services/ProjectionThreads.ts"; |
| 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 | const makeProjectionThreadRepository = Effect.gen(function* () { | 23 | const makeProjectionThreadRepository = Effect.gen(function* () { |
| 16 | const sql = yield* SqlClient.SqlClient; | 24 | const sql = yield* SqlClient.SqlClient; |
| ... | @@ -23,7 +31,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -23,7 +31,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 23 | thread_id, | 31 | thread_id, |
| 24 | project_id, | 32 | project_id, |
| 25 | title, | 33 | title, |
| 26 | model, | 34 | model_selection_json, |
| 27 | runtime_mode, | 35 | runtime_mode, |
| 28 | interaction_mode, | 36 | interaction_mode, |
| 29 | branch, | 37 | branch, |
| ... | @@ -37,7 +45,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -37,7 +45,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 37 | ${row.threadId}, | 45 | ${row.threadId}, |
| 38 | ${row.projectId}, | 46 | ${row.projectId}, |
| 39 | ${row.title}, | 47 | ${row.title}, |
| 40 | ${row.model}, | 48 | ${JSON.stringify(row.modelSelection)}, |
| 41 | ${row.runtimeMode}, | 49 | ${row.runtimeMode}, |
| 42 | ${row.interactionMode}, | 50 | ${row.interactionMode}, |
| 43 | ${row.branch}, | 51 | ${row.branch}, |
| ... | @@ -51,7 +59,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -51,7 +59,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 51 | DO UPDATE SET | 59 | DO UPDATE SET |
| 52 | project_id = excluded.project_id, | 60 | project_id = excluded.project_id, |
| 53 | title = excluded.title, | 61 | title = excluded.title, |
| 54 | model = excluded.model, | 62 | model_selection_json = excluded.model_selection_json, |
| 55 | runtime_mode = excluded.runtime_mode, | 63 | runtime_mode = excluded.runtime_mode, |
| 56 | interaction_mode = excluded.interaction_mode, | 64 | interaction_mode = excluded.interaction_mode, |
| 57 | branch = excluded.branch, | 65 | branch = excluded.branch, |
| ... | @@ -65,14 +73,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -65,14 +73,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 65 | 73 | ||
| 66 | const getProjectionThreadRow = SqlSchema.findOneOption({ | 74 | const getProjectionThreadRow = SqlSchema.findOneOption({ |
| 67 | Request: GetProjectionThreadInput, | 75 | Request: GetProjectionThreadInput, |
| 68 | Result: ProjectionThread, | 76 | Result: ProjectionThreadDbRow, |
| 69 | execute: ({ threadId }) => | 77 | execute: ({ threadId }) => |
| 70 | sql` | 78 | sql` |
| 71 | SELECT | 79 | SELECT |
| 72 | thread_id AS "threadId", | 80 | thread_id AS "threadId", |
| 73 | project_id AS "projectId", | 81 | project_id AS "projectId", |
| 74 | title, | 82 | title, |
| 75 | model, | 83 | model_selection_json AS "modelSelection", |
| 76 | runtime_mode AS "runtimeMode", | 84 | runtime_mode AS "runtimeMode", |
| 77 | interaction_mode AS "interactionMode", | 85 | interaction_mode AS "interactionMode", |
| 78 | branch, | 86 | branch, |
| ... | @@ -88,14 +96,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { | ... | @@ -88,14 +96,14 @@ const makeProjectionThreadRepository = Effect.gen(function* () { |
| 88 | 96 | ||
| 89 | const listProjectionThreadRows = SqlSchema.findAll({ | 97 | const listProjectionThreadRows = SqlSchema.findAll({ |
| 90 | Request: ListProjectionThreadsByProjectInput, | 98 | Request: ListProjectionThreadsByProjectInput, |
| 91 | Result: ProjectionThread, | 99 | Result: ProjectionThreadDbRow, |
| 92 | execute: ({ projectId }) => | 100 | execute: ({ projectId }) => |
| 93 | sql` | 101 | sql` |
| 94 | SELECT | 102 | SELECT |
| 95 | thread_id AS "threadId", | 103 | thread_id AS "threadId", |
| 96 | project_id AS "projectId", | 104 | project_id AS "projectId", |
| 97 | title, | 105 | title, |
| 98 | model, | 106 | model_selection_json AS "modelSelection", |
| 99 | runtime_mode AS "runtimeMode", | 107 | runtime_mode AS "runtimeMode", |
| 100 | interaction_mode AS "interactionMode", | 108 | interaction_mode AS "interactionMode", |
| 101 | branch, | 109 | branch, |
apps/server/src/persistence/Layers/Sqlite.ts+1-1| ... | @@ -31,7 +31,7 @@ const setup = Layer.effectDiscard( | ... | @@ -31,7 +31,7 @@ const setup = Layer.effectDiscard( |
| 31 | const sql = yield* SqlClient.SqlClient; | 31 | const sql = yield* SqlClient.SqlClient; |
| 32 | yield* sql`PRAGMA journal_mode = WAL;`; | 32 | yield* sql`PRAGMA journal_mode = WAL;`; |
| 33 | yield* sql`PRAGMA foreign_keys = ON;`; | 33 | yield* sql`PRAGMA foreign_keys = ON;`; |
| 34 | yield* runMigrations; | 34 | yield* runMigrations(); |
| 35 | }), | 35 | }), |
| 36 | ); | 36 | ); |
| 37 | 37 |
apps/server/src/persistence/Migrations.ts+47-24| ... | @@ -10,6 +10,7 @@ | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 | ||
| 11 | import * as Migrator from "effect/unstable/sql/Migrator"; | 11 | import * as Migrator from "effect/unstable/sql/Migrator"; |
| 12 | import * as Layer from "effect/Layer"; | 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Effect from "effect/Effect"; | ||
| 13 | 14 | ||
| 14 | // Import all migrations statically | 15 | // Import all migrations statically |
| 15 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; | 16 | import Migration0001 from "./Migrations/001_OrchestrationEvents.ts"; |
| ... | @@ -27,7 +28,7 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts" | ... | @@ -27,7 +28,7 @@ import Migration0012 from "./Migrations/012_ProjectionThreadsInteractionMode.ts" |
| 27 | import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; | 28 | import Migration0013 from "./Migrations/013_ProjectionThreadProposedPlans.ts"; |
| 28 | import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; | 29 | import Migration0014 from "./Migrations/014_ProjectionThreadProposedPlanImplementation.ts"; |
| 29 | import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts"; | 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 | * Migration loader with all migrations defined inline. | 34 | * Migration loader with all migrations defined inline. |
| ... | @@ -39,23 +40,33 @@ import { Effect } from "effect"; | ... | @@ -39,23 +40,33 @@ import { Effect } from "effect"; |
| 39 | * Uses Migrator.fromRecord which parses the key format and | 40 | * Uses Migrator.fromRecord which parses the key format and |
| 40 | * returns migrations sorted by ID. | 41 | * returns migrations sorted by ID. |
| 41 | */ | 42 | */ |
| 42 | const loader = Migrator.fromRecord({ | 43 | export const migrationEntries = [ |
| 43 | "1_OrchestrationEvents": Migration0001, | 44 | [1, "OrchestrationEvents", Migration0001], |
| 44 | "2_OrchestrationCommandReceipts": Migration0002, | 45 | [2, "OrchestrationCommandReceipts", Migration0002], |
| 45 | "3_CheckpointDiffBlobs": Migration0003, | 46 | [3, "CheckpointDiffBlobs", Migration0003], |
| 46 | "4_ProviderSessionRuntime": Migration0004, | 47 | [4, "ProviderSessionRuntime", Migration0004], |
| 47 | "5_Projections": Migration0005, | 48 | [5, "Projections", Migration0005], |
| 48 | "6_ProjectionThreadSessionRuntimeModeColumns": Migration0006, | 49 | [6, "ProjectionThreadSessionRuntimeModeColumns", Migration0006], |
| 49 | "7_ProjectionThreadMessageAttachments": Migration0007, | 50 | [7, "ProjectionThreadMessageAttachments", Migration0007], |
| 50 | "8_ProjectionThreadActivitySequence": Migration0008, | 51 | [8, "ProjectionThreadActivitySequence", Migration0008], |
| 51 | "9_ProviderSessionRuntimeMode": Migration0009, | 52 | [9, "ProviderSessionRuntimeMode", Migration0009], |
| 52 | "10_ProjectionThreadsRuntimeMode": Migration0010, | 53 | [10, "ProjectionThreadsRuntimeMode", Migration0010], |
| 53 | "11_OrchestrationThreadCreatedRuntimeMode": Migration0011, | 54 | [11, "OrchestrationThreadCreatedRuntimeMode", Migration0011], |
| 54 | "12_ProjectionThreadsInteractionMode": Migration0012, | 55 | [12, "ProjectionThreadsInteractionMode", Migration0012], |
| 55 | "13_ProjectionThreadProposedPlans": Migration0013, | 56 | [13, "ProjectionThreadProposedPlans", Migration0013], |
| 56 | "14_ProjectionThreadProposedPlanImplementation": Migration0014, | 57 | [14, "ProjectionThreadProposedPlanImplementation", Migration0014], |
| 57 | "15_ProjectionTurnsSourceProposedPlan": Migration0015, | 58 | [15, "ProjectionTurnsSourceProposedPlan", Migration0015], |
| 58 | }); | 59 | [16, "CanonicalizeModelSelections", Migration0016], |
| 60 | ] as const; | ||
| 61 | |||
| 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 | * Migrator run function - no schema dumping needed | 72 | * Migrator run function - no schema dumping needed |
| ... | @@ -63,6 +74,10 @@ const loader = Migrator.fromRecord({ | ... | @@ -63,6 +74,10 @@ const loader = Migrator.fromRecord({ |
| 63 | */ | 74 | */ |
| 64 | const run = Migrator.make({}); | 75 | const run = Migrator.make({}); |
| 65 | 76 | ||
| 77 | export interface RunMigrationsOptions { | ||
| 78 | readonly toMigrationInclusive?: number | undefined; | ||
| 79 | } | ||
| 80 | |||
| 66 | /** | 81 | /** |
| 67 | * Run all pending migrations. | 82 | * Run all pending migrations. |
| 68 | * | 83 | * |
| ... | @@ -73,11 +88,19 @@ const run = Migrator.make({}); | ... | @@ -73,11 +88,19 @@ const run = Migrator.make({}); |
| 73 | * | 88 | * |
| 74 | * @returns Effect containing array of executed migrations | 89 | * @returns Effect containing array of executed migrations |
| 75 | */ | 90 | */ |
| 76 | export const runMigrations = Effect.gen(function* () { | 91 | export const runMigrations = ({ toMigrationInclusive }: RunMigrationsOptions = {}) => |
| 77 | yield* Effect.log("Running migrations..."); | 92 | Effect.gen(function* () { |
| 78 | yield* run({ loader }); | 93 | yield* Effect.log( |
| 79 | yield* Effect.log("Migrations ran successfully"); | 94 | toMigrationInclusive === undefined |
| 80 | }); | 95 | ? "Running all migrations..." |
| 96 | : `Running migrations 1 through ${toMigrationInclusive}...`, | ||
| 97 | ); | ||
| 98 | const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); | ||
| 99 | yield* Effect.log("Migrations ran successfully").pipe( | ||
| 100 | Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }), | ||
| 101 | ); | ||
| 102 | return executedMigrations; | ||
| 103 | }); | ||
| 81 | 104 | ||
| 82 | /** | 105 | /** |
| 83 | * Layer that runs migrations when the layer is built. | 106 | * Layer that runs migrations when the layer is built. |
| ... | @@ -96,4 +119,4 @@ export const runMigrations = Effect.gen(function* () { | ... | @@ -96,4 +119,4 @@ export const runMigrations = Effect.gen(function* () { |
| 96 | * ) | 119 | * ) |
| 97 | * ``` | 120 | * ``` |
| 98 | */ | 121 | */ |
| 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,7 +6,7 @@ |
| 6 | * | 6 | * |
| 7 | * @module ProjectionProjectRepository | 7 | * @module ProjectionProjectRepository |
| 8 | */ | 8 | */ |
| 9 | import { IsoDateTime, ProjectId, ProjectScript } from "@t3tools/contracts"; | 9 | import { IsoDateTime, ModelSelection, ProjectId, ProjectScript } from "@t3tools/contracts"; |
| 10 | import { Option, Schema, ServiceMap } from "effect"; | 10 | import { Option, Schema, ServiceMap } from "effect"; |
| 11 | import type { Effect } from "effect"; | 11 | import type { Effect } from "effect"; |
| 12 | 12 | ||
| ... | @@ -16,7 +16,7 @@ export const ProjectionProject = Schema.Struct({ | ... | @@ -16,7 +16,7 @@ export const ProjectionProject = Schema.Struct({ |
| 16 | projectId: ProjectId, | 16 | projectId: ProjectId, |
| 17 | title: Schema.String, | 17 | title: Schema.String, |
| 18 | workspaceRoot: Schema.String, | 18 | workspaceRoot: Schema.String, |
| 19 | defaultModel: Schema.NullOr(Schema.String), | 19 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 20 | scripts: Schema.Array(ProjectScript), | 20 | scripts: Schema.Array(ProjectScript), |
| 21 | createdAt: IsoDateTime, | 21 | createdAt: IsoDateTime, |
| 22 | updatedAt: IsoDateTime, | 22 | updatedAt: IsoDateTime, |
apps/server/src/persistence/Services/ProjectionThreads.ts+2-1| ... | @@ -8,6 +8,7 @@ | ... | @@ -8,6 +8,7 @@ |
| 8 | */ | 8 | */ |
| 9 | import { | 9 | import { |
| 10 | IsoDateTime, | 10 | IsoDateTime, |
| 11 | ModelSelection, | ||
| 11 | ProjectId, | 12 | ProjectId, |
| 12 | ProviderInteractionMode, | 13 | ProviderInteractionMode, |
| 13 | RuntimeMode, | 14 | RuntimeMode, |
| ... | @@ -23,7 +24,7 @@ export const ProjectionThread = Schema.Struct({ | ... | @@ -23,7 +24,7 @@ export const ProjectionThread = Schema.Struct({ |
| 23 | threadId: ThreadId, | 24 | threadId: ThreadId, |
| 24 | projectId: ProjectId, | 25 | projectId: ProjectId, |
| 25 | title: Schema.String, | 26 | title: Schema.String, |
| 26 | model: Schema.String, | 27 | modelSelection: ModelSelection, |
| 27 | runtimeMode: RuntimeMode, | 28 | runtimeMode: RuntimeMode, |
| 28 | interactionMode: ProviderInteractionMode, | 29 | interactionMode: ProviderInteractionMode, |
| 29 | branch: Schema.NullOr(Schema.String), | 30 | branch: Schema.NullOr(Schema.String), |
apps/server/src/provider/Layers/ClaudeAdapter.test.ts+52-37| ... | @@ -332,13 +332,14 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -332,13 +332,14 @@ describe("ClaudeAdapterLive", () => { |
| 332 | yield* adapter.startSession({ | 332 | yield* adapter.startSession({ |
| 333 | threadId: THREAD_ID, | 333 | threadId: THREAD_ID, |
| 334 | provider: "claudeAgent", | 334 | provider: "claudeAgent", |
| 335 | model: "claude-opus-4-6", | 335 | modelSelection: { |
| 336 | runtimeMode: "full-access", | 336 | provider: "claudeAgent", |
| 337 | modelOptions: { | 337 | model: "claude-opus-4-6", |
| 338 | claudeAgent: { | 338 | options: { |
| 339 | effort: "max", | 339 | effort: "max", |
| 340 | }, | 340 | }, |
| 341 | }, | 341 | }, |
| 342 | runtimeMode: "full-access", | ||
| 342 | }); | 343 | }); |
| 343 | 344 | ||
| 344 | const createInput = harness.getLastCreateQueryInput(); | 345 | const createInput = harness.getLastCreateQueryInput(); |
| ... | @@ -356,13 +357,14 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -356,13 +357,14 @@ describe("ClaudeAdapterLive", () => { |
| 356 | yield* adapter.startSession({ | 357 | yield* adapter.startSession({ |
| 357 | threadId: THREAD_ID, | 358 | threadId: THREAD_ID, |
| 358 | provider: "claudeAgent", | 359 | provider: "claudeAgent", |
| 359 | model: "claude-sonnet-4-6", | 360 | modelSelection: { |
| 360 | runtimeMode: "full-access", | 361 | provider: "claudeAgent", |
| 361 | modelOptions: { | 362 | model: "claude-sonnet-4-6", |
| 362 | claudeAgent: { | 363 | options: { |
| 363 | effort: "max", | 364 | effort: "max", |
| 364 | }, | 365 | }, |
| 365 | }, | 366 | }, |
| 367 | runtimeMode: "full-access", | ||
| 366 | }); | 368 | }); |
| 367 | 369 | ||
| 368 | const createInput = harness.getLastCreateQueryInput(); | 370 | const createInput = harness.getLastCreateQueryInput(); |
| ... | @@ -380,13 +382,14 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -380,13 +382,14 @@ describe("ClaudeAdapterLive", () => { |
| 380 | yield* adapter.startSession({ | 382 | yield* adapter.startSession({ |
| 381 | threadId: THREAD_ID, | 383 | threadId: THREAD_ID, |
| 382 | provider: "claudeAgent", | 384 | provider: "claudeAgent", |
| 383 | model: "claude-haiku-4-5", | 385 | modelSelection: { |
| 384 | runtimeMode: "full-access", | 386 | provider: "claudeAgent", |
| 385 | modelOptions: { | 387 | model: "claude-haiku-4-5", |
| 386 | claudeAgent: { | 388 | options: { |
| 387 | effort: "high", | 389 | effort: "high", |
| 388 | }, | 390 | }, |
| 389 | }, | 391 | }, |
| 392 | runtimeMode: "full-access", | ||
| 390 | }); | 393 | }); |
| 391 | 394 | ||
| 392 | const createInput = harness.getLastCreateQueryInput(); | 395 | const createInput = harness.getLastCreateQueryInput(); |
| ... | @@ -404,13 +407,14 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -404,13 +407,14 @@ describe("ClaudeAdapterLive", () => { |
| 404 | yield* adapter.startSession({ | 407 | yield* adapter.startSession({ |
| 405 | threadId: THREAD_ID, | 408 | threadId: THREAD_ID, |
| 406 | provider: "claudeAgent", | 409 | provider: "claudeAgent", |
| 407 | model: "claude-haiku-4-5", | 410 | modelSelection: { |
| 408 | runtimeMode: "full-access", | 411 | provider: "claudeAgent", |
| 409 | modelOptions: { | 412 | model: "claude-haiku-4-5", |
| 410 | claudeAgent: { | 413 | options: { |
| 411 | thinking: false, | 414 | thinking: false, |
| 412 | }, | 415 | }, |
| 413 | }, | 416 | }, |
| 417 | runtimeMode: "full-access", | ||
| 414 | }); | 418 | }); |
| 415 | 419 | ||
| 416 | const createInput = harness.getLastCreateQueryInput(); | 420 | const createInput = harness.getLastCreateQueryInput(); |
| ... | @@ -430,13 +434,14 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -430,13 +434,14 @@ describe("ClaudeAdapterLive", () => { |
| 430 | yield* adapter.startSession({ | 434 | yield* adapter.startSession({ |
| 431 | threadId: THREAD_ID, | 435 | threadId: THREAD_ID, |
| 432 | provider: "claudeAgent", | 436 | provider: "claudeAgent", |
| 433 | model: "claude-sonnet-4-6", | 437 | modelSelection: { |
| 434 | runtimeMode: "full-access", | 438 | provider: "claudeAgent", |
| 435 | modelOptions: { | 439 | model: "claude-sonnet-4-6", |
| 436 | claudeAgent: { | 440 | options: { |
| 437 | thinking: false, | 441 | thinking: false, |
| 438 | }, | 442 | }, |
| 439 | }, | 443 | }, |
| 444 | runtimeMode: "full-access", | ||
| 440 | }); | 445 | }); |
| 441 | 446 | ||
| 442 | const createInput = harness.getLastCreateQueryInput(); | 447 | const createInput = harness.getLastCreateQueryInput(); |
| ... | @@ -454,13 +459,14 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -454,13 +459,14 @@ describe("ClaudeAdapterLive", () => { |
| 454 | yield* adapter.startSession({ | 459 | yield* adapter.startSession({ |
| 455 | threadId: THREAD_ID, | 460 | threadId: THREAD_ID, |
| 456 | provider: "claudeAgent", | 461 | provider: "claudeAgent", |
| 457 | model: "claude-opus-4-6", | 462 | modelSelection: { |
| 458 | runtimeMode: "full-access", | 463 | provider: "claudeAgent", |
| 459 | modelOptions: { | 464 | model: "claude-opus-4-6", |
| 460 | claudeAgent: { | 465 | options: { |
| 461 | fastMode: true, | 466 | fastMode: true, |
| 462 | }, | 467 | }, |
| 463 | }, | 468 | }, |
| 469 | runtimeMode: "full-access", | ||
| 464 | }); | 470 | }); |
| 465 | 471 | ||
| 466 | const createInput = harness.getLastCreateQueryInput(); | 472 | const createInput = harness.getLastCreateQueryInput(); |
| ... | @@ -480,13 +486,14 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -480,13 +486,14 @@ describe("ClaudeAdapterLive", () => { |
| 480 | yield* adapter.startSession({ | 486 | yield* adapter.startSession({ |
| 481 | threadId: THREAD_ID, | 487 | threadId: THREAD_ID, |
| 482 | provider: "claudeAgent", | 488 | provider: "claudeAgent", |
| 483 | model: "claude-sonnet-4-6", | 489 | modelSelection: { |
| 484 | runtimeMode: "full-access", | 490 | provider: "claudeAgent", |
| 485 | modelOptions: { | 491 | model: "claude-sonnet-4-6", |
| 486 | claudeAgent: { | 492 | options: { |
| 487 | fastMode: true, | 493 | fastMode: true, |
| 488 | }, | 494 | }, |
| 489 | }, | 495 | }, |
| 496 | runtimeMode: "full-access", | ||
| 490 | }); | 497 | }); |
| 491 | 498 | ||
| 492 | const createInput = harness.getLastCreateQueryInput(); | 499 | const createInput = harness.getLastCreateQueryInput(); |
| ... | @@ -504,22 +511,24 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -504,22 +511,24 @@ describe("ClaudeAdapterLive", () => { |
| 504 | const session = yield* adapter.startSession({ | 511 | const session = yield* adapter.startSession({ |
| 505 | threadId: THREAD_ID, | 512 | threadId: THREAD_ID, |
| 506 | provider: "claudeAgent", | 513 | provider: "claudeAgent", |
| 507 | model: "claude-sonnet-4-6", | 514 | modelSelection: { |
| 508 | runtimeMode: "full-access", | 515 | provider: "claudeAgent", |
| 509 | modelOptions: { | 516 | model: "claude-sonnet-4-6", |
| 510 | claudeAgent: { | 517 | options: { |
| 511 | effort: "ultrathink", | 518 | effort: "ultrathink", |
| 512 | }, | 519 | }, |
| 513 | }, | 520 | }, |
| 521 | runtimeMode: "full-access", | ||
| 514 | }); | 522 | }); |
| 515 | 523 | ||
| 516 | yield* adapter.sendTurn({ | 524 | yield* adapter.sendTurn({ |
| 517 | threadId: session.threadId, | 525 | threadId: session.threadId, |
| 518 | input: "Investigate the edge cases", | 526 | input: "Investigate the edge cases", |
| 519 | attachments: [], | 527 | attachments: [], |
| 520 | model: "claude-sonnet-4-6", | 528 | modelSelection: { |
| 521 | modelOptions: { | 529 | provider: "claudeAgent", |
| 522 | claudeAgent: { | 530 | model: "claude-sonnet-4-6", |
| 531 | options: { | ||
| 523 | effort: "ultrathink", | 532 | effort: "ultrathink", |
| 524 | }, | 533 | }, |
| 525 | }, | 534 | }, |
| ... | @@ -613,7 +622,10 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -613,7 +622,10 @@ describe("ClaudeAdapterLive", () => { |
| 613 | const session = yield* adapter.startSession({ | 622 | const session = yield* adapter.startSession({ |
| 614 | threadId: THREAD_ID, | 623 | threadId: THREAD_ID, |
| 615 | provider: "claudeAgent", | 624 | provider: "claudeAgent", |
| 616 | model: "claude-sonnet-4-5", | 625 | modelSelection: { |
| 626 | provider: "claudeAgent", | ||
| 627 | model: "claude-sonnet-4-5", | ||
| 628 | }, | ||
| 617 | runtimeMode: "full-access", | 629 | runtimeMode: "full-access", |
| 618 | }); | 630 | }); |
| 619 | 631 | ||
| ... | @@ -2360,7 +2372,10 @@ describe("ClaudeAdapterLive", () => { | ... | @@ -2360,7 +2372,10 @@ describe("ClaudeAdapterLive", () => { |
| 2360 | yield* adapter.sendTurn({ | 2372 | yield* adapter.sendTurn({ |
| 2361 | threadId: session.threadId, | 2373 | threadId: session.threadId, |
| 2362 | input: "hello", | 2374 | input: "hello", |
| 2363 | model: "claude-opus-4-6", | 2375 | modelSelection: { |
| 2376 | provider: "claudeAgent", | ||
| 2377 | model: "claude-opus-4-6", | ||
| 2378 | }, | ||
| 2364 | attachments: [], | 2379 | attachments: [], |
| 2365 | }); | 2380 | }); |
| 2366 | 2381 |
apps/server/src/provider/Layers/ClaudeAdapter.ts+64-36| ... | @@ -38,15 +38,13 @@ import { | ... | @@ -38,15 +38,13 @@ import { |
| 38 | ThreadId, | 38 | ThreadId, |
| 39 | TurnId, | 39 | TurnId, |
| 40 | type UserInputQuestion, | 40 | type UserInputQuestion, |
| 41 | ClaudeCodeEffort, | ||
| 41 | } from "@t3tools/contracts"; | 42 | } from "@t3tools/contracts"; |
| 42 | import { | 43 | import { |
| 44 | hasEffortLevel, | ||
| 43 | applyClaudePromptEffortPrefix, | 45 | applyClaudePromptEffortPrefix, |
| 44 | getEffectiveClaudeCodeEffort, | 46 | getModelCapabilities, |
| 45 | getReasoningEffortOptions, | 47 | trimOrNull, |
| 46 | resolveReasoningEffortForProvider, | ||
| 47 | supportsClaudeFastMode, | ||
| 48 | supportsClaudeThinkingToggle, | ||
| 49 | supportsClaudeUltrathinkKeyword, | ||
| 50 | } from "@t3tools/shared/model"; | 48 | } from "@t3tools/shared/model"; |
| 51 | import { | 49 | import { |
| 52 | Cause, | 50 | Cause, |
| ... | @@ -158,6 +156,7 @@ interface ClaudeSessionContext { | ... | @@ -158,6 +156,7 @@ interface ClaudeSessionContext { |
| 158 | readonly inFlightTools: Map<number, ToolInFlight>; | 156 | readonly inFlightTools: Map<number, ToolInFlight>; |
| 159 | turnState: ClaudeTurnState | undefined; | 157 | turnState: ClaudeTurnState | undefined; |
| 160 | lastKnownContextWindow: number | undefined; | 158 | lastKnownContextWindow: number | undefined; |
| 159 | lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; | ||
| 161 | lastAssistantUuid: string | undefined; | 160 | lastAssistantUuid: string | undefined; |
| 162 | lastThreadStartedId: string | undefined; | 161 | lastThreadStartedId: string | undefined; |
| 163 | stopped: boolean; | 162 | stopped: boolean; |
| ... | @@ -211,6 +210,15 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray | ... | @@ -211,6 +210,15 @@ function normalizeClaudeStreamMessages(cause: Cause.Cause<Error>): ReadonlyArray |
| 211 | return squashed.length > 0 ? [squashed] : []; | 210 | return squashed.length > 0 ? [squashed] : []; |
| 212 | } | 211 | } |
| 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 | function isClaudeInterruptedMessage(message: string): boolean { | 222 | function isClaudeInterruptedMessage(message: string): boolean { |
| 215 | const normalized = message.toLowerCase(); | 223 | const normalized = message.toLowerCase(); |
| 216 | return ( | 224 | return ( |
| ... | @@ -512,15 +520,16 @@ const CLAUDE_SETTING_SOURCES = [ | ... | @@ -512,15 +520,16 @@ const CLAUDE_SETTING_SOURCES = [ |
| 512 | ] as const satisfies ReadonlyArray<SettingSource>; | 520 | ] as const satisfies ReadonlyArray<SettingSource>; |
| 513 | 521 | ||
| 514 | function buildPromptText(input: ProviderSendTurnInput): string { | 522 | function buildPromptText(input: ProviderSendTurnInput): string { |
| 515 | const requestedEffort = resolveReasoningEffortForProvider( | 523 | const rawEffort = |
| 516 | "claudeAgent", | 524 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.options?.effort : null; |
| 517 | input.modelOptions?.claudeAgent?.effort ?? null, | 525 | const requestedEffort = trimOrNull(rawEffort); |
| 518 | ); | 526 | const claudeModel = |
| 519 | const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model); | 527 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection.model : undefined; |
| 528 | const caps = getModelCapabilities("claudeAgent", claudeModel); | ||
| 520 | const promptEffort = | 529 | const promptEffort = |
| 521 | requestedEffort === "ultrathink" && supportsClaudeUltrathinkKeyword(input.model) | 530 | requestedEffort === "ultrathink" && caps.reasoningEffortLevels.length > 0 |
| 522 | ? "ultrathink" | 531 | ? "ultrathink" |
| 523 | : requestedEffort && supportedEffortOptions.includes(requestedEffort) | 532 | : requestedEffort && hasEffortLevel(caps, requestedEffort) |
| 524 | ? requestedEffort | 533 | ? requestedEffort |
| 525 | : null; | 534 | : null; |
| 526 | return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); | 535 | return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); |
| ... | @@ -1368,14 +1377,33 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -1368,14 +1377,33 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 1368 | const resultUsage = | 1377 | const resultUsage = |
| 1369 | result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined; | 1378 | result?.usage && typeof result.usage === "object" ? { ...result.usage } : undefined; |
| 1370 | const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); | 1379 | const resultContextWindow = maxClaudeContextWindowFromModelUsage(result?.modelUsage); |
| 1371 | const usageSnapshot = normalizeClaudeTokenUsage( | ||
| 1372 | resultUsage, | ||
| 1373 | resultContextWindow ?? context.lastKnownContextWindow, | ||
| 1374 | ); | ||
| 1375 | if (resultContextWindow !== undefined) { | 1380 | if (resultContextWindow !== undefined) { |
| 1376 | context.lastKnownContextWindow = resultContextWindow; | 1381 | context.lastKnownContextWindow = resultContextWindow; |
| 1377 | } | 1382 | } |
| 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 | const turnState = context.turnState; | 1407 | const turnState = context.turnState; |
| 1380 | if (!turnState) { | 1408 | if (!turnState) { |
| 1381 | if (usageSnapshot) { | 1409 | if (usageSnapshot) { |
| ... | @@ -2051,6 +2079,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2051,6 +2079,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2051 | context.lastKnownContextWindow, | 2079 | context.lastKnownContextWindow, |
| 2052 | ); | 2080 | ); |
| 2053 | if (normalizedUsage) { | 2081 | if (normalizedUsage) { |
| 2082 | context.lastKnownTokenUsage = normalizedUsage; | ||
| 2054 | const usageStamp = yield* makeEventStamp(); | 2083 | const usageStamp = yield* makeEventStamp(); |
| 2055 | yield* offerRuntimeEvent({ | 2084 | yield* offerRuntimeEvent({ |
| 2056 | ...base, | 2085 | ...base, |
| ... | @@ -2082,6 +2111,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2082,6 +2111,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2082 | context.lastKnownContextWindow, | 2111 | context.lastKnownContextWindow, |
| 2083 | ); | 2112 | ); |
| 2084 | if (normalizedUsage) { | 2113 | if (normalizedUsage) { |
| 2114 | context.lastKnownTokenUsage = normalizedUsage; | ||
| 2085 | const usageStamp = yield* makeEventStamp(); | 2115 | const usageStamp = yield* makeEventStamp(); |
| 2086 | yield* offerRuntimeEvent({ | 2116 | yield* offerRuntimeEvent({ |
| 2087 | ...base, | 2117 | ...base, |
| ... | @@ -2698,21 +2728,16 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2698,21 +2728,16 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2698 | ); | 2728 | ); |
| 2699 | 2729 | ||
| 2700 | const providerOptions = input.providerOptions?.claudeAgent; | 2730 | const providerOptions = input.providerOptions?.claudeAgent; |
| 2701 | const requestedEffort = resolveReasoningEffortForProvider( | 2731 | const modelSelection = |
| 2702 | "claudeAgent", | 2732 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; |
| 2703 | input.modelOptions?.claudeAgent?.effort ?? null, | 2733 | const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); |
| 2704 | ); | 2734 | const caps = getModelCapabilities("claudeAgent", modelSelection?.model); |
| 2705 | const supportedEffortOptions = getReasoningEffortOptions("claudeAgent", input.model); | ||
| 2706 | const effort = | 2735 | const effort = |
| 2707 | requestedEffort && supportedEffortOptions.includes(requestedEffort) | 2736 | requestedEffort && hasEffortLevel(caps, requestedEffort) ? requestedEffort : null; |
| 2708 | ? requestedEffort | 2737 | const fastMode = modelSelection?.options?.fastMode === true && caps.supportsFastMode; |
| 2709 | : null; | ||
| 2710 | const fastMode = | ||
| 2711 | input.modelOptions?.claudeAgent?.fastMode === true && supportsClaudeFastMode(input.model); | ||
| 2712 | const thinking = | 2738 | const thinking = |
| 2713 | typeof input.modelOptions?.claudeAgent?.thinking === "boolean" && | 2739 | typeof modelSelection?.options?.thinking === "boolean" && caps.supportsThinkingToggle |
| 2714 | supportsClaudeThinkingToggle(input.model) | 2740 | ? modelSelection.options.thinking |
| 2715 | ? input.modelOptions.claudeAgent.thinking | ||
| 2716 | : undefined; | 2741 | : undefined; |
| 2717 | const effectiveEffort = getEffectiveClaudeCodeEffort(effort); | 2742 | const effectiveEffort = getEffectiveClaudeCodeEffort(effort); |
| 2718 | const permissionMode = | 2743 | const permissionMode = |
| ... | @@ -2725,7 +2750,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2725,7 +2750,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2725 | 2750 | ||
| 2726 | const queryOptions: ClaudeQueryOptions = { | 2751 | const queryOptions: ClaudeQueryOptions = { |
| 2727 | ...(input.cwd ? { cwd: input.cwd } : {}), | 2752 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 2728 | ...(input.model ? { model: input.model } : {}), | 2753 | ...(modelSelection?.model ? { model: modelSelection.model } : {}), |
| 2729 | pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", | 2754 | pathToClaudeCodeExecutable: providerOptions?.binaryPath ?? "claude", |
| 2730 | settingSources: [...CLAUDE_SETTING_SOURCES], | 2755 | settingSources: [...CLAUDE_SETTING_SOURCES], |
| 2731 | ...(effectiveEffort ? { effort: effectiveEffort } : {}), | 2756 | ...(effectiveEffort ? { effort: effectiveEffort } : {}), |
| ... | @@ -2766,7 +2791,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2766,7 +2791,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2766 | status: "ready", | 2791 | status: "ready", |
| 2767 | runtimeMode: input.runtimeMode, | 2792 | runtimeMode: input.runtimeMode, |
| 2768 | ...(input.cwd ? { cwd: input.cwd } : {}), | 2793 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 2769 | ...(input.model ? { model: input.model } : {}), | 2794 | ...(modelSelection?.model ? { model: modelSelection.model } : {}), |
| 2770 | ...(threadId ? { threadId } : {}), | 2795 | ...(threadId ? { threadId } : {}), |
| 2771 | resumeCursor: { | 2796 | resumeCursor: { |
| 2772 | ...(threadId ? { threadId } : {}), | 2797 | ...(threadId ? { threadId } : {}), |
| ... | @@ -2794,6 +2819,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2794,6 +2819,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2794 | inFlightTools, | 2819 | inFlightTools, |
| 2795 | turnState: undefined, | 2820 | turnState: undefined, |
| 2796 | lastKnownContextWindow: undefined, | 2821 | lastKnownContextWindow: undefined, |
| 2822 | lastKnownTokenUsage: undefined, | ||
| 2797 | lastAssistantUuid: resumeState?.resumeSessionAt, | 2823 | lastAssistantUuid: resumeState?.resumeSessionAt, |
| 2798 | lastThreadStartedId: undefined, | 2824 | lastThreadStartedId: undefined, |
| 2799 | stopped: false, | 2825 | stopped: false, |
| ... | @@ -2821,7 +2847,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2821,7 +2847,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2821 | threadId, | 2847 | threadId, |
| 2822 | payload: { | 2848 | payload: { |
| 2823 | config: { | 2849 | config: { |
| 2824 | ...(input.model ? { model: input.model } : {}), | 2850 | ...(modelSelection?.model ? { model: modelSelection.model } : {}), |
| 2825 | ...(input.cwd ? { cwd: input.cwd } : {}), | 2851 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 2826 | ...(effectiveEffort ? { effort: effectiveEffort } : {}), | 2852 | ...(effectiveEffort ? { effort: effectiveEffort } : {}), |
| 2827 | ...(permissionMode ? { permissionMode } : {}), | 2853 | ...(permissionMode ? { permissionMode } : {}), |
| ... | @@ -2867,6 +2893,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2867,6 +2893,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2867 | const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) => | 2893 | const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) => |
| 2868 | Effect.gen(function* () { | 2894 | Effect.gen(function* () { |
| 2869 | const context = yield* requireSession(input.threadId); | 2895 | const context = yield* requireSession(input.threadId); |
| 2896 | const modelSelection = | ||
| 2897 | input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; | ||
| 2870 | 2898 | ||
| 2871 | if (context.turnState) { | 2899 | if (context.turnState) { |
| 2872 | // Auto-close a stale synthetic turn (from background agent responses | 2900 | // Auto-close a stale synthetic turn (from background agent responses |
| ... | @@ -2874,9 +2902,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2874,9 +2902,9 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2874 | yield* completeTurn(context, "completed"); | 2902 | yield* completeTurn(context, "completed"); |
| 2875 | } | 2903 | } |
| 2876 | 2904 | ||
| 2877 | if (input.model) { | 2905 | if (modelSelection?.model) { |
| 2878 | yield* Effect.tryPromise({ | 2906 | yield* Effect.tryPromise({ |
| 2879 | try: () => context.query.setModel(input.model), | 2907 | try: () => context.query.setModel(modelSelection.model), |
| 2880 | catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), | 2908 | catch: (cause) => toRequestError(input.threadId, "turn/setModel", cause), |
| 2881 | }); | 2909 | }); |
| 2882 | } | 2910 | } |
| ... | @@ -2926,7 +2954,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { | ... | @@ -2926,7 +2954,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { |
| 2926 | createdAt: turnStartedStamp.createdAt, | 2954 | createdAt: turnStartedStamp.createdAt, |
| 2927 | threadId: context.session.threadId, | 2955 | threadId: context.session.threadId, |
| 2928 | turnId, | 2956 | turnId, |
| 2929 | payload: input.model ? { model: input.model } : {}, | 2957 | payload: modelSelection?.model ? { model: modelSelection.model } : {}, |
| 2930 | providerRefs: {}, | 2958 | providerRefs: {}, |
| 2931 | }); | 2959 | }); |
| 2932 | 2960 |
apps/server/src/provider/Layers/CodexAdapter.test.ts+8-6| ... | @@ -188,9 +188,10 @@ validationLayer("CodexAdapterLive validation", (it) => { | ... | @@ -188,9 +188,10 @@ validationLayer("CodexAdapterLive validation", (it) => { |
| 188 | yield* adapter.startSession({ | 188 | yield* adapter.startSession({ |
| 189 | provider: "codex", | 189 | provider: "codex", |
| 190 | threadId: asThreadId("thread-1"), | 190 | threadId: asThreadId("thread-1"), |
| 191 | model: "gpt-5.3-codex", | 191 | modelSelection: { |
| 192 | modelOptions: { | 192 | provider: "codex", |
| 193 | codex: { | 193 | model: "gpt-5.3-codex", |
| 194 | options: { | ||
| 194 | fastMode: true, | 195 | fastMode: true, |
| 195 | }, | 196 | }, |
| 196 | }, | 197 | }, |
| ... | @@ -256,9 +257,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { | ... | @@ -256,9 +257,10 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 256 | adapter.sendTurn({ | 257 | adapter.sendTurn({ |
| 257 | threadId: asThreadId("sess-missing"), | 258 | threadId: asThreadId("sess-missing"), |
| 258 | input: "hello", | 259 | input: "hello", |
| 259 | model: "gpt-5.3-codex", | 260 | modelSelection: { |
| 260 | modelOptions: { | 261 | provider: "codex", |
| 261 | codex: { | 262 | model: "gpt-5.3-codex", |
| 263 | options: { | ||
| 262 | reasoningEffort: "high", | 264 | reasoningEffort: "high", |
| 263 | fastMode: true, | 265 | fastMode: true, |
| 264 | }, | 266 | }, |
apps/server/src/provider/Layers/CodexAdapter.ts+16-6| ... | @@ -1361,8 +1361,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | ... | @@ -1361,8 +1361,12 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1361 | ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), | 1361 | ...(input.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), |
| 1362 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), | 1362 | ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), |
| 1363 | runtimeMode: input.runtimeMode, | 1363 | runtimeMode: input.runtimeMode, |
| 1364 | ...(input.model !== undefined ? { model: input.model } : {}), | 1364 | ...(input.modelSelection?.provider === "codex" |
| 1365 | ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}), | 1365 | ? { model: input.modelSelection.model } |
| 1366 | : {}), | ||
| 1367 | ...(input.modelSelection?.provider === "codex" && input.modelSelection.options?.fastMode | ||
| 1368 | ? { serviceTier: "fast" } | ||
| 1369 | : {}), | ||
| 1366 | }; | 1370 | }; |
| 1367 | 1371 | ||
| 1368 | return Effect.tryPromise({ | 1372 | return Effect.tryPromise({ |
| ... | @@ -1418,11 +1422,17 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | ... | @@ -1418,11 +1422,17 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 1418 | const managerInput = { | 1422 | const managerInput = { |
| 1419 | threadId: input.threadId, | 1423 | threadId: input.threadId, |
| 1420 | ...(input.input !== undefined ? { input: input.input } : {}), | 1424 | ...(input.input !== undefined ? { input: input.input } : {}), |
| 1421 | ...(input.model !== undefined ? { model: input.model } : {}), | 1425 | ...(input.modelSelection?.provider === "codex" |
| 1422 | ...(input.modelOptions?.codex?.reasoningEffort !== undefined | 1426 | ? { model: input.modelSelection.model } |
| 1423 | ? { effort: input.modelOptions.codex.reasoningEffort } | 1427 | : {}), |
| 1428 | ...(input.modelSelection?.provider === "codex" && | ||
| 1429 | input.modelSelection.options?.reasoningEffort !== undefined | ||
| 1430 | ? { effort: input.modelSelection.options.reasoningEffort } | ||
| 1431 | : {}), | ||
| 1432 | ...(input.modelSelection?.provider === "codex" && | ||
| 1433 | input.modelSelection.options?.fastMode | ||
| 1434 | ? { serviceTier: "fast" } | ||
| 1424 | : {}), | 1435 | : {}), |
| 1425 | ...(input.modelOptions?.codex?.fastMode ? { serviceTier: "fast" } : {}), | ||
| 1426 | ...(input.interactionMode !== undefined | 1436 | ...(input.interactionMode !== undefined |
| 1427 | ? { interactionMode: input.interactionMode } | 1437 | ? { interactionMode: input.interactionMode } |
| 1428 | : {}), | 1438 | : {}), |
apps/server/src/provider/Layers/ProviderService.test.ts+9-5| ... | @@ -633,8 +633,10 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -633,8 +633,10 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 633 | provider: "claudeAgent", | 633 | provider: "claudeAgent", |
| 634 | threadId: asThreadId("thread-claude-send-turn"), | 634 | threadId: asThreadId("thread-claude-send-turn"), |
| 635 | cwd: "/tmp/project-claude-send-turn", | 635 | cwd: "/tmp/project-claude-send-turn", |
| 636 | modelOptions: { | 636 | modelSelection: { |
| 637 | claudeAgent: { | 637 | provider: "claudeAgent", |
| 638 | model: "claude-opus-4-6", | ||
| 639 | options: { | ||
| 638 | effort: "max", | 640 | effort: "max", |
| 639 | }, | 641 | }, |
| 640 | }, | 642 | }, |
| ... | @@ -658,14 +660,16 @@ routing.layer("ProviderServiceLive routing", (it) => { | ... | @@ -658,14 +660,16 @@ routing.layer("ProviderServiceLive routing", (it) => { |
| 658 | const startPayload = resumedStartInput as { | 660 | const startPayload = resumedStartInput as { |
| 659 | provider?: string; | 661 | provider?: string; |
| 660 | cwd?: string; | 662 | cwd?: string; |
| 661 | modelOptions?: unknown; | 663 | modelSelection?: unknown; |
| 662 | resumeCursor?: unknown; | 664 | resumeCursor?: unknown; |
| 663 | threadId?: string; | 665 | threadId?: string; |
| 664 | }; | 666 | }; |
| 665 | assert.equal(startPayload.provider, "claudeAgent"); | 667 | assert.equal(startPayload.provider, "claudeAgent"); |
| 666 | assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); | 668 | assert.equal(startPayload.cwd, "/tmp/project-claude-send-turn"); |
| 667 | assert.deepEqual(startPayload.modelOptions, { | 669 | assert.deepEqual(startPayload.modelSelection, { |
| 668 | claudeAgent: { | 670 | provider: "claudeAgent", |
| 671 | model: "claude-opus-4-6", | ||
| 672 | options: { | ||
| 669 | effort: "max", | 673 | effort: "max", |
| 670 | }, | 674 | }, |
| 671 | }); | 675 | }); |
apps/server/src/provider/Layers/ProviderService.ts+16-13| ... | @@ -10,6 +10,7 @@ | ... | @@ -10,6 +10,7 @@ |
| 10 | * @module ProviderServiceLive | 10 | * @module ProviderServiceLive |
| 11 | */ | 11 | */ |
| 12 | import { | 12 | import { |
| 13 | ModelSelection, | ||
| 13 | NonNegativeInt, | 14 | NonNegativeInt, |
| 14 | ThreadId, | 15 | ThreadId, |
| 15 | ProviderInterruptTurnInput, | 16 | ProviderInterruptTurnInput, |
| ... | @@ -89,7 +90,7 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st | ... | @@ -89,7 +90,7 @@ function toRuntimeStatus(session: ProviderSession): "starting" | "running" | "st |
| 89 | function toRuntimePayloadFromSession( | 90 | function toRuntimePayloadFromSession( |
| 90 | session: ProviderSession, | 91 | session: ProviderSession, |
| 91 | extra?: { | 92 | extra?: { |
| 92 | readonly modelOptions?: unknown; | 93 | readonly modelSelection?: unknown; |
| 93 | readonly providerOptions?: unknown; | 94 | readonly providerOptions?: unknown; |
| 94 | readonly lastRuntimeEvent?: string; | 95 | readonly lastRuntimeEvent?: string; |
| 95 | readonly lastRuntimeEventAt?: string; | 96 | readonly lastRuntimeEventAt?: string; |
| ... | @@ -100,7 +101,7 @@ function toRuntimePayloadFromSession( | ... | @@ -100,7 +101,7 @@ function toRuntimePayloadFromSession( |
| 100 | model: session.model ?? null, | 101 | model: session.model ?? null, |
| 101 | activeTurnId: session.activeTurnId ?? null, | 102 | activeTurnId: session.activeTurnId ?? null, |
| 102 | lastError: session.lastError ?? null, | 103 | lastError: session.lastError ?? null, |
| 103 | ...(extra?.modelOptions !== undefined ? { modelOptions: extra.modelOptions } : {}), | 104 | ...(extra?.modelSelection !== undefined ? { modelSelection: extra.modelSelection } : {}), |
| 104 | ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), | 105 | ...(extra?.providerOptions !== undefined ? { providerOptions: extra.providerOptions } : {}), |
| 105 | ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), | 106 | ...(extra?.lastRuntimeEvent !== undefined ? { lastRuntimeEvent: extra.lastRuntimeEvent } : {}), |
| 106 | ...(extra?.lastRuntimeEventAt !== undefined | 107 | ...(extra?.lastRuntimeEventAt !== undefined |
| ... | @@ -109,15 +110,14 @@ function toRuntimePayloadFromSession( | ... | @@ -109,15 +110,14 @@ function toRuntimePayloadFromSession( |
| 109 | }; | 110 | }; |
| 110 | } | 111 | } |
| 111 | 112 | ||
| 112 | function readPersistedModelOptions( | 113 | function readPersistedModelSelection( |
| 113 | runtimePayload: ProviderRuntimeBinding["runtimePayload"], | 114 | runtimePayload: ProviderRuntimeBinding["runtimePayload"], |
| 114 | ): Record<string, unknown> | undefined { | 115 | ): ModelSelection | undefined { |
| 115 | if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { | 116 | if (!runtimePayload || typeof runtimePayload !== "object" || Array.isArray(runtimePayload)) { |
| 116 | return undefined; | 117 | return undefined; |
| 117 | } | 118 | } |
| 118 | const raw = "modelOptions" in runtimePayload ? runtimePayload.modelOptions : undefined; | 119 | const raw = "modelSelection" in runtimePayload ? runtimePayload.modelSelection : undefined; |
| 119 | if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; | 120 | return Schema.is(ModelSelection)(raw) ? raw : undefined; |
| 120 | return raw as Record<string, unknown>; | ||
| 121 | } | 121 | } |
| 122 | 122 | ||
| 123 | function readPersistedProviderOptions( | 123 | function readPersistedProviderOptions( |
| ... | @@ -172,7 +172,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -172,7 +172,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 172 | session: ProviderSession, | 172 | session: ProviderSession, |
| 173 | threadId: ThreadId, | 173 | threadId: ThreadId, |
| 174 | extra?: { | 174 | extra?: { |
| 175 | readonly modelOptions?: unknown; | 175 | readonly modelSelection?: unknown; |
| 176 | readonly providerOptions?: unknown; | 176 | readonly providerOptions?: unknown; |
| 177 | readonly lastRuntimeEvent?: string; | 177 | readonly lastRuntimeEvent?: string; |
| 178 | readonly lastRuntimeEventAt?: string; | 178 | readonly lastRuntimeEventAt?: string; |
| ... | @@ -238,14 +238,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -238,14 +238,14 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 238 | } | 238 | } |
| 239 | 239 | ||
| 240 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); | 240 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); |
| 241 | const persistedModelOptions = readPersistedModelOptions(input.binding.runtimePayload); | 241 | const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); |
| 242 | const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); | 242 | const persistedProviderOptions = readPersistedProviderOptions(input.binding.runtimePayload); |
| 243 | 243 | ||
| 244 | const resumed = yield* adapter.startSession({ | 244 | const resumed = yield* adapter.startSession({ |
| 245 | threadId: input.binding.threadId, | 245 | threadId: input.binding.threadId, |
| 246 | provider: input.binding.provider, | 246 | provider: input.binding.provider, |
| 247 | ...(persistedCwd ? { cwd: persistedCwd } : {}), | 247 | ...(persistedCwd ? { cwd: persistedCwd } : {}), |
| 248 | ...(persistedModelOptions ? { modelOptions: persistedModelOptions } : {}), | 248 | ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), |
| 249 | ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), | 249 | ...(persistedProviderOptions ? { providerOptions: persistedProviderOptions } : {}), |
| 250 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), | 250 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), |
| 251 | runtimeMode: input.binding.runtimeMode ?? "full-access", | 251 | runtimeMode: input.binding.runtimeMode ?? "full-access", |
| ... | @@ -328,7 +328,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -328,7 +328,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 328 | } | 328 | } |
| 329 | 329 | ||
| 330 | yield* upsertSessionBinding(session, threadId, { | 330 | yield* upsertSessionBinding(session, threadId, { |
| 331 | modelOptions: input.modelOptions, | 331 | modelSelection: input.modelSelection, |
| 332 | providerOptions: input.providerOptions, | 332 | providerOptions: input.providerOptions, |
| 333 | }); | 333 | }); |
| 334 | yield* analytics.record("provider.session.started", { | 334 | yield* analytics.record("provider.session.started", { |
| ... | @@ -336,7 +336,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -336,7 +336,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 336 | runtimeMode: input.runtimeMode, | 336 | runtimeMode: input.runtimeMode, |
| 337 | hasResumeCursor: session.resumeCursor !== undefined, | 337 | hasResumeCursor: session.resumeCursor !== undefined, |
| 338 | hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0, | 338 | hasCwd: typeof input.cwd === "string" && input.cwd.trim().length > 0, |
| 339 | hasModel: typeof input.model === "string" && input.model.trim().length > 0, | 339 | hasModel: |
| 340 | typeof input.modelSelection?.model === "string" && | ||
| 341 | input.modelSelection.model.trim().length > 0, | ||
| 340 | }); | 342 | }); |
| 341 | 343 | ||
| 342 | return session; | 344 | return session; |
| ... | @@ -372,6 +374,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -372,6 +374,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 372 | status: "running", | 374 | status: "running", |
| 373 | ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), | 375 | ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), |
| 374 | runtimePayload: { | 376 | runtimePayload: { |
| 377 | ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), | ||
| 375 | activeTurnId: turn.turnId, | 378 | activeTurnId: turn.turnId, |
| 376 | lastRuntimeEvent: "provider.sendTurn", | 379 | lastRuntimeEvent: "provider.sendTurn", |
| 377 | lastRuntimeEventAt: new Date().toISOString(), | 380 | lastRuntimeEventAt: new Date().toISOString(), |
| ... | @@ -379,7 +382,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -379,7 +382,7 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 379 | }); | 382 | }); |
| 380 | yield* analytics.record("provider.turn.sent", { | 383 | yield* analytics.record("provider.turn.sent", { |
| 381 | provider: routed.adapter.provider, | 384 | provider: routed.adapter.provider, |
| 382 | model: input.model, | 385 | model: input.modelSelection?.model, |
| 383 | interactionMode: input.interactionMode, | 386 | interactionMode: input.interactionMode, |
| 384 | attachmentCount: input.attachments.length, | 387 | attachmentCount: input.attachments.length, |
| 385 | hasInput: typeof input.input === "string" && input.input.trim().length > 0, | 388 | hasInput: typeof input.input === "string" && input.input.trim().length > 0, |
apps/server/src/wsServer.test.ts+32-8| ... | @@ -703,13 +703,19 @@ describe("WebSocket Server", () => { | ... | @@ -703,13 +703,19 @@ describe("WebSocket Server", () => { |
| 703 | id: string; | 703 | id: string; |
| 704 | workspaceRoot: string; | 704 | workspaceRoot: string; |
| 705 | title: string; | 705 | title: string; |
| 706 | defaultModel: string | null; | 706 | defaultModelSelection: { |
| 707 | provider: string; | ||
| 708 | model: string; | ||
| 709 | } | null; | ||
| 707 | }>; | 710 | }>; |
| 708 | threads: Array<{ | 711 | threads: Array<{ |
| 709 | id: string; | 712 | id: string; |
| 710 | projectId: string; | 713 | projectId: string; |
| 711 | title: string; | 714 | title: string; |
| 712 | model: string; | 715 | modelSelection: { |
| 716 | provider: string; | ||
| 717 | model: string; | ||
| 718 | }; | ||
| 713 | branch: string | null; | 719 | branch: string | null; |
| 714 | worktreePath: string | null; | 720 | worktreePath: string | null; |
| 715 | }>; | 721 | }>; |
| ... | @@ -725,7 +731,10 @@ describe("WebSocket Server", () => { | ... | @@ -725,7 +731,10 @@ describe("WebSocket Server", () => { |
| 725 | id: bootstrapProjectId, | 731 | id: bootstrapProjectId, |
| 726 | workspaceRoot: "/test/bootstrap-workspace", | 732 | workspaceRoot: "/test/bootstrap-workspace", |
| 727 | title: "bootstrap-workspace", | 733 | title: "bootstrap-workspace", |
| 728 | defaultModel: "gpt-5-codex", | 734 | defaultModelSelection: { |
| 735 | provider: "codex", | ||
| 736 | model: "gpt-5-codex", | ||
| 737 | }, | ||
| 729 | }), | 738 | }), |
| 730 | ]), | 739 | ]), |
| 731 | ); | 740 | ); |
| ... | @@ -735,7 +744,10 @@ describe("WebSocket Server", () => { | ... | @@ -735,7 +744,10 @@ describe("WebSocket Server", () => { |
| 735 | id: bootstrapThreadId, | 744 | id: bootstrapThreadId, |
| 736 | projectId: bootstrapProjectId, | 745 | projectId: bootstrapProjectId, |
| 737 | title: "New thread", | 746 | title: "New thread", |
| 738 | model: "gpt-5-codex", | 747 | modelSelection: { |
| 748 | provider: "codex", | ||
| 749 | model: "gpt-5-codex", | ||
| 750 | }, | ||
| 739 | branch: null, | 751 | branch: null, |
| 740 | worktreePath: null, | 752 | worktreePath: null, |
| 741 | }), | 753 | }), |
| ... | @@ -1192,7 +1204,10 @@ describe("WebSocket Server", () => { | ... | @@ -1192,7 +1204,10 @@ describe("WebSocket Server", () => { |
| 1192 | projectId: "project-diff", | 1204 | projectId: "project-diff", |
| 1193 | title: "Diff Project", | 1205 | title: "Diff Project", |
| 1194 | workspaceRoot, | 1206 | workspaceRoot, |
| 1195 | defaultModel: "gpt-5-codex", | 1207 | defaultModelSelection: { |
| 1208 | provider: "codex", | ||
| 1209 | model: "gpt-5-codex", | ||
| 1210 | }, | ||
| 1196 | createdAt, | 1211 | createdAt, |
| 1197 | }); | 1212 | }); |
| 1198 | expect(createProjectResponse.error).toBeUndefined(); | 1213 | expect(createProjectResponse.error).toBeUndefined(); |
| ... | @@ -1202,7 +1217,10 @@ describe("WebSocket Server", () => { | ... | @@ -1202,7 +1217,10 @@ describe("WebSocket Server", () => { |
| 1202 | threadId: "thread-diff", | 1217 | threadId: "thread-diff", |
| 1203 | projectId: "project-diff", | 1218 | projectId: "project-diff", |
| 1204 | title: "Diff Thread", | 1219 | title: "Diff Thread", |
| 1205 | model: "gpt-5-codex", | 1220 | modelSelection: { |
| 1221 | provider: "codex", | ||
| 1222 | model: "gpt-5-codex", | ||
| 1223 | }, | ||
| 1206 | runtimeMode: "full-access", | 1224 | runtimeMode: "full-access", |
| 1207 | interactionMode: "default", | 1225 | interactionMode: "default", |
| 1208 | branch: null, | 1226 | branch: null, |
| ... | @@ -1270,7 +1288,10 @@ describe("WebSocket Server", () => { | ... | @@ -1270,7 +1288,10 @@ describe("WebSocket Server", () => { |
| 1270 | projectId: "project-1", | 1288 | projectId: "project-1", |
| 1271 | title: "WS Project", | 1289 | title: "WS Project", |
| 1272 | workspaceRoot, | 1290 | workspaceRoot, |
| 1273 | defaultModel: "gpt-5-codex", | 1291 | defaultModelSelection: { |
| 1292 | provider: "codex", | ||
| 1293 | model: "gpt-5-codex", | ||
| 1294 | }, | ||
| 1274 | createdAt, | 1295 | createdAt, |
| 1275 | }); | 1296 | }); |
| 1276 | expect(createProjectResponse.error).toBeUndefined(); | 1297 | expect(createProjectResponse.error).toBeUndefined(); |
| ... | @@ -1280,7 +1301,10 @@ describe("WebSocket Server", () => { | ... | @@ -1280,7 +1301,10 @@ describe("WebSocket Server", () => { |
| 1280 | threadId: "thread-1", | 1301 | threadId: "thread-1", |
| 1281 | projectId: "project-1", | 1302 | projectId: "project-1", |
| 1282 | title: "Thread 1", | 1303 | title: "Thread 1", |
| 1283 | model: "gpt-5-codex", | 1304 | modelSelection: { |
| 1305 | provider: "codex", | ||
| 1306 | model: "gpt-5-codex", | ||
| 1307 | }, | ||
| 1284 | runtimeMode: "full-access", | 1308 | runtimeMode: "full-access", |
| 1285 | interactionMode: "default", | 1309 | interactionMode: "default", |
| 1286 | branch: null, | 1310 | branch: null, |
apps/server/src/wsServer.ts+11-5| ... | @@ -631,25 +631,31 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -631,25 +631,31 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 631 | (project) => project.workspaceRoot === cwd && project.deletedAt === null, | 631 | (project) => project.workspaceRoot === cwd && project.deletedAt === null, |
| 632 | ); | 632 | ); |
| 633 | let bootstrapProjectId: ProjectId; | 633 | let bootstrapProjectId: ProjectId; |
| 634 | let bootstrapProjectDefaultModel: string; | 634 | let bootstrapProjectDefaultModelSelection; |
| 635 | 635 | ||
| 636 | if (!existingProject) { | 636 | if (!existingProject) { |
| 637 | const createdAt = new Date().toISOString(); | 637 | const createdAt = new Date().toISOString(); |
| 638 | bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); | 638 | bootstrapProjectId = ProjectId.makeUnsafe(crypto.randomUUID()); |
| 639 | const bootstrapProjectTitle = path.basename(cwd) || "project"; | 639 | const bootstrapProjectTitle = path.basename(cwd) || "project"; |
| 640 | bootstrapProjectDefaultModel = "gpt-5-codex"; | 640 | bootstrapProjectDefaultModelSelection = { |
| 641 | provider: "codex" as const, | ||
| 642 | model: "gpt-5-codex", | ||
| 643 | }; | ||
| 641 | yield* orchestrationEngine.dispatch({ | 644 | yield* orchestrationEngine.dispatch({ |
| 642 | type: "project.create", | 645 | type: "project.create", |
| 643 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), | 646 | commandId: CommandId.makeUnsafe(crypto.randomUUID()), |
| 644 | projectId: bootstrapProjectId, | 647 | projectId: bootstrapProjectId, |
| 645 | title: bootstrapProjectTitle, | 648 | title: bootstrapProjectTitle, |
| 646 | workspaceRoot: cwd, | 649 | workspaceRoot: cwd, |
| 647 | defaultModel: bootstrapProjectDefaultModel, | 650 | defaultModelSelection: bootstrapProjectDefaultModelSelection, |
| 648 | createdAt, | 651 | createdAt, |
| 649 | }); | 652 | }); |
| 650 | } else { | 653 | } else { |
| 651 | bootstrapProjectId = existingProject.id; | 654 | bootstrapProjectId = existingProject.id; |
| 652 | bootstrapProjectDefaultModel = existingProject.defaultModel ?? "gpt-5-codex"; | 655 | bootstrapProjectDefaultModelSelection = existingProject.defaultModelSelection ?? { |
| 656 | provider: "codex" as const, | ||
| 657 | model: "gpt-5-codex", | ||
| 658 | }; | ||
| 653 | } | 659 | } |
| 654 | 660 | ||
| 655 | const existingThread = snapshot.threads.find( | 661 | const existingThread = snapshot.threads.find( |
| ... | @@ -664,7 +670,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -664,7 +670,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 664 | threadId, | 670 | threadId, |
| 665 | projectId: bootstrapProjectId, | 671 | projectId: bootstrapProjectId, |
| 666 | title: "New thread", | 672 | title: "New thread", |
| 667 | model: bootstrapProjectDefaultModel, | 673 | modelSelection: bootstrapProjectDefaultModelSelection, |
| 668 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | 674 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 669 | runtimeMode: "full-access", | 675 | runtimeMode: "full-access", |
| 670 | branch: null, | 676 | branch: null, |
apps/web/src/components/ChatView.browser.tsx+80-41| ... | @@ -90,6 +90,7 @@ interface UserRowMeasurement { | ... | @@ -90,6 +90,7 @@ interface UserRowMeasurement { |
| 90 | } | 90 | } |
| 91 | 91 | ||
| 92 | interface MountedChatView { | 92 | interface MountedChatView { |
| 93 | [Symbol.asyncDispose]: () => Promise<void>; | ||
| 93 | cleanup: () => Promise<void>; | 94 | cleanup: () => Promise<void>; |
| 94 | measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>; | 95 | measureUserRow: (targetMessageId: MessageId) => Promise<UserRowMeasurement>; |
| 95 | setViewport: (viewport: ViewportSpec) => Promise<void>; | 96 | setViewport: (viewport: ViewportSpec) => Promise<void>; |
| ... | @@ -221,7 +222,10 @@ function createSnapshotForTargetUser(options: { | ... | @@ -221,7 +222,10 @@ function createSnapshotForTargetUser(options: { |
| 221 | id: PROJECT_ID, | 222 | id: PROJECT_ID, |
| 222 | title: "Project", | 223 | title: "Project", |
| 223 | workspaceRoot: "/repo/project", | 224 | workspaceRoot: "/repo/project", |
| 224 | defaultModel: "gpt-5", | 225 | defaultModelSelection: { |
| 226 | provider: "codex", | ||
| 227 | model: "gpt-5", | ||
| 228 | }, | ||
| 225 | scripts: [], | 229 | scripts: [], |
| 226 | createdAt: NOW_ISO, | 230 | createdAt: NOW_ISO, |
| 227 | updatedAt: NOW_ISO, | 231 | updatedAt: NOW_ISO, |
| ... | @@ -233,7 +237,10 @@ function createSnapshotForTargetUser(options: { | ... | @@ -233,7 +237,10 @@ function createSnapshotForTargetUser(options: { |
| 233 | id: THREAD_ID, | 237 | id: THREAD_ID, |
| 234 | projectId: PROJECT_ID, | 238 | projectId: PROJECT_ID, |
| 235 | title: "Browser test thread", | 239 | title: "Browser test thread", |
| 236 | model: "gpt-5", | 240 | modelSelection: { |
| 241 | provider: "codex", | ||
| 242 | model: "gpt-5", | ||
| 243 | }, | ||
| 237 | interactionMode: "default", | 244 | interactionMode: "default", |
| 238 | runtimeMode: "full-access", | 245 | runtimeMode: "full-access", |
| 239 | branch: "main", | 246 | branch: "main", |
| ... | @@ -287,7 +294,10 @@ function addThreadToSnapshot( | ... | @@ -287,7 +294,10 @@ function addThreadToSnapshot( |
| 287 | id: threadId, | 294 | id: threadId, |
| 288 | projectId: PROJECT_ID, | 295 | projectId: PROJECT_ID, |
| 289 | title: "New thread", | 296 | title: "New thread", |
| 290 | model: "gpt-5", | 297 | modelSelection: { |
| 298 | provider: "codex", | ||
| 299 | model: "gpt-5", | ||
| 300 | }, | ||
| 291 | interactionMode: "default", | 301 | interactionMode: "default", |
| 292 | runtimeMode: "full-access", | 302 | runtimeMode: "full-access", |
| 293 | branch: "main", | 303 | branch: "main", |
| ... | @@ -756,11 +766,14 @@ async function mountChatView(options: { | ... | @@ -756,11 +766,14 @@ async function mountChatView(options: { |
| 756 | 766 | ||
| 757 | await waitForLayout(); | 767 | await waitForLayout(); |
| 758 | 768 | ||
| 769 | const cleanup = async () => { | ||
| 770 | await screen.unmount(); | ||
| 771 | host.remove(); | ||
| 772 | }; | ||
| 773 | |||
| 759 | return { | 774 | return { |
| 760 | cleanup: async () => { | 775 | [Symbol.asyncDispose]: cleanup, |
| 761 | await screen.unmount(); | 776 | cleanup, |
| 762 | host.remove(); | ||
| 763 | }, | ||
| 764 | measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), | 777 | measureUserRow: async (targetMessageId: MessageId) => measureUserRow({ host, targetMessageId }), |
| 765 | setViewport: async (viewport: ViewportSpec) => { | 778 | setViewport: async (viewport: ViewportSpec) => { |
| 766 | await setViewport(viewport); | 779 | await setViewport(viewport); |
| ... | @@ -817,8 +830,8 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -817,8 +830,8 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 817 | draftsByThreadId: {}, | 830 | draftsByThreadId: {}, |
| 818 | draftThreadsByThreadId: {}, | 831 | draftThreadsByThreadId: {}, |
| 819 | projectDraftThreadIdByProjectId: {}, | 832 | projectDraftThreadIdByProjectId: {}, |
| 820 | stickyModel: null, | 833 | stickyModelSelectionByProvider: {}, |
| 821 | stickyModelOptions: {}, | 834 | stickyActiveProvider: null, |
| 822 | }); | 835 | }); |
| 823 | useStore.setState({ | 836 | useStore.setState({ |
| 824 | projects: [], | 837 | projects: [], |
| ... | @@ -1483,13 +1496,17 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1483,13 +1496,17 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1483 | 1496 | ||
| 1484 | it("snapshots sticky codex settings into a new draft thread", async () => { | 1497 | it("snapshots sticky codex settings into a new draft thread", async () => { |
| 1485 | useComposerDraftStore.setState({ | 1498 | useComposerDraftStore.setState({ |
| 1486 | stickyModel: "gpt-5.3-codex", | 1499 | stickyModelSelectionByProvider: { |
| 1487 | stickyModelOptions: { | ||
| 1488 | codex: { | 1500 | codex: { |
| 1489 | reasoningEffort: "medium", | 1501 | provider: "codex", |
| 1490 | fastMode: true, | 1502 | model: "gpt-5.3-codex", |
| 1503 | options: { | ||
| 1504 | reasoningEffort: "medium", | ||
| 1505 | fastMode: true, | ||
| 1506 | }, | ||
| 1491 | }, | 1507 | }, |
| 1492 | }, | 1508 | }, |
| 1509 | stickyActiveProvider: "codex", | ||
| 1493 | }); | 1510 | }); |
| 1494 | 1511 | ||
| 1495 | const mounted = await mountChatView({ | 1512 | const mounted = await mountChatView({ |
| ... | @@ -1514,13 +1531,16 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1514,13 +1531,16 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1514 | const newThreadId = newThreadPath.slice(1) as ThreadId; | 1531 | const newThreadId = newThreadPath.slice(1) as ThreadId; |
| 1515 | 1532 | ||
| 1516 | expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ | 1533 | expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ |
| 1517 | model: "gpt-5.3-codex", | 1534 | modelSelectionByProvider: { |
| 1518 | provider: "codex", | ||
| 1519 | modelOptions: { | ||
| 1520 | codex: { | 1535 | codex: { |
| 1521 | fastMode: true, | 1536 | provider: "codex", |
| 1537 | model: "gpt-5.3-codex", | ||
| 1538 | options: { | ||
| 1539 | fastMode: true, | ||
| 1540 | }, | ||
| 1522 | }, | 1541 | }, |
| 1523 | }, | 1542 | }, |
| 1543 | activeProvider: "codex", | ||
| 1524 | }); | 1544 | }); |
| 1525 | } finally { | 1545 | } finally { |
| 1526 | await mounted.cleanup(); | 1546 | await mounted.cleanup(); |
| ... | @@ -1529,13 +1549,17 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1529,13 +1549,17 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1529 | 1549 | ||
| 1530 | it("hydrates the provider alongside a sticky claude model", async () => { | 1550 | it("hydrates the provider alongside a sticky claude model", async () => { |
| 1531 | useComposerDraftStore.setState({ | 1551 | useComposerDraftStore.setState({ |
| 1532 | stickyModel: "claude-opus-4-6", | 1552 | stickyModelSelectionByProvider: { |
| 1533 | stickyModelOptions: { | ||
| 1534 | claudeAgent: { | 1553 | claudeAgent: { |
| 1535 | effort: "max", | 1554 | provider: "claudeAgent", |
| 1536 | fastMode: true, | 1555 | model: "claude-opus-4-6", |
| 1556 | options: { | ||
| 1557 | effort: "max", | ||
| 1558 | fastMode: true, | ||
| 1559 | }, | ||
| 1537 | }, | 1560 | }, |
| 1538 | }, | 1561 | }, |
| 1562 | stickyActiveProvider: "claudeAgent", | ||
| 1539 | }); | 1563 | }); |
| 1540 | 1564 | ||
| 1541 | const mounted = await mountChatView({ | 1565 | const mounted = await mountChatView({ |
| ... | @@ -1560,16 +1584,18 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1560,16 +1584,18 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1560 | const newThreadId = newThreadPath.slice(1) as ThreadId; | 1584 | const newThreadId = newThreadPath.slice(1) as ThreadId; |
| 1561 | 1585 | ||
| 1562 | expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ | 1586 | expect(useComposerDraftStore.getState().draftsByThreadId[newThreadId]).toMatchObject({ |
| 1563 | provider: "claudeAgent", | 1587 | modelSelectionByProvider: { |
| 1564 | model: "claude-opus-4-6", | ||
| 1565 | modelOptions: { | ||
| 1566 | claudeAgent: { | 1588 | claudeAgent: { |
| 1567 | effort: "max", | 1589 | provider: "claudeAgent", |
| 1568 | fastMode: true, | 1590 | model: "claude-opus-4-6", |
| 1591 | options: { | ||
| 1592 | effort: "max", | ||
| 1593 | fastMode: true, | ||
| 1594 | }, | ||
| 1569 | }, | 1595 | }, |
| 1570 | }, | 1596 | }, |
| 1597 | activeProvider: "claudeAgent", | ||
| 1571 | }); | 1598 | }); |
| 1572 | await expect.element(page.getByText("Claude Opus 4.6")).toBeInTheDocument(); | ||
| 1573 | } finally { | 1599 | } finally { |
| 1574 | await mounted.cleanup(); | 1600 | await mounted.cleanup(); |
| 1575 | } | 1601 | } |
| ... | @@ -1605,13 +1631,17 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1605,13 +1631,17 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1605 | 1631 | ||
| 1606 | it("prefers draft state over sticky composer settings and defaults", async () => { | 1632 | it("prefers draft state over sticky composer settings and defaults", async () => { |
| 1607 | useComposerDraftStore.setState({ | 1633 | useComposerDraftStore.setState({ |
| 1608 | stickyModel: "gpt-5.3-codex", | 1634 | stickyModelSelectionByProvider: { |
| 1609 | stickyModelOptions: { | ||
| 1610 | codex: { | 1635 | codex: { |
| 1611 | reasoningEffort: "medium", | 1636 | provider: "codex", |
| 1612 | fastMode: true, | 1637 | model: "gpt-5.3-codex", |
| 1638 | options: { | ||
| 1639 | reasoningEffort: "medium", | ||
| 1640 | fastMode: true, | ||
| 1641 | }, | ||
| 1613 | }, | 1642 | }, |
| 1614 | }, | 1643 | }, |
| 1644 | stickyActiveProvider: "codex", | ||
| 1615 | }); | 1645 | }); |
| 1616 | 1646 | ||
| 1617 | const mounted = await mountChatView({ | 1647 | const mounted = await mountChatView({ |
| ... | @@ -1636,17 +1666,22 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1636,17 +1666,22 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1636 | const threadId = threadPath.slice(1) as ThreadId; | 1666 | const threadId = threadPath.slice(1) as ThreadId; |
| 1637 | 1667 | ||
| 1638 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ | 1668 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ |
| 1639 | model: "gpt-5.3-codex", | 1669 | modelSelectionByProvider: { |
| 1640 | modelOptions: { | ||
| 1641 | codex: { | 1670 | codex: { |
| 1642 | fastMode: true, | 1671 | provider: "codex", |
| 1672 | model: "gpt-5.3-codex", | ||
| 1673 | options: { | ||
| 1674 | fastMode: true, | ||
| 1675 | }, | ||
| 1643 | }, | 1676 | }, |
| 1644 | }, | 1677 | }, |
| 1678 | activeProvider: "codex", | ||
| 1645 | }); | 1679 | }); |
| 1646 | 1680 | ||
| 1647 | useComposerDraftStore.getState().setModel(threadId, "gpt-5.4"); | 1681 | useComposerDraftStore.getState().setModelSelection(threadId, { |
| 1648 | useComposerDraftStore.getState().setModelOptions(threadId, { | 1682 | provider: "codex", |
| 1649 | codex: { | 1683 | model: "gpt-5.4", |
| 1684 | options: { | ||
| 1650 | reasoningEffort: "low", | 1685 | reasoningEffort: "low", |
| 1651 | fastMode: true, | 1686 | fastMode: true, |
| 1652 | }, | 1687 | }, |
| ... | @@ -1660,13 +1695,17 @@ describe("ChatView timeline estimator parity (full app)", () => { | ... | @@ -1660,13 +1695,17 @@ describe("ChatView timeline estimator parity (full app)", () => { |
| 1660 | "New-thread should reuse the existing project draft thread.", | 1695 | "New-thread should reuse the existing project draft thread.", |
| 1661 | ); | 1696 | ); |
| 1662 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ | 1697 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ |
| 1663 | model: "gpt-5.4", | 1698 | modelSelectionByProvider: { |
| 1664 | modelOptions: { | ||
| 1665 | codex: { | 1699 | codex: { |
| 1666 | reasoningEffort: "low", | 1700 | provider: "codex", |
| 1667 | fastMode: true, | 1701 | model: "gpt-5.4", |
| 1702 | options: { | ||
| 1703 | reasoningEffort: "low", | ||
| 1704 | fastMode: true, | ||
| 1705 | }, | ||
| 1668 | }, | 1706 | }, |
| 1669 | }, | 1707 | }, |
| 1708 | activeProvider: "codex", | ||
| 1670 | }); | 1709 | }); |
| 1671 | } finally { | 1710 | } finally { |
| 1672 | await mounted.cleanup(); | 1711 | await mounted.cleanup(); |
apps/web/src/components/ChatView.logic.ts+3-3| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ProjectId, type ThreadId } from "@t3tools/contracts"; | 1 | import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts"; |
| 2 | import { type ChatMessage, type Thread } from "../types"; | 2 | import { type ChatMessage, type Thread } from "../types"; |
| 3 | import { randomUUID } from "~/lib/utils"; | 3 | import { randomUUID } from "~/lib/utils"; |
| 4 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; | 4 | import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; |
| ... | @@ -17,7 +17,7 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema. | ... | @@ -17,7 +17,7 @@ export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema. |
| 17 | export function buildLocalDraftThread( | 17 | export function buildLocalDraftThread( |
| 18 | threadId: ThreadId, | 18 | threadId: ThreadId, |
| 19 | draftThread: DraftThreadState, | 19 | draftThread: DraftThreadState, |
| 20 | fallbackModel: string, | 20 | fallbackModelSelection: ModelSelection, |
| 21 | error: string | null, | 21 | error: string | null, |
| 22 | ): Thread { | 22 | ): Thread { |
| 23 | return { | 23 | return { |
| ... | @@ -25,7 +25,7 @@ export function buildLocalDraftThread( | ... | @@ -25,7 +25,7 @@ export function buildLocalDraftThread( |
| 25 | codexThreadId: null, | 25 | codexThreadId: null, |
| 26 | projectId: draftThread.projectId, | 26 | projectId: draftThread.projectId, |
| 27 | title: "New thread", | 27 | title: "New thread", |
| 28 | model: fallbackModel, | 28 | modelSelection: fallbackModelSelection, |
| 29 | runtimeMode: draftThread.runtimeMode, | 29 | runtimeMode: draftThread.runtimeMode, |
| 30 | interactionMode: draftThread.interactionMode, | 30 | interactionMode: draftThread.interactionMode, |
| 31 | session: null, | 31 | session: null, |
apps/web/src/components/ChatView.tsx+82-64| ... | @@ -3,6 +3,7 @@ import { | ... | @@ -3,6 +3,7 @@ import { |
| 3 | DEFAULT_MODEL_BY_PROVIDER, | 3 | DEFAULT_MODEL_BY_PROVIDER, |
| 4 | type ClaudeCodeEffort, | 4 | type ClaudeCodeEffort, |
| 5 | type MessageId, | 5 | type MessageId, |
| 6 | type ModelSelection, | ||
| 6 | type ProjectScript, | 7 | type ProjectScript, |
| 7 | type ModelSlug, | 8 | type ModelSlug, |
| 8 | type ProviderKind, | 9 | type ProviderKind, |
| ... | @@ -23,9 +24,8 @@ import { | ... | @@ -23,9 +24,8 @@ import { |
| 23 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 24 | import { | 25 | import { |
| 25 | applyClaudePromptEffortPrefix, | 26 | applyClaudePromptEffortPrefix, |
| 26 | getDefaultModel, | 27 | getModelCapabilities, |
| 27 | normalizeModelSlug, | 28 | normalizeModelSlug, |
| 28 | resolveModelSlugForProvider, | ||
| 29 | } from "@t3tools/shared/model"; | 29 | } from "@t3tools/shared/model"; |
| 30 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | 30 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 31 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 31 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| ... | @@ -133,6 +133,7 @@ import { | ... | @@ -133,6 +133,7 @@ import { |
| 133 | type DraftThreadEnvMode, | 133 | type DraftThreadEnvMode, |
| 134 | type PersistedComposerImageAttachment, | 134 | type PersistedComposerImageAttachment, |
| 135 | useComposerDraftStore, | 135 | useComposerDraftStore, |
| 136 | useEffectiveComposerModelState, | ||
| 136 | useComposerThreadDraft, | 137 | useComposerThreadDraft, |
| 137 | } from "../composerDraftStore"; | 138 | } from "../composerDraftStore"; |
| 138 | import { | 139 | import { |
| ... | @@ -196,10 +197,12 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw | ... | @@ -196,10 +197,12 @@ const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnsw |
| 196 | 197 | ||
| 197 | function formatOutgoingPrompt(params: { | 198 | function formatOutgoingPrompt(params: { |
| 198 | provider: ProviderKind; | 199 | provider: ProviderKind; |
| 200 | model: string | null; | ||
| 199 | effort: string | null; | 201 | effort: string | null; |
| 200 | text: string; | 202 | text: string; |
| 201 | }): string { | 203 | }): string { |
| 202 | if (params.provider === "claudeAgent" && params.effort === "ultrathink") { | 204 | const caps = getModelCapabilities(params.provider, params.model); |
| 205 | if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { | ||
| 203 | return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); | 206 | return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); |
| 204 | } | 207 | } |
| 205 | return params.text; | 208 | return params.text; |
| ... | @@ -248,7 +251,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -248,7 +251,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 248 | const setStoreThreadError = useStore((store) => store.setError); | 251 | const setStoreThreadError = useStore((store) => store.setError); |
| 249 | const setStoreThreadBranch = useStore((store) => store.setThreadBranch); | 252 | const setStoreThreadBranch = useStore((store) => store.setThreadBranch); |
| 250 | const { settings } = useAppSettings(); | 253 | const { settings } = useAppSettings(); |
| 251 | const setStickyComposerModel = useComposerDraftStore((store) => store.setStickyModel); | 254 | const setStickyComposerModelSelection = useComposerDraftStore( |
| 255 | (store) => store.setStickyModelSelection, | ||
| 256 | ); | ||
| 252 | const timestampFormat = settings.timestampFormat; | 257 | const timestampFormat = settings.timestampFormat; |
| 253 | const navigate = useNavigate(); | 258 | const navigate = useNavigate(); |
| 254 | const rawSearch = useSearch({ | 259 | const rawSearch = useSearch({ |
| ... | @@ -273,8 +278,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -273,8 +278,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 273 | ); | 278 | ); |
| 274 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; | 279 | const nonPersistedComposerImageIds = composerDraft.nonPersistedImageIds; |
| 275 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); | 280 | const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); |
| 276 | const setComposerDraftProvider = useComposerDraftStore((store) => store.setProvider); | 281 | const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); |
| 277 | const setComposerDraftModel = useComposerDraftStore((store) => store.setModel); | ||
| 278 | const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); | 282 | const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); |
| 279 | const setComposerDraftInteractionMode = useComposerDraftStore( | 283 | const setComposerDraftInteractionMode = useComposerDraftStore( |
| 280 | (store) => store.setInteractionMode, | 284 | (store) => store.setInteractionMode, |
| ... | @@ -467,11 +471,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -467,11 +471,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 467 | ? buildLocalDraftThread( | 471 | ? buildLocalDraftThread( |
| 468 | threadId, | 472 | threadId, |
| 469 | draftThread, | 473 | draftThread, |
| 470 | fallbackDraftProject?.model ?? DEFAULT_MODEL_BY_PROVIDER.codex, | 474 | fallbackDraftProject?.defaultModelSelection ?? { |
| 475 | provider: "codex", | ||
| 476 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | ||
| 477 | }, | ||
| 471 | localDraftError, | 478 | localDraftError, |
| 472 | ) | 479 | ) |
| 473 | : undefined, | 480 | : undefined, |
| 474 | [draftThread, fallbackDraftProject?.model, localDraftError, threadId], | 481 | [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], |
| 475 | ); | 482 | ); |
| 476 | const activeThread = serverThread ?? localDraftThread; | 483 | const activeThread = serverThread ?? localDraftThread; |
| 477 | const runtimeMode = | 484 | const runtimeMode = |
| ... | @@ -590,7 +597,9 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -590,7 +597,9 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 590 | ]); | 597 | ]); |
| 591 | 598 | ||
| 592 | const sessionProvider = activeThread?.session?.provider ?? null; | 599 | const sessionProvider = activeThread?.session?.provider ?? null; |
| 593 | const selectedProviderByThreadId = composerDraft.provider; | 600 | const selectedProviderByThreadId = composerDraft.activeProvider ?? null; |
| 601 | const threadProvider = | ||
| 602 | activeThread?.modelSelection.provider ?? activeProject?.defaultModelSelection?.provider ?? null; | ||
| 594 | const hasThreadStarted = Boolean( | 603 | const hasThreadStarted = Boolean( |
| 595 | activeThread && | 604 | activeThread && |
| 596 | (activeThread.latestTurn !== null || | 605 | (activeThread.latestTurn !== null || |
| ... | @@ -598,34 +607,38 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -598,34 +607,38 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 598 | activeThread.session !== null), | 607 | activeThread.session !== null), |
| 599 | ); | 608 | ); |
| 600 | const lockedProvider: ProviderKind | null = hasThreadStarted | 609 | const lockedProvider: ProviderKind | null = hasThreadStarted |
| 601 | ? (sessionProvider ?? selectedProviderByThreadId ?? null) | 610 | ? (sessionProvider ?? threadProvider ?? selectedProviderByThreadId ?? null) |
| 602 | : null; | 611 | : null; |
| 603 | const selectedProvider: ProviderKind = lockedProvider ?? selectedProviderByThreadId ?? "codex"; | 612 | const selectedProvider: ProviderKind = |
| 604 | const baseThreadModel = resolveModelSlugForProvider( | 613 | lockedProvider ?? selectedProviderByThreadId ?? threadProvider ?? "codex"; |
| 605 | selectedProvider, | ||
| 606 | activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), | ||
| 607 | ); | ||
| 608 | const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); | 614 | const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); |
| 609 | const selectedModel = useMemo(() => { | 615 | const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ |
| 610 | const draftModel = composerDraft.model; | 616 | threadId, |
| 611 | if (!draftModel) { | 617 | selectedProvider, |
| 612 | return baseThreadModel; | 618 | threadModelSelection: activeThread?.modelSelection, |
| 613 | } | 619 | projectModelSelection: activeProject?.defaultModelSelection, |
| 614 | return resolveAppModelSelection(selectedProvider, customModelsByProvider, draftModel); | 620 | customModelsByProvider, |
| 615 | }, [baseThreadModel, composerDraft.model, customModelsByProvider, selectedProvider]); | 621 | }); |
| 616 | const draftModelOptions = composerDraft.modelOptions; | ||
| 617 | const composerProviderState = useMemo( | 622 | const composerProviderState = useMemo( |
| 618 | () => | 623 | () => |
| 619 | getComposerProviderState({ | 624 | getComposerProviderState({ |
| 620 | provider: selectedProvider, | 625 | provider: selectedProvider, |
| 621 | model: selectedModel, | 626 | model: selectedModel, |
| 622 | prompt, | 627 | prompt, |
| 623 | modelOptions: draftModelOptions, | 628 | modelOptions: composerModelOptions, |
| 624 | }), | 629 | }), |
| 625 | [draftModelOptions, prompt, selectedModel, selectedProvider], | 630 | [composerModelOptions, prompt, selectedModel, selectedProvider], |
| 626 | ); | 631 | ); |
| 627 | const selectedPromptEffort = composerProviderState.promptEffort; | 632 | const selectedPromptEffort = composerProviderState.promptEffort; |
| 628 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; | 633 | const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; |
| 634 | const selectedModelSelection = useMemo<ModelSelection>( | ||
| 635 | () => ({ | ||
| 636 | provider: selectedProvider, | ||
| 637 | model: selectedModel, | ||
| 638 | ...(selectedModelOptionsForDispatch ? { options: selectedModelOptionsForDispatch } : {}), | ||
| 639 | }), | ||
| 640 | [selectedModel, selectedModelOptionsForDispatch, selectedProvider], | ||
| 641 | ); | ||
| 629 | const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); | 642 | const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); |
| 630 | const selectedModelForPicker = selectedModel; | 643 | const selectedModelForPicker = selectedModel; |
| 631 | const modelOptionsByProvider = useMemo( | 644 | const modelOptionsByProvider = useMemo( |
| ... | @@ -1594,7 +1607,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1594,7 +1607,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1594 | async (input: { | 1607 | async (input: { |
| 1595 | threadId: ThreadId; | 1608 | threadId: ThreadId; |
| 1596 | createdAt: string; | 1609 | createdAt: string; |
| 1597 | model?: string; | 1610 | modelSelection?: ModelSelection; |
| 1598 | runtimeMode: RuntimeMode; | 1611 | runtimeMode: RuntimeMode; |
| 1599 | interactionMode: ProviderInteractionMode; | 1612 | interactionMode: ProviderInteractionMode; |
| 1600 | }) => { | 1613 | }) => { |
| ... | @@ -1606,12 +1619,18 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -1606,12 +1619,18 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1606 | return; | 1619 | return; |
| 1607 | } | 1620 | } |
| 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 | await api.orchestration.dispatchCommand({ | 1629 | await api.orchestration.dispatchCommand({ |
| 1611 | type: "thread.meta.update", | 1630 | type: "thread.meta.update", |
| 1612 | commandId: newCommandId(), | 1631 | commandId: newCommandId(), |
| 1613 | threadId: input.threadId, | 1632 | threadId: input.threadId, |
| 1614 | model: input.model, | 1633 | modelSelection: input.modelSelection, |
| 1615 | }); | 1634 | }); |
| 1616 | } | 1635 | } |
| 1617 | 1636 | ||
| ... | @@ -2441,6 +2460,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2441,6 +2460,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2441 | const messageCreatedAt = new Date().toISOString(); | 2460 | const messageCreatedAt = new Date().toISOString(); |
| 2442 | const outgoingMessageText = formatOutgoingPrompt({ | 2461 | const outgoingMessageText = formatOutgoingPrompt({ |
| 2443 | provider: selectedProvider, | 2462 | provider: selectedProvider, |
| 2463 | model: selectedModel, | ||
| 2444 | effort: selectedPromptEffort, | 2464 | effort: selectedPromptEffort, |
| 2445 | text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, | 2465 | text: messageTextForSend || IMAGE_ONLY_BOOTSTRAP_PROMPT, |
| 2446 | }); | 2466 | }); |
| ... | @@ -2542,8 +2562,14 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2542,8 +2562,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2542 | } | 2562 | } |
| 2543 | } | 2563 | } |
| 2544 | const title = truncateTitle(titleSeed); | 2564 | const title = truncateTitle(titleSeed); |
| 2545 | let threadCreateModel: ModelSlug = | 2565 | const threadCreateModelSelection: ModelSelection = { |
| 2546 | selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; | 2566 | provider: selectedProvider, |
| 2567 | model: | ||
| 2568 | selectedModel || | ||
| 2569 | activeProject.defaultModelSelection?.model || | ||
| 2570 | DEFAULT_MODEL_BY_PROVIDER.codex, | ||
| 2571 | ...(selectedModelSelection.options ? { options: selectedModelSelection.options } : {}), | ||
| 2572 | }; | ||
| 2547 | 2573 | ||
| 2548 | if (isLocalDraftThread) { | 2574 | if (isLocalDraftThread) { |
| 2549 | await api.orchestration.dispatchCommand({ | 2575 | await api.orchestration.dispatchCommand({ |
| ... | @@ -2552,7 +2578,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2552,7 +2578,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2552 | threadId: threadIdForSend, | 2578 | threadId: threadIdForSend, |
| 2553 | projectId: activeProject.id, | 2579 | projectId: activeProject.id, |
| 2554 | title, | 2580 | title, |
| 2555 | model: threadCreateModel, | 2581 | modelSelection: threadCreateModelSelection, |
| 2556 | runtimeMode, | 2582 | runtimeMode, |
| 2557 | interactionMode, | 2583 | interactionMode, |
| 2558 | branch: nextThreadBranch, | 2584 | branch: nextThreadBranch, |
| ... | @@ -2601,7 +2627,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2601,7 +2627,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2601 | await persistThreadSettingsForNextTurn({ | 2627 | await persistThreadSettingsForNextTurn({ |
| 2602 | threadId: threadIdForSend, | 2628 | threadId: threadIdForSend, |
| 2603 | createdAt: messageCreatedAt, | 2629 | createdAt: messageCreatedAt, |
| 2604 | ...(selectedModel ? { model: selectedModel } : {}), | 2630 | ...(selectedModel ? { modelSelection: selectedModelSelection } : {}), |
| 2605 | runtimeMode, | 2631 | runtimeMode, |
| 2606 | interactionMode, | 2632 | interactionMode, |
| 2607 | }); | 2633 | }); |
| ... | @@ -2619,12 +2645,8 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2619,12 +2645,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2619 | text: outgoingMessageText, | 2645 | text: outgoingMessageText, |
| 2620 | attachments: turnAttachments, | 2646 | attachments: turnAttachments, |
| 2621 | }, | 2647 | }, |
| 2622 | model: selectedModel || undefined, | 2648 | modelSelection: selectedModelSelection, |
| 2623 | ...(selectedModelOptionsForDispatch | ||
| 2624 | ? { modelOptions: selectedModelOptionsForDispatch } | ||
| 2625 | : {}), | ||
| 2626 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), | 2649 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), |
| 2627 | provider: selectedProvider, | ||
| 2628 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | 2650 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", |
| 2629 | runtimeMode, | 2651 | runtimeMode, |
| 2630 | interactionMode, | 2652 | interactionMode, |
| ... | @@ -2861,6 +2883,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2861,6 +2883,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2861 | const messageCreatedAt = new Date().toISOString(); | 2883 | const messageCreatedAt = new Date().toISOString(); |
| 2862 | const outgoingMessageText = formatOutgoingPrompt({ | 2884 | const outgoingMessageText = formatOutgoingPrompt({ |
| 2863 | provider: selectedProvider, | 2885 | provider: selectedProvider, |
| 2886 | model: selectedModel, | ||
| 2864 | effort: selectedPromptEffort, | 2887 | effort: selectedPromptEffort, |
| 2865 | text: trimmed, | 2888 | text: trimmed, |
| 2866 | }); | 2889 | }); |
| ... | @@ -2885,7 +2908,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2885,7 +2908,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2885 | await persistThreadSettingsForNextTurn({ | 2908 | await persistThreadSettingsForNextTurn({ |
| 2886 | threadId: threadIdForSend, | 2909 | threadId: threadIdForSend, |
| 2887 | createdAt: messageCreatedAt, | 2910 | createdAt: messageCreatedAt, |
| 2888 | ...(selectedModel ? { model: selectedModel } : {}), | 2911 | modelSelection: selectedModelSelection, |
| 2889 | runtimeMode, | 2912 | runtimeMode, |
| 2890 | interactionMode: nextInteractionMode, | 2913 | interactionMode: nextInteractionMode, |
| 2891 | }); | 2914 | }); |
| ... | @@ -2904,11 +2927,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2904,11 +2927,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2904 | text: outgoingMessageText, | 2927 | text: outgoingMessageText, |
| 2905 | attachments: [], | 2928 | attachments: [], |
| 2906 | }, | 2929 | }, |
| 2907 | provider: selectedProvider, | 2930 | modelSelection: selectedModelSelection, |
| 2908 | model: selectedModel || undefined, | ||
| 2909 | ...(selectedModelOptionsForDispatch | ||
| 2910 | ? { modelOptions: selectedModelOptionsForDispatch } | ||
| 2911 | : {}), | ||
| 2912 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), | 2931 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), |
| 2913 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | 2932 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", |
| 2914 | runtimeMode, | 2933 | runtimeMode, |
| ... | @@ -2955,13 +2974,13 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2955,13 +2974,13 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2955 | resetSendPhase, | 2974 | resetSendPhase, |
| 2956 | runtimeMode, | 2975 | runtimeMode, |
| 2957 | selectedPromptEffort, | 2976 | selectedPromptEffort, |
| 2958 | selectedModel, | 2977 | selectedModelSelection, |
| 2959 | selectedModelOptionsForDispatch, | ||
| 2960 | providerOptionsForDispatch, | 2978 | providerOptionsForDispatch, |
| 2961 | selectedProvider, | 2979 | selectedProvider, |
| 2962 | setComposerDraftInteractionMode, | 2980 | setComposerDraftInteractionMode, |
| 2963 | setThreadError, | 2981 | setThreadError, |
| 2964 | settings.enableAssistantStreaming, | 2982 | settings.enableAssistantStreaming, |
| 2983 | selectedModel, | ||
| 2965 | ], | 2984 | ], |
| 2966 | ); | 2985 | ); |
| 2967 | 2986 | ||
| ... | @@ -2986,15 +3005,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -2986,15 +3005,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2986 | const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); | 3005 | const implementationPrompt = buildPlanImplementationPrompt(planMarkdown); |
| 2987 | const outgoingImplementationPrompt = formatOutgoingPrompt({ | 3006 | const outgoingImplementationPrompt = formatOutgoingPrompt({ |
| 2988 | provider: selectedProvider, | 3007 | provider: selectedProvider, |
| 3008 | model: selectedModel, | ||
| 2989 | effort: selectedPromptEffort, | 3009 | effort: selectedPromptEffort, |
| 2990 | text: implementationPrompt, | 3010 | text: implementationPrompt, |
| 2991 | }); | 3011 | }); |
| 2992 | const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); | 3012 | const nextThreadTitle = truncateTitle(buildPlanImplementationThreadTitle(planMarkdown)); |
| 2993 | const nextThreadModel: ModelSlug = | 3013 | const nextThreadModelSelection: ModelSelection = selectedModelSelection; |
| 2994 | selectedModel || | ||
| 2995 | (activeThread.model as ModelSlug) || | ||
| 2996 | (activeProject.model as ModelSlug) || | ||
| 2997 | DEFAULT_MODEL_BY_PROVIDER.codex; | ||
| 2998 | 3014 | ||
| 2999 | sendInFlightRef.current = true; | 3015 | sendInFlightRef.current = true; |
| 3000 | beginSendPhase("sending-turn"); | 3016 | beginSendPhase("sending-turn"); |
| ... | @@ -3010,7 +3026,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3010,7 +3026,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3010 | threadId: nextThreadId, | 3026 | threadId: nextThreadId, |
| 3011 | projectId: activeProject.id, | 3027 | projectId: activeProject.id, |
| 3012 | title: nextThreadTitle, | 3028 | title: nextThreadTitle, |
| 3013 | model: nextThreadModel, | 3029 | modelSelection: nextThreadModelSelection, |
| 3014 | runtimeMode, | 3030 | runtimeMode, |
| 3015 | interactionMode: "default", | 3031 | interactionMode: "default", |
| 3016 | branch: activeThread.branch, | 3032 | branch: activeThread.branch, |
| ... | @@ -3028,11 +3044,7 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3028,11 +3044,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3028 | text: outgoingImplementationPrompt, | 3044 | text: outgoingImplementationPrompt, |
| 3029 | attachments: [], | 3045 | attachments: [], |
| 3030 | }, | 3046 | }, |
| 3031 | provider: selectedProvider, | 3047 | modelSelection: selectedModelSelection, |
| 3032 | model: selectedModel || undefined, | ||
| 3033 | ...(selectedModelOptionsForDispatch | ||
| 3034 | ? { modelOptions: selectedModelOptionsForDispatch } | ||
| 3035 | : {}), | ||
| 3036 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), | 3048 | ...(providerOptionsForDispatch ? { providerOptions: providerOptionsForDispatch } : {}), |
| 3037 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", | 3049 | assistantDeliveryMode: settings.enableAssistantStreaming ? "streaming" : "buffered", |
| 3038 | runtimeMode, | 3050 | runtimeMode, |
| ... | @@ -3084,12 +3096,12 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3084,12 +3096,12 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3084 | resetSendPhase, | 3096 | resetSendPhase, |
| 3085 | runtimeMode, | 3097 | runtimeMode, |
| 3086 | selectedPromptEffort, | 3098 | selectedPromptEffort, |
| 3087 | selectedModel, | 3099 | selectedModelSelection, |
| 3088 | selectedModelOptionsForDispatch, | ||
| 3089 | providerOptionsForDispatch, | 3100 | providerOptionsForDispatch, |
| 3090 | selectedProvider, | 3101 | selectedProvider, |
| 3091 | settings.enableAssistantStreaming, | 3102 | settings.enableAssistantStreaming, |
| 3092 | syncServerReadModel, | 3103 | syncServerReadModel, |
| 3104 | selectedModel, | ||
| 3093 | ]); | 3105 | ]); |
| 3094 | 3106 | ||
| 3095 | const onProviderModelSelect = useCallback( | 3107 | const onProviderModelSelect = useCallback( |
| ... | @@ -3100,18 +3112,20 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3100,18 +3112,20 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3100 | return; | 3112 | return; |
| 3101 | } | 3113 | } |
| 3102 | const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); | 3114 | const resolvedModel = resolveAppModelSelection(provider, customModelsByProvider, model); |
| 3103 | setComposerDraftProvider(activeThread.id, provider); | 3115 | const nextModelSelection: ModelSelection = { |
| 3104 | setComposerDraftModel(activeThread.id, resolvedModel); | 3116 | provider, |
| 3105 | setStickyComposerModel(resolvedModel); | 3117 | model: resolvedModel, |
| 3118 | }; | ||
| 3119 | setComposerDraftModelSelection(activeThread.id, nextModelSelection); | ||
| 3120 | setStickyComposerModelSelection(nextModelSelection); | ||
| 3106 | scheduleComposerFocus(); | 3121 | scheduleComposerFocus(); |
| 3107 | }, | 3122 | }, |
| 3108 | [ | 3123 | [ |
| 3109 | activeThread, | 3124 | activeThread, |
| 3110 | lockedProvider, | 3125 | lockedProvider, |
| 3111 | scheduleComposerFocus, | 3126 | scheduleComposerFocus, |
| 3112 | setComposerDraftModel, | 3127 | setComposerDraftModelSelection, |
| 3113 | setComposerDraftProvider, | 3128 | setStickyComposerModelSelection, |
| 3114 | setStickyComposerModel, | ||
| 3115 | customModelsByProvider, | 3129 | customModelsByProvider, |
| 3116 | ], | 3130 | ], |
| 3117 | ); | 3131 | ); |
| ... | @@ -3135,12 +3149,16 @@ export default function ChatView({ threadId }: ChatViewProps) { | ... | @@ -3135,12 +3149,16 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 3135 | provider: selectedProvider, | 3149 | provider: selectedProvider, |
| 3136 | threadId, | 3150 | threadId, |
| 3137 | model: selectedModel, | 3151 | model: selectedModel, |
| 3152 | modelOptions: composerModelOptions?.[selectedProvider], | ||
| 3153 | prompt, | ||
| 3138 | onPromptChange: setPromptFromTraits, | 3154 | onPromptChange: setPromptFromTraits, |
| 3139 | }); | 3155 | }); |
| 3140 | const providerTraitsPicker = renderProviderTraitsPicker({ | 3156 | const providerTraitsPicker = renderProviderTraitsPicker({ |
| 3141 | provider: selectedProvider, | 3157 | provider: selectedProvider, |
| 3142 | threadId, | 3158 | threadId, |
| 3143 | model: selectedModel, | 3159 | model: selectedModel, |
| 3160 | modelOptions: composerModelOptions?.[selectedProvider], | ||
| 3161 | prompt, | ||
| 3144 | onPromptChange: setPromptFromTraits, | 3162 | onPromptChange: setPromptFromTraits, |
| 3145 | }); | 3163 | }); |
| 3146 | const onEnvModeChange = useCallback( | 3164 | const onEnvModeChange = useCallback( |
apps/web/src/components/KeybindingsToast.browser.tsx+8-2| ... | @@ -64,7 +64,10 @@ function createMinimalSnapshot(): OrchestrationReadModel { | ... | @@ -64,7 +64,10 @@ function createMinimalSnapshot(): OrchestrationReadModel { |
| 64 | id: PROJECT_ID, | 64 | id: PROJECT_ID, |
| 65 | title: "Project", | 65 | title: "Project", |
| 66 | workspaceRoot: "/repo/project", | 66 | workspaceRoot: "/repo/project", |
| 67 | defaultModel: "gpt-5", | 67 | defaultModelSelection: { |
| 68 | provider: "codex", | ||
| 69 | model: "gpt-5", | ||
| 70 | }, | ||
| 68 | scripts: [], | 71 | scripts: [], |
| 69 | createdAt: NOW_ISO, | 72 | createdAt: NOW_ISO, |
| 70 | updatedAt: NOW_ISO, | 73 | updatedAt: NOW_ISO, |
| ... | @@ -76,7 +79,10 @@ function createMinimalSnapshot(): OrchestrationReadModel { | ... | @@ -76,7 +79,10 @@ function createMinimalSnapshot(): OrchestrationReadModel { |
| 76 | id: THREAD_ID, | 79 | id: THREAD_ID, |
| 77 | projectId: PROJECT_ID, | 80 | projectId: PROJECT_ID, |
| 78 | title: "Test thread", | 81 | title: "Test thread", |
| 79 | model: "gpt-5", | 82 | modelSelection: { |
| 83 | provider: "codex", | ||
| 84 | model: "gpt-5", | ||
| 85 | }, | ||
| 80 | interactionMode: "default", | 86 | interactionMode: "default", |
| 81 | runtimeMode: "full-access", | 87 | runtimeMode: "full-access", |
| 82 | branch: "main", | 88 | branch: "main", |
apps/web/src/components/Sidebar.logic.test.ts+12-3| ... | @@ -343,16 +343,21 @@ describe("getVisibleThreadsForProject", () => { | ... | @@ -343,16 +343,21 @@ describe("getVisibleThreadsForProject", () => { |
| 343 | }); | 343 | }); |
| 344 | 344 | ||
| 345 | function makeProject(overrides: Partial<Project> = {}): Project { | 345 | function makeProject(overrides: Partial<Project> = {}): Project { |
| 346 | const { defaultModelSelection, ...rest } = overrides; | ||
| 346 | return { | 347 | return { |
| 347 | id: ProjectId.makeUnsafe("project-1"), | 348 | id: ProjectId.makeUnsafe("project-1"), |
| 348 | name: "Project", | 349 | name: "Project", |
| 349 | cwd: "/tmp/project", | 350 | cwd: "/tmp/project", |
| 350 | model: "gpt-5.4", | 351 | defaultModelSelection: { |
| 352 | provider: "codex", | ||
| 353 | model: "gpt-5.4", | ||
| 354 | ...defaultModelSelection, | ||
| 355 | }, | ||
| 351 | expanded: true, | 356 | expanded: true, |
| 352 | createdAt: "2026-03-09T10:00:00.000Z", | 357 | createdAt: "2026-03-09T10:00:00.000Z", |
| 353 | updatedAt: "2026-03-09T10:00:00.000Z", | 358 | updatedAt: "2026-03-09T10:00:00.000Z", |
| 354 | scripts: [], | 359 | scripts: [], |
| 355 | ...overrides, | 360 | ...rest, |
| 356 | }; | 361 | }; |
| 357 | } | 362 | } |
| 358 | 363 | ||
| ... | @@ -362,7 +367,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -362,7 +367,11 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 362 | codexThreadId: null, | 367 | codexThreadId: null, |
| 363 | projectId: ProjectId.makeUnsafe("project-1"), | 368 | projectId: ProjectId.makeUnsafe("project-1"), |
| 364 | title: "Thread", | 369 | title: "Thread", |
| 365 | model: "gpt-5.4", | 370 | modelSelection: { |
| 371 | provider: "codex", | ||
| 372 | model: "gpt-5.4", | ||
| 373 | ...overrides?.modelSelection, | ||
| 374 | }, | ||
| 366 | runtimeMode: DEFAULT_RUNTIME_MODE, | 375 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 367 | interactionMode: DEFAULT_INTERACTION_MODE, | 376 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 368 | session: null, | 377 | session: null, |
apps/web/src/components/Sidebar.tsx+4-1| ... | @@ -539,7 +539,10 @@ export default function Sidebar() { | ... | @@ -539,7 +539,10 @@ export default function Sidebar() { |
| 539 | projectId, | 539 | projectId, |
| 540 | title, | 540 | title, |
| 541 | workspaceRoot: cwd, | 541 | workspaceRoot: cwd, |
| 542 | defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, | 542 | defaultModelSelection: { |
| 543 | provider: "codex", | ||
| 544 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | ||
| 545 | }, | ||
| 543 | createdAt, | 546 | createdAt, |
| 544 | }); | 547 | }); |
| 545 | await handleNewThread(projectId, { | 548 | await handleNewThread(projectId, { |
apps/web/src/components/chat/ClaudeTraitsPicker.browser.tsx deleted-197| ... | @@ -1,197 +0,0 @@ | ||
| 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,4 +1,4 @@ |
| 1 | import { type ProviderModelOptions, ThreadId } from "@t3tools/contracts"; | 1 | import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts"; |
| 2 | import "../../index.css"; | 2 | import "../../index.css"; |
| 3 | 3 | ||
| 4 | import { page } from "vitest/browser"; | 4 | import { page } from "vitest/browser"; |
| ... | @@ -6,30 +6,31 @@ import { afterEach, describe, expect, it, vi } from "vitest"; | ... | @@ -6,30 +6,31 @@ import { afterEach, describe, expect, it, vi } from "vitest"; |
| 6 | import { render } from "vitest-browser-react"; | 6 | import { render } from "vitest-browser-react"; |
| 7 | 7 | ||
| 8 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; | 8 | import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; |
| 9 | import { ClaudeTraitsMenuContent } from "./ClaudeTraitsPicker"; | 9 | import { TraitsMenuContent } from "./TraitsPicker"; |
| 10 | import { CodexTraitsMenuContent } from "./CodexTraitsPicker"; | ||
| 11 | import { useComposerDraftStore } from "../../composerDraftStore"; | 10 | import { useComposerDraftStore } from "../../composerDraftStore"; |
| 12 | 11 | ||
| 13 | async function mountMenu(props?: { | 12 | async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: string }) { |
| 14 | model?: string; | ||
| 15 | prompt?: string; | ||
| 16 | provider?: "codex" | "claudeAgent"; | ||
| 17 | modelOptions?: ProviderModelOptions | null; | ||
| 18 | }) { | ||
| 19 | const threadId = ThreadId.makeUnsafe("thread-compact-menu"); | 13 | const threadId = ThreadId.makeUnsafe("thread-compact-menu"); |
| 20 | const provider = props?.provider ?? "claudeAgent"; | 14 | const provider = props?.modelSelection?.provider ?? "claudeAgent"; |
| 21 | const draftsByThreadId = {} as ReturnType< | 15 | const draftsByThreadId = {} as ReturnType< |
| 22 | typeof useComposerDraftStore.getState | 16 | typeof useComposerDraftStore.getState |
| 23 | >["draftsByThreadId"]; | 17 | >["draftsByThreadId"]; |
| 18 | const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; | ||
| 19 | |||
| 24 | draftsByThreadId[threadId] = { | 20 | draftsByThreadId[threadId] = { |
| 25 | prompt: props?.prompt ?? "", | 21 | prompt: props?.prompt ?? "", |
| 26 | images: [], | 22 | images: [], |
| 27 | nonPersistedImageIds: [], | 23 | nonPersistedImageIds: [], |
| 28 | persistedAttachments: [], | 24 | persistedAttachments: [], |
| 29 | terminalContexts: [], | 25 | terminalContexts: [], |
| 30 | provider, | 26 | modelSelectionByProvider: { |
| 31 | model: props?.model ?? "claude-opus-4-6", | 27 | [provider]: { |
| 32 | modelOptions: props?.modelOptions ?? null, | 28 | provider, |
| 29 | model, | ||
| 30 | ...(props?.modelSelection?.options ? { options: props.modelSelection.options } : {}), | ||
| 31 | }, | ||
| 32 | }, | ||
| 33 | activeProvider: provider, | ||
| 33 | runtimeMode: null, | 34 | runtimeMode: null, |
| 34 | interactionMode: null, | 35 | interactionMode: null, |
| 35 | }; | 36 | }; |
| ... | @@ -41,6 +42,7 @@ async function mountMenu(props?: { | ... | @@ -41,6 +42,7 @@ async function mountMenu(props?: { |
| 41 | const host = document.createElement("div"); | 42 | const host = document.createElement("div"); |
| 42 | document.body.append(host); | 43 | document.body.append(host); |
| 43 | const onPromptChange = vi.fn(); | 44 | const onPromptChange = vi.fn(); |
| 45 | const providerOptions = props?.modelSelection?.options; | ||
| 44 | const screen = await render( | 46 | const screen = await render( |
| 45 | <CompactComposerControlsMenu | 47 | <CompactComposerControlsMenu |
| 46 | activePlan={false} | 48 | activePlan={false} |
| ... | @@ -48,15 +50,14 @@ async function mountMenu(props?: { | ... | @@ -48,15 +50,14 @@ async function mountMenu(props?: { |
| 48 | planSidebarOpen={false} | 50 | planSidebarOpen={false} |
| 49 | runtimeMode="approval-required" | 51 | runtimeMode="approval-required" |
| 50 | traitsMenuContent={ | 52 | traitsMenuContent={ |
| 51 | provider === "codex" ? ( | 53 | <TraitsMenuContent |
| 52 | <CodexTraitsMenuContent threadId={threadId} /> | 54 | provider={provider} |
| 53 | ) : ( | 55 | threadId={threadId} |
| 54 | <ClaudeTraitsMenuContent | 56 | model={model} |
| 55 | threadId={threadId} | 57 | prompt={props?.prompt ?? ""} |
| 56 | model={props?.model ?? "claude-opus-4-6"} | 58 | modelOptions={providerOptions} |
| 57 | onPromptChange={onPromptChange} | 59 | onPromptChange={onPromptChange} |
| 58 | /> | 60 | /> |
| 59 | ) | ||
| 60 | } | 61 | } |
| 61 | onToggleInteractionMode={vi.fn()} | 62 | onToggleInteractionMode={vi.fn()} |
| 62 | onTogglePlanSidebar={vi.fn()} | 63 | onTogglePlanSidebar={vi.fn()} |
| ... | @@ -65,11 +66,14 @@ async function mountMenu(props?: { | ... | @@ -65,11 +66,14 @@ async function mountMenu(props?: { |
| 65 | { container: host }, | 66 | { container: host }, |
| 66 | ); | 67 | ); |
| 67 | 68 | ||
| 69 | const cleanup = async () => { | ||
| 70 | await screen.unmount(); | ||
| 71 | host.remove(); | ||
| 72 | }; | ||
| 73 | |||
| 68 | return { | 74 | return { |
| 69 | cleanup: async () => { | 75 | [Symbol.asyncDispose]: cleanup, |
| 70 | await screen.unmount(); | 76 | cleanup, |
| 71 | host.remove(); | ||
| 72 | }, | ||
| 73 | }; | 77 | }; |
| 74 | } | 78 | } |
| 75 | 79 | ||
| ... | @@ -80,107 +84,90 @@ describe("CompactComposerControlsMenu", () => { | ... | @@ -80,107 +84,90 @@ describe("CompactComposerControlsMenu", () => { |
| 80 | draftsByThreadId: {}, | 84 | draftsByThreadId: {}, |
| 81 | draftThreadsByThreadId: {}, | 85 | draftThreadsByThreadId: {}, |
| 82 | projectDraftThreadIdByProjectId: {}, | 86 | projectDraftThreadIdByProjectId: {}, |
| 87 | stickyModelSelectionByProvider: {}, | ||
| 83 | }); | 88 | }); |
| 84 | }); | 89 | }); |
| 85 | 90 | ||
| 86 | it("shows fast mode controls for Opus", async () => { | 91 | it("shows fast mode controls for Opus", async () => { |
| 87 | const mounted = await mountMenu(); | 92 | await using _ = await mountMenu({ |
| 88 | 93 | modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, | |
| 89 | try { | 94 | }); |
| 90 | await page.getByLabelText("More composer controls").click(); | 95 | |
| 91 | 96 | await page.getByLabelText("More composer controls").click(); | |
| 92 | await vi.waitFor(() => { | 97 | |
| 93 | const text = document.body.textContent ?? ""; | 98 | await vi.waitFor(() => { |
| 94 | expect(text).toContain("Fast Mode"); | 99 | const text = document.body.textContent ?? ""; |
| 95 | expect(text).toContain("off"); | 100 | expect(text).toContain("Fast Mode"); |
| 96 | expect(text).toContain("on"); | 101 | expect(text).toContain("off"); |
| 97 | }); | 102 | expect(text).toContain("on"); |
| 98 | } finally { | 103 | }); |
| 99 | await mounted.cleanup(); | ||
| 100 | } | ||
| 101 | }); | 104 | }); |
| 102 | 105 | ||
| 103 | it("hides fast mode controls for non-Opus Claude models", async () => { | 106 | it("hides fast mode controls for non-Opus Claude models", async () => { |
| 104 | const mounted = await mountMenu({ model: "claude-sonnet-4-6" }); | 107 | await using _ = await mountMenu({ |
| 108 | modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, | ||
| 109 | }); | ||
| 105 | 110 | ||
| 106 | try { | 111 | await page.getByLabelText("More composer controls").click(); |
| 107 | await page.getByLabelText("More composer controls").click(); | ||
| 108 | 112 | ||
| 109 | await vi.waitFor(() => { | 113 | await vi.waitFor(() => { |
| 110 | expect(document.body.textContent ?? "").not.toContain("Fast Mode"); | 114 | expect(document.body.textContent ?? "").not.toContain("Fast Mode"); |
| 111 | }); | 115 | }); |
| 112 | } finally { | ||
| 113 | await mounted.cleanup(); | ||
| 114 | } | ||
| 115 | }); | 116 | }); |
| 116 | 117 | ||
| 117 | it("shows only the provided effort options", async () => { | 118 | it("shows only the provided effort options", async () => { |
| 118 | const mounted = await mountMenu({ | 119 | await using _ = await mountMenu({ |
| 119 | model: "claude-sonnet-4-6", | 120 | modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, |
| 120 | }); | 121 | }); |
| 121 | 122 | ||
| 122 | try { | 123 | await page.getByLabelText("More composer controls").click(); |
| 123 | await page.getByLabelText("More composer controls").click(); | 124 | |
| 124 | 125 | await vi.waitFor(() => { | |
| 125 | await vi.waitFor(() => { | 126 | const text = document.body.textContent ?? ""; |
| 126 | const text = document.body.textContent ?? ""; | 127 | expect(text).toContain("Low"); |
| 127 | expect(text).toContain("Low"); | 128 | expect(text).toContain("Medium"); |
| 128 | expect(text).toContain("Medium"); | 129 | expect(text).toContain("High"); |
| 129 | expect(text).toContain("High"); | 130 | expect(text).not.toContain("Max"); |
| 130 | expect(text).not.toContain("Max"); | 131 | expect(text).toContain("Ultrathink"); |
| 131 | expect(text).toContain("Ultrathink"); | 132 | }); |
| 132 | }); | ||
| 133 | } finally { | ||
| 134 | await mounted.cleanup(); | ||
| 135 | } | ||
| 136 | }); | 133 | }); |
| 137 | 134 | ||
| 138 | it("shows a Claude thinking on/off section for Haiku", async () => { | 135 | it("shows a Claude thinking on/off section for Haiku", async () => { |
| 139 | const mounted = await mountMenu({ | 136 | await using _ = await mountMenu({ |
| 140 | model: "claude-haiku-4-5", | 137 | modelSelection: { |
| 141 | modelOptions: { | 138 | provider: "claudeAgent", |
| 142 | claudeAgent: { | 139 | model: "claude-haiku-4-5", |
| 143 | thinking: true, | 140 | options: { thinking: true }, |
| 144 | }, | ||
| 145 | }, | 141 | }, |
| 146 | }); | 142 | }); |
| 147 | 143 | ||
| 148 | try { | 144 | await page.getByLabelText("More composer controls").click(); |
| 149 | await page.getByLabelText("More composer controls").click(); | 145 | |
| 150 | 146 | await vi.waitFor(() => { | |
| 151 | await vi.waitFor(() => { | 147 | const text = document.body.textContent ?? ""; |
| 152 | const text = document.body.textContent ?? ""; | 148 | expect(text).toContain("Thinking"); |
| 153 | expect(text).toContain("Thinking"); | 149 | expect(text).toContain("On (default)"); |
| 154 | expect(text).toContain("On (default)"); | 150 | expect(text).toContain("Off"); |
| 155 | expect(text).toContain("Off"); | 151 | }); |
| 156 | }); | ||
| 157 | } finally { | ||
| 158 | await mounted.cleanup(); | ||
| 159 | } | ||
| 160 | }); | 152 | }); |
| 161 | 153 | ||
| 162 | it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => { | 154 | it("shows prompt-controlled Ultrathink messaging with disabled effort controls", async () => { |
| 163 | const mounted = await mountMenu({ | 155 | await using _ = await mountMenu({ |
| 164 | model: "claude-opus-4-6", | 156 | modelSelection: { |
| 165 | prompt: "Ultrathink:\nInvestigate this", | 157 | provider: "claudeAgent", |
| 166 | modelOptions: { | 158 | model: "claude-opus-4-6", |
| 167 | claudeAgent: { | 159 | options: { effort: "high" }, |
| 168 | effort: "high", | ||
| 169 | }, | ||
| 170 | }, | 160 | }, |
| 161 | prompt: "Ultrathink:\nInvestigate this", | ||
| 171 | }); | 162 | }); |
| 172 | 163 | ||
| 173 | try { | 164 | await page.getByLabelText("More composer controls").click(); |
| 174 | await page.getByLabelText("More composer controls").click(); | 165 | |
| 175 | 166 | await vi.waitFor(() => { | |
| 176 | await vi.waitFor(() => { | 167 | const text = document.body.textContent ?? ""; |
| 177 | const text = document.body.textContent ?? ""; | 168 | expect(text).toContain("Effort"); |
| 178 | expect(text).toContain("Effort"); | 169 | expect(text).toContain("Remove Ultrathink from the prompt to change effort."); |
| 179 | expect(text).toContain("Remove Ultrathink from the prompt to change effort."); | 170 | expect(text).not.toContain("Fallback Effort"); |
| 180 | expect(text).not.toContain("Fallback Effort"); | 171 | }); |
| 181 | }); | ||
| 182 | } finally { | ||
| 183 | await mounted.cleanup(); | ||
| 184 | } | ||
| 185 | }); | 172 | }); |
| 186 | }); | 173 | }); |
apps/web/src/components/chat/ProviderHealthBanner.tsx+2-7| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { type ServerProviderStatus } from "@t3tools/contracts"; | 1 | import { PROVIDER_DISPLAY_NAMES, type ServerProviderStatus } from "@t3tools/contracts"; |
| 2 | import { memo } from "react"; | 2 | import { memo } from "react"; |
| 3 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; | 3 | import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; |
| 4 | import { CircleAlertIcon } from "lucide-react"; | 4 | import { CircleAlertIcon } from "lucide-react"; |
| ... | @@ -12,12 +12,7 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({ | ... | @@ -12,12 +12,7 @@ export const ProviderHealthBanner = memo(function ProviderHealthBanner({ |
| 12 | return null; | 12 | return null; |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | const providerLabel = | 15 | const providerLabel = PROVIDER_DISPLAY_NAMES[status.provider] ?? status.provider; |
| 16 | status.provider === "codex" | ||
| 17 | ? "Codex" | ||
| 18 | : status.provider === "claudeAgent" | ||
| 19 | ? "Claude" | ||
| 20 | : status.provider; | ||
| 21 | const defaultMessage = | 16 | const defaultMessage = |
| 22 | status.status === "error" | 17 | status.status === "error" |
| 23 | ? `${providerLabel} provider is unavailable.` | 18 | ? `${providerLabel} provider is unavailable.` |
apps/web/src/components/chat/TraitsPicker.browser.tsx created+362| ... | @@ -0,0 +1,362 @@ | ||
| 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,12 +34,51 @@ describe("getComposerProviderState", () => { |
| 34 | provider: "codex", | 34 | provider: "codex", |
| 35 | promptEffort: "low", | 35 | promptEffort: "low", |
| 36 | modelOptionsForDispatch: { | 36 | modelOptionsForDispatch: { |
| 37 | reasoningEffort: "low", | ||
| 38 | fastMode: true, | ||
| 39 | }, | ||
| 40 | }); | ||
| 41 | }); | ||
| 42 | |||
| 43 | it("preserves codex fast mode when it is the only active option", () => { | ||
| 44 | const state = getComposerProviderState({ | ||
| 45 | provider: "codex", | ||
| 46 | model: "gpt-5.4", | ||
| 47 | prompt: "", | ||
| 48 | modelOptions: { | ||
| 37 | codex: { | 49 | codex: { |
| 38 | reasoningEffort: "low", | ||
| 39 | fastMode: true, | 50 | fastMode: true, |
| 40 | }, | 51 | }, |
| 41 | }, | 52 | }, |
| 42 | }); | 53 | }); |
| 54 | |||
| 55 | expect(state).toEqual({ | ||
| 56 | provider: "codex", | ||
| 57 | promptEffort: "high", | ||
| 58 | modelOptionsForDispatch: { | ||
| 59 | fastMode: true, | ||
| 60 | }, | ||
| 61 | }); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("drops explicit codex default/off overrides from dispatch while keeping the selected effort label", () => { | ||
| 65 | const state = getComposerProviderState({ | ||
| 66 | provider: "codex", | ||
| 67 | model: "gpt-5.4", | ||
| 68 | prompt: "", | ||
| 69 | modelOptions: { | ||
| 70 | codex: { | ||
| 71 | reasoningEffort: "high", | ||
| 72 | fastMode: false, | ||
| 73 | }, | ||
| 74 | }, | ||
| 75 | }); | ||
| 76 | |||
| 77 | expect(state).toEqual({ | ||
| 78 | provider: "codex", | ||
| 79 | promptEffort: "high", | ||
| 80 | modelOptionsForDispatch: undefined, | ||
| 81 | }); | ||
| 43 | }); | 82 | }); |
| 44 | 83 | ||
| 45 | it("returns Claude defaults for effort-capable models", () => { | 84 | it("returns Claude defaults for effort-capable models", () => { |
| ... | @@ -73,9 +112,7 @@ describe("getComposerProviderState", () => { | ... | @@ -73,9 +112,7 @@ describe("getComposerProviderState", () => { |
| 73 | provider: "claudeAgent", | 112 | provider: "claudeAgent", |
| 74 | promptEffort: "medium", | 113 | promptEffort: "medium", |
| 75 | modelOptionsForDispatch: { | 114 | modelOptionsForDispatch: { |
| 76 | claudeAgent: { | 115 | effort: "medium", |
| 77 | effort: "medium", | ||
| 78 | }, | ||
| 79 | }, | 116 | }, |
| 80 | composerFrameClassName: "ultrathink-frame", | 117 | composerFrameClassName: "ultrathink-frame", |
| 81 | composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", | 118 | composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]", |
| ... | @@ -100,21 +137,18 @@ describe("getComposerProviderState", () => { | ... | @@ -100,21 +137,18 @@ describe("getComposerProviderState", () => { |
| 100 | provider: "claudeAgent", | 137 | provider: "claudeAgent", |
| 101 | promptEffort: null, | 138 | promptEffort: null, |
| 102 | modelOptionsForDispatch: { | 139 | modelOptionsForDispatch: { |
| 103 | claudeAgent: { | 140 | thinking: false, |
| 104 | thinking: false, | ||
| 105 | }, | ||
| 106 | }, | 141 | }, |
| 107 | }); | 142 | }); |
| 108 | }); | 143 | }); |
| 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 | const state = getComposerProviderState({ | 146 | const state = getComposerProviderState({ |
| 112 | provider: "claudeAgent", | 147 | provider: "claudeAgent", |
| 113 | model: "claude-opus-4-6", | 148 | model: "claude-opus-4-6", |
| 114 | prompt: "", | 149 | prompt: "", |
| 115 | modelOptions: { | 150 | modelOptions: { |
| 116 | codex: { | 151 | claudeAgent: { |
| 117 | reasoningEffort: "low", | ||
| 118 | fastMode: true, | 152 | fastMode: true, |
| 119 | }, | 153 | }, |
| 120 | }, | 154 | }, |
| ... | @@ -123,25 +157,27 @@ describe("getComposerProviderState", () => { | ... | @@ -123,25 +157,27 @@ describe("getComposerProviderState", () => { |
| 123 | expect(state).toEqual({ | 157 | expect(state).toEqual({ |
| 124 | provider: "claudeAgent", | 158 | provider: "claudeAgent", |
| 125 | promptEffort: "high", | 159 | promptEffort: "high", |
| 126 | modelOptionsForDispatch: undefined, | 160 | modelOptionsForDispatch: { |
| 161 | fastMode: true, | ||
| 162 | }, | ||
| 127 | }); | 163 | }); |
| 128 | }); | 164 | }); |
| 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 | const state = getComposerProviderState({ | 167 | const state = getComposerProviderState({ |
| 132 | provider: "codex", | 168 | provider: "claudeAgent", |
| 133 | model: "gpt-5.4", | 169 | model: "claude-opus-4-6", |
| 134 | prompt: "Ultrathink:\nThis should not matter", | 170 | prompt: "", |
| 135 | modelOptions: { | 171 | modelOptions: { |
| 136 | claudeAgent: { | 172 | claudeAgent: { |
| 137 | effort: "max", | 173 | effort: "high", |
| 138 | fastMode: true, | 174 | fastMode: false, |
| 139 | }, | 175 | }, |
| 140 | }, | 176 | }, |
| 141 | }); | 177 | }); |
| 142 | 178 | ||
| 143 | expect(state).toEqual({ | 179 | expect(state).toEqual({ |
| 144 | provider: "codex", | 180 | provider: "claudeAgent", |
| 145 | promptEffort: "high", | 181 | promptEffort: "high", |
| 146 | modelOptionsForDispatch: undefined, | 182 | modelOptionsForDispatch: undefined, |
| 147 | }); | 183 | }); |
apps/web/src/components/chat/composerProviderRegistry.tsx+108-57| ... | @@ -5,17 +5,16 @@ import { | ... | @@ -5,17 +5,16 @@ import { |
| 5 | type ThreadId, | 5 | type ThreadId, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { | 7 | import { |
| 8 | getDefaultReasoningEffort, | 8 | getModelCapabilities, |
| 9 | getReasoningEffortOptions, | ||
| 10 | isClaudeUltrathinkPrompt, | 9 | isClaudeUltrathinkPrompt, |
| 11 | normalizeClaudeModelOptions, | 10 | normalizeClaudeModelOptions, |
| 12 | normalizeCodexModelOptions, | 11 | normalizeCodexModelOptions, |
| 13 | resolveReasoningEffortForProvider, | 12 | trimOrNull, |
| 14 | supportsClaudeUltrathinkKeyword, | 13 | getDefaultEffort, |
| 14 | hasEffortLevel, | ||
| 15 | } from "@t3tools/shared/model"; | 15 | } from "@t3tools/shared/model"; |
| 16 | import type { ReactNode } from "react"; | 16 | import type { ReactNode } from "react"; |
| 17 | import { ClaudeTraitsMenuContent, ClaudeTraitsPicker } from "./ClaudeTraitsPicker"; | 17 | import { TraitsMenuContent, TraitsPicker } from "./TraitsPicker"; |
| 18 | import { CodexTraitsMenuContent, CodexTraitsPicker } from "./CodexTraitsPicker"; | ||
| 19 | 18 | ||
| 20 | export type ComposerProviderStateInput = { | 19 | export type ComposerProviderStateInput = { |
| 21 | provider: ProviderKind; | 20 | provider: ProviderKind; |
| ... | @@ -27,7 +26,7 @@ export type ComposerProviderStateInput = { | ... | @@ -27,7 +26,7 @@ export type ComposerProviderStateInput = { |
| 27 | export type ComposerProviderState = { | 26 | export type ComposerProviderState = { |
| 28 | provider: ProviderKind; | 27 | provider: ProviderKind; |
| 29 | promptEffort: string | null; | 28 | promptEffort: string | null; |
| 30 | modelOptionsForDispatch: ProviderModelOptions | undefined; | 29 | modelOptionsForDispatch: ProviderModelOptions[ProviderKind] | undefined; |
| 31 | composerFrameClassName?: string; | 30 | composerFrameClassName?: string; |
| 32 | composerSurfaceClassName?: string; | 31 | composerSurfaceClassName?: string; |
| 33 | modelPickerIconClassName?: string; | 32 | modelPickerIconClassName?: string; |
| ... | @@ -38,70 +37,114 @@ type ProviderRegistryEntry = { | ... | @@ -38,70 +37,114 @@ type ProviderRegistryEntry = { |
| 38 | renderTraitsMenuContent: (input: { | 37 | renderTraitsMenuContent: (input: { |
| 39 | threadId: ThreadId; | 38 | threadId: ThreadId; |
| 40 | model: ModelSlug; | 39 | model: ModelSlug; |
| 40 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | ||
| 41 | prompt: string; | ||
| 41 | onPromptChange: (prompt: string) => void; | 42 | onPromptChange: (prompt: string) => void; |
| 42 | }) => ReactNode; | 43 | }) => ReactNode; |
| 43 | renderTraitsPicker: (input: { | 44 | renderTraitsPicker: (input: { |
| 44 | threadId: ThreadId; | 45 | threadId: ThreadId; |
| 45 | model: ModelSlug; | 46 | model: ModelSlug; |
| 47 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | ||
| 48 | prompt: string; | ||
| 46 | onPromptChange: (prompt: string) => void; | 49 | onPromptChange: (prompt: string) => void; |
| 47 | }) => ReactNode; | 50 | }) => ReactNode; |
| 48 | }; | 51 | }; |
| 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 | const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { | 103 | const composerProviderRegistry: Record<ProviderKind, ProviderRegistryEntry> = { |
| 51 | codex: { | 104 | codex: { |
| 52 | getState: ({ modelOptions }) => { | 105 | getState: (input) => getProviderStateFromCapabilities(input), |
| 53 | const promptEffort = | 106 | renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( |
| 54 | resolveReasoningEffortForProvider("codex", modelOptions?.codex?.reasoningEffort) ?? | 107 | <TraitsMenuContent |
| 55 | getDefaultReasoningEffort("codex"); | 108 | provider="codex" |
| 56 | const normalizedCodexOptions = normalizeCodexModelOptions(modelOptions?.codex); | 109 | threadId={threadId} |
| 57 | 110 | model={model} | |
| 58 | return { | 111 | modelOptions={modelOptions} |
| 59 | provider: "codex", | 112 | prompt={prompt} |
| 60 | promptEffort, | 113 | onPromptChange={onPromptChange} |
| 61 | modelOptionsForDispatch: normalizedCodexOptions | 114 | /> |
| 62 | ? { codex: normalizedCodexOptions } | 115 | ), |
| 63 | : undefined, | 116 | renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( |
| 64 | }; | 117 | <TraitsPicker |
| 65 | }, | 118 | provider="codex" |
| 66 | renderTraitsMenuContent: ({ threadId }) => <CodexTraitsMenuContent threadId={threadId} />, | 119 | threadId={threadId} |
| 67 | renderTraitsPicker: ({ threadId }) => <CodexTraitsPicker threadId={threadId} />, | 120 | model={model} |
| 121 | modelOptions={modelOptions} | ||
| 122 | prompt={prompt} | ||
| 123 | onPromptChange={onPromptChange} | ||
| 124 | /> | ||
| 125 | ), | ||
| 68 | }, | 126 | }, |
| 69 | claudeAgent: { | 127 | claudeAgent: { |
| 70 | getState: ({ model, prompt, modelOptions }) => { | 128 | getState: (input) => getProviderStateFromCapabilities(input), |
| 71 | const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); | 129 | renderTraitsMenuContent: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( |
| 72 | const draftEffort = resolveReasoningEffortForProvider( | 130 | <TraitsMenuContent |
| 73 | "claudeAgent", | 131 | provider="claudeAgent" |
| 74 | modelOptions?.claudeAgent?.effort, | 132 | threadId={threadId} |
| 75 | ); | 133 | model={model} |
| 76 | const defaultEffort = getDefaultReasoningEffort("claudeAgent"); | 134 | modelOptions={modelOptions} |
| 77 | const promptEffort = | 135 | prompt={prompt} |
| 78 | draftEffort && draftEffort !== "ultrathink" && reasoningOptions.includes(draftEffort) | 136 | onPromptChange={onPromptChange} |
| 79 | ? draftEffort | 137 | /> |
| 80 | : reasoningOptions.includes(defaultEffort) | ||
| 81 | ? defaultEffort | ||
| 82 | : null; | ||
| 83 | const normalizedClaudeOptions = normalizeClaudeModelOptions(model, modelOptions?.claudeAgent); | ||
| 84 | const ultrathinkActive = | ||
| 85 | supportsClaudeUltrathinkKeyword(model) && isClaudeUltrathinkPrompt(prompt); | ||
| 86 | |||
| 87 | return { | ||
| 88 | provider: "claudeAgent", | ||
| 89 | promptEffort, | ||
| 90 | modelOptionsForDispatch: normalizedClaudeOptions | ||
| 91 | ? { claudeAgent: normalizedClaudeOptions } | ||
| 92 | : undefined, | ||
| 93 | ...(ultrathinkActive ? { composerFrameClassName: "ultrathink-frame" } : {}), | ||
| 94 | ...(ultrathinkActive | ||
| 95 | ? { composerSurfaceClassName: "shadow-[0_0_0_1px_rgba(255,255,255,0.04)_inset]" } | ||
| 96 | : {}), | ||
| 97 | ...(ultrathinkActive ? { modelPickerIconClassName: "ultrathink-chroma" } : {}), | ||
| 98 | }; | ||
| 99 | }, | ||
| 100 | renderTraitsMenuContent: ({ threadId, model, onPromptChange }) => ( | ||
| 101 | <ClaudeTraitsMenuContent threadId={threadId} model={model} onPromptChange={onPromptChange} /> | ||
| 102 | ), | 138 | ), |
| 103 | renderTraitsPicker: ({ threadId, model, onPromptChange }) => ( | 139 | renderTraitsPicker: ({ threadId, model, modelOptions, prompt, onPromptChange }) => ( |
| 104 | <ClaudeTraitsPicker threadId={threadId} model={model} onPromptChange={onPromptChange} /> | 140 | <TraitsPicker |
| 141 | provider="claudeAgent" | ||
| 142 | threadId={threadId} | ||
| 143 | model={model} | ||
| 144 | modelOptions={modelOptions} | ||
| 145 | prompt={prompt} | ||
| 146 | onPromptChange={onPromptChange} | ||
| 147 | /> | ||
| 105 | ), | 148 | ), |
| 106 | }, | 149 | }, |
| 107 | }; | 150 | }; |
| ... | @@ -114,11 +157,15 @@ export function renderProviderTraitsMenuContent(input: { | ... | @@ -114,11 +157,15 @@ export function renderProviderTraitsMenuContent(input: { |
| 114 | provider: ProviderKind; | 157 | provider: ProviderKind; |
| 115 | threadId: ThreadId; | 158 | threadId: ThreadId; |
| 116 | model: ModelSlug; | 159 | model: ModelSlug; |
| 160 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | ||
| 161 | prompt: string; | ||
| 117 | onPromptChange: (prompt: string) => void; | 162 | onPromptChange: (prompt: string) => void; |
| 118 | }): ReactNode { | 163 | }): ReactNode { |
| 119 | return composerProviderRegistry[input.provider].renderTraitsMenuContent({ | 164 | return composerProviderRegistry[input.provider].renderTraitsMenuContent({ |
| 120 | threadId: input.threadId, | 165 | threadId: input.threadId, |
| 121 | model: input.model, | 166 | model: input.model, |
| 167 | modelOptions: input.modelOptions, | ||
| 168 | prompt: input.prompt, | ||
| 122 | onPromptChange: input.onPromptChange, | 169 | onPromptChange: input.onPromptChange, |
| 123 | }); | 170 | }); |
| 124 | } | 171 | } |
| ... | @@ -127,11 +174,15 @@ export function renderProviderTraitsPicker(input: { | ... | @@ -127,11 +174,15 @@ export function renderProviderTraitsPicker(input: { |
| 127 | provider: ProviderKind; | 174 | provider: ProviderKind; |
| 128 | threadId: ThreadId; | 175 | threadId: ThreadId; |
| 129 | model: ModelSlug; | 176 | model: ModelSlug; |
| 177 | modelOptions: ProviderModelOptions[ProviderKind] | undefined; | ||
| 178 | prompt: string; | ||
| 130 | onPromptChange: (prompt: string) => void; | 179 | onPromptChange: (prompt: string) => void; |
| 131 | }): ReactNode { | 180 | }): ReactNode { |
| 132 | return composerProviderRegistry[input.provider].renderTraitsPicker({ | 181 | return composerProviderRegistry[input.provider].renderTraitsPicker({ |
| 133 | threadId: input.threadId, | 182 | threadId: input.threadId, |
| 134 | model: input.model, | 183 | model: input.model, |
| 184 | modelOptions: input.modelOptions, | ||
| 185 | prompt: input.prompt, | ||
| 135 | onPromptChange: input.onPromptChange, | 186 | onPromptChange: input.onPromptChange, |
| 136 | }); | 187 | }); |
| 137 | } | 188 | } |
apps/web/src/composerDraftStore.test.ts+241-156| ... | @@ -1,5 +1,10 @@ | ... | @@ -1,5 +1,10 @@ |
| 1 | import * as Schema from "effect/Schema"; | 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 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | 8 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 4 | 9 | ||
| 5 | import { | 10 | import { |
| ... | @@ -67,11 +72,27 @@ function resetComposerDraftStore() { | ... | @@ -67,11 +72,27 @@ function resetComposerDraftStore() { |
| 67 | draftsByThreadId: {}, | 72 | draftsByThreadId: {}, |
| 68 | draftThreadsByThreadId: {}, | 73 | draftThreadsByThreadId: {}, |
| 69 | projectDraftThreadIdByProjectId: {}, | 74 | projectDraftThreadIdByProjectId: {}, |
| 70 | stickyModel: null, | 75 | stickyModelSelectionByProvider: {}, |
| 71 | stickyModelOptions: {}, | 76 | stickyActiveProvider: null, |
| 72 | }); | 77 | }); |
| 73 | } | 78 | } |
| 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 | describe("composerDraftStore addImages", () => { | 96 | describe("composerDraftStore addImages", () => { |
| 76 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); | 97 | const threadId = ThreadId.makeUnsafe("thread-dedupe"); |
| 77 | let originalRevokeObjectUrl: typeof URL.revokeObjectURL; | 98 | let originalRevokeObjectUrl: typeof URL.revokeObjectURL; |
| ... | @@ -197,6 +218,8 @@ describe("composerDraftStore syncPersistedAttachments", () => { | ... | @@ -197,6 +218,8 @@ describe("composerDraftStore syncPersistedAttachments", () => { |
| 197 | draftsByThreadId: {}, | 218 | draftsByThreadId: {}, |
| 198 | draftThreadsByThreadId: {}, | 219 | draftThreadsByThreadId: {}, |
| 199 | projectDraftThreadIdByProjectId: {}, | 220 | projectDraftThreadIdByProjectId: {}, |
| 221 | stickyModelSelectionByProvider: {}, | ||
| 222 | stickyActiveProvider: null, | ||
| 200 | }); | 223 | }); |
| 201 | }); | 224 | }); |
| 202 | 225 | ||
| ... | @@ -253,6 +276,8 @@ describe("composerDraftStore terminal contexts", () => { | ... | @@ -253,6 +276,8 @@ describe("composerDraftStore terminal contexts", () => { |
| 253 | draftsByThreadId: {}, | 276 | draftsByThreadId: {}, |
| 254 | draftThreadsByThreadId: {}, | 277 | draftThreadsByThreadId: {}, |
| 255 | projectDraftThreadIdByProjectId: {}, | 278 | projectDraftThreadIdByProjectId: {}, |
| 279 | stickyModelSelectionByProvider: {}, | ||
| 280 | stickyActiveProvider: null, | ||
| 256 | }); | 281 | }); |
| 257 | }); | 282 | }); |
| 258 | 283 | ||
| ... | @@ -595,62 +620,58 @@ describe("composerDraftStore project draft thread mapping", () => { | ... | @@ -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 | const threadId = ThreadId.makeUnsafe("thread-model-options"); | 624 | const threadId = ThreadId.makeUnsafe("thread-model-options"); |
| 600 | 625 | ||
| 601 | beforeEach(() => { | 626 | beforeEach(() => { |
| 602 | resetComposerDraftStore(); | 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 | const store = useComposerDraftStore.getState(); | 631 | const store = useComposerDraftStore.getState(); |
| 607 | store.setModelOptions(threadId, { | 632 | store.setModelSelection( |
| 608 | codex: { | 633 | threadId, |
| 634 | modelSelection("codex", "gpt-5.3-codex", { | ||
| 609 | reasoningEffort: "xhigh", | 635 | reasoningEffort: "xhigh", |
| 610 | fastMode: true, | 636 | fastMode: true, |
| 611 | }, | 637 | }), |
| 612 | claudeAgent: { | 638 | ); |
| 613 | thinking: false, | ||
| 614 | }, | ||
| 615 | }); | ||
| 616 | 639 | ||
| 617 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ | 640 | expect( |
| 618 | codex: { | 641 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, |
| 642 | ).toEqual( | ||
| 643 | modelSelection("codex", "gpt-5.3-codex", { | ||
| 619 | reasoningEffort: "xhigh", | 644 | reasoningEffort: "xhigh", |
| 620 | fastMode: true, | 645 | fastMode: true, |
| 621 | }, | 646 | }), |
| 622 | claudeAgent: { | 647 | ); |
| 623 | thinking: false, | ||
| 624 | }, | ||
| 625 | }); | ||
| 626 | }); | 648 | }); |
| 627 | 649 | ||
| 628 | it("drops default-only model options from the draft", () => { | 650 | it("keeps default-only model selections on the draft", () => { |
| 629 | const store = useComposerDraftStore.getState(); | 651 | const store = useComposerDraftStore.getState(); |
| 630 | store.setModelOptions(threadId, { | 652 | store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); |
| 631 | codex: { | ||
| 632 | reasoningEffort: "high", | ||
| 633 | }, | ||
| 634 | claudeAgent: { | ||
| 635 | thinking: true, | ||
| 636 | }, | ||
| 637 | }); | ||
| 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 | const store = useComposerDraftStore.getState(); | 660 | const store = useComposerDraftStore.getState(); |
| 644 | 661 | ||
| 645 | store.setModelOptions(threadId, { | 662 | store.setModelSelection( |
| 646 | codex: { | 663 | threadId, |
| 647 | reasoningEffort: "xhigh", | 664 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 648 | }, | ||
| 649 | claudeAgent: { | ||
| 650 | effort: "max", | 665 | effort: "max", |
| 651 | fastMode: true, | 666 | fastMode: true, |
| 652 | }, | 667 | }), |
| 653 | }); | 668 | ); |
| 669 | store.setStickyModelSelection( | ||
| 670 | modelSelection("claudeAgent", "claude-opus-4-6", { | ||
| 671 | effort: "max", | ||
| 672 | fastMode: true, | ||
| 673 | }), | ||
| 674 | ); | ||
| 654 | 675 | ||
| 655 | store.setProviderModelOptions( | 676 | store.setProviderModelOptions( |
| 656 | threadId, | 677 | threadId, |
| ... | @@ -661,114 +682,166 @@ describe("composerDraftStore modelOptions", () => { | ... | @@ -661,114 +682,166 @@ describe("composerDraftStore modelOptions", () => { |
| 661 | { persistSticky: true }, | 682 | { persistSticky: true }, |
| 662 | ); | 683 | ); |
| 663 | 684 | ||
| 664 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ | 685 | expect( |
| 665 | codex: { | 686 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider |
| 666 | reasoningEffort: "xhigh", | 687 | .claudeAgent, |
| 667 | }, | 688 | ).toEqual( |
| 668 | claudeAgent: { | 689 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 669 | thinking: false, | 690 | thinking: false, |
| 670 | }, | 691 | }), |
| 671 | }); | 692 | ); |
| 672 | expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ | 693 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( |
| 673 | codex: { | 694 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 674 | reasoningEffort: "xhigh", | ||
| 675 | }, | ||
| 676 | claudeAgent: { | ||
| 677 | thinking: false, | 695 | thinking: false, |
| 678 | }, | 696 | }), |
| 679 | }); | 697 | ); |
| 680 | }); | 698 | }); |
| 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 | const store = useComposerDraftStore.getState(); | 701 | const store = useComposerDraftStore.getState(); |
| 684 | 702 | ||
| 685 | store.setModelOptions(threadId, { | 703 | store.setModelSelection( |
| 686 | codex: { | 704 | threadId, |
| 687 | reasoningEffort: "xhigh", | 705 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 688 | }, | ||
| 689 | claudeAgent: { | ||
| 690 | effort: "max", | 706 | effort: "max", |
| 691 | }, | 707 | }), |
| 692 | }); | 708 | ); |
| 693 | 709 | ||
| 694 | store.setProviderModelOptions(threadId, "claudeAgent", { | 710 | store.setProviderModelOptions(threadId, "claudeAgent", { |
| 695 | thinking: true, | 711 | thinking: true, |
| 696 | }); | 712 | }); |
| 697 | 713 | ||
| 698 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ | 714 | expect( |
| 699 | codex: { | 715 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider |
| 700 | reasoningEffort: "xhigh", | 716 | .claudeAgent, |
| 701 | }, | 717 | ).toEqual( |
| 702 | }); | 718 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 703 | expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); | 719 | thinking: true, |
| 720 | }), | ||
| 721 | ); | ||
| 722 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider).toEqual({}); | ||
| 704 | }); | 723 | }); |
| 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 | const store = useComposerDraftStore.getState(); | 726 | const store = useComposerDraftStore.getState(); |
| 708 | 727 | ||
| 709 | store.setModelOptions(threadId, { | 728 | store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4", { fastMode: true })); |
| 710 | codex: { | ||
| 711 | fastMode: true, | ||
| 712 | }, | ||
| 713 | }); | ||
| 714 | 729 | ||
| 715 | store.setProviderModelOptions(threadId, "codex", { | 730 | store.setProviderModelOptions(threadId, "codex", { |
| 716 | reasoningEffort: "high", | 731 | reasoningEffort: "high", |
| 717 | fastMode: false, | 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 | it("updates only the draft when sticky persistence is omitted", () => { | 745 | it("updates only the draft when sticky persistence is omitted", () => { |
| 724 | const store = useComposerDraftStore.getState(); | 746 | const store = useComposerDraftStore.getState(); |
| 725 | 747 | ||
| 726 | store.setStickyModelOptions({ | 748 | store.setStickyModelSelection( |
| 727 | codex: { | 749 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 728 | fastMode: true, | 750 | ); |
| 729 | }, | 751 | store.setModelSelection( |
| 730 | }); | 752 | threadId, |
| 731 | store.setModelOptions(threadId, { | 753 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 732 | codex: { | 754 | ); |
| 733 | fastMode: true, | ||
| 734 | }, | ||
| 735 | claudeAgent: { | ||
| 736 | effort: "max", | ||
| 737 | }, | ||
| 738 | }); | ||
| 739 | 755 | ||
| 740 | store.setProviderModelOptions(threadId, "claudeAgent", { | 756 | store.setProviderModelOptions(threadId, "claudeAgent", { |
| 741 | thinking: false, | 757 | thinking: false, |
| 742 | }); | 758 | }); |
| 743 | 759 | ||
| 744 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ | 760 | expect( |
| 745 | codex: { | 761 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider |
| 746 | fastMode: true, | 762 | .claudeAgent, |
| 747 | }, | 763 | ).toEqual( |
| 748 | claudeAgent: { | 764 | modelSelection("claudeAgent", "claude-opus-4-6", { |
| 749 | thinking: false, | 765 | thinking: false, |
| 750 | }, | 766 | }), |
| 751 | }); | 767 | ); |
| 752 | expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ | 768 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( |
| 753 | codex: { | 769 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 770 | ); | ||
| 771 | }); | ||
| 772 | |||
| 773 | it("does not clear other provider options when setting options for a single provider", () => { | ||
| 774 | const store = useComposerDraftStore.getState(); | ||
| 775 | |||
| 776 | // Set options for both providers | ||
| 777 | store.setModelOptions( | ||
| 778 | threadId, | ||
| 779 | providerModelOptions({ | ||
| 780 | codex: { fastMode: true }, | ||
| 781 | claudeAgent: { effort: "max" }, | ||
| 782 | }), | ||
| 783 | ); | ||
| 784 | |||
| 785 | // Now set options for only codex — claudeAgent should be untouched | ||
| 786 | store.setModelOptions(threadId, providerModelOptions({ codex: { reasoningEffort: "xhigh" } })); | ||
| 787 | |||
| 788 | const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | ||
| 789 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ reasoningEffort: "xhigh" }); | ||
| 790 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); | ||
| 791 | }); | ||
| 792 | |||
| 793 | it("preserves other provider options when switching the active model selection", () => { | ||
| 794 | const store = useComposerDraftStore.getState(); | ||
| 795 | |||
| 796 | store.setModelOptions( | ||
| 797 | threadId, | ||
| 798 | providerModelOptions({ | ||
| 799 | codex: { fastMode: true }, | ||
| 800 | claudeAgent: { effort: "max" }, | ||
| 801 | }), | ||
| 802 | ); | ||
| 803 | |||
| 804 | store.setModelSelection(threadId, modelSelection("claudeAgent", "claude-opus-4-6")); | ||
| 805 | |||
| 806 | const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | ||
| 807 | expect(draft?.modelSelectionByProvider.claudeAgent).toEqual( | ||
| 808 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), | ||
| 809 | ); | ||
| 810 | expect(draft?.modelSelectionByProvider.codex?.options).toEqual({ fastMode: true }); | ||
| 811 | expect(draft?.activeProvider).toBe("claudeAgent"); | ||
| 812 | }); | ||
| 813 | |||
| 814 | it("creates the first sticky snapshot from provider option changes", () => { | ||
| 815 | const store = useComposerDraftStore.getState(); | ||
| 816 | |||
| 817 | store.setModelSelection(threadId, modelSelection("codex", "gpt-5.4")); | ||
| 818 | |||
| 819 | store.setProviderModelOptions( | ||
| 820 | threadId, | ||
| 821 | "codex", | ||
| 822 | { | ||
| 754 | fastMode: true, | 823 | fastMode: true, |
| 755 | }, | 824 | }, |
| 756 | }); | 825 | { persistSticky: true }, |
| 826 | ); | ||
| 827 | |||
| 828 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( | ||
| 829 | modelSelection("codex", "gpt-5.4", { | ||
| 830 | fastMode: true, | ||
| 831 | }), | ||
| 832 | ); | ||
| 757 | }); | 833 | }); |
| 758 | 834 | ||
| 759 | it("updates only the draft when sticky persistence is disabled", () => { | 835 | it("updates only the draft when sticky persistence is disabled", () => { |
| 760 | const store = useComposerDraftStore.getState(); | 836 | const store = useComposerDraftStore.getState(); |
| 761 | 837 | ||
| 762 | store.setStickyModelOptions({ | 838 | store.setStickyModelSelection( |
| 763 | claudeAgent: { | 839 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 764 | effort: "max", | 840 | ); |
| 765 | }, | 841 | store.setModelSelection( |
| 766 | }); | 842 | threadId, |
| 767 | store.setModelOptions(threadId, { | 843 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 768 | claudeAgent: { | 844 | ); |
| 769 | effort: "max", | ||
| 770 | }, | ||
| 771 | }); | ||
| 772 | 845 | ||
| 773 | store.setProviderModelOptions( | 846 | store.setProviderModelOptions( |
| 774 | threadId, | 847 | threadId, |
| ... | @@ -779,34 +852,35 @@ describe("composerDraftStore modelOptions", () => { | ... | @@ -779,34 +852,35 @@ describe("composerDraftStore modelOptions", () => { |
| 779 | { persistSticky: false }, | 852 | { persistSticky: false }, |
| 780 | ); | 853 | ); |
| 781 | 854 | ||
| 782 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelOptions).toEqual({ | 855 | expect( |
| 783 | claudeAgent: { | 856 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider |
| 857 | .claudeAgent, | ||
| 858 | ).toEqual( | ||
| 859 | modelSelection("claudeAgent", "claude-opus-4-6", { | ||
| 784 | thinking: false, | 860 | thinking: false, |
| 785 | }, | 861 | }), |
| 786 | }); | 862 | ); |
| 787 | expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({ | 863 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.claudeAgent).toEqual( |
| 788 | claudeAgent: { | 864 | modelSelection("claudeAgent", "claude-opus-4-6", { effort: "max" }), |
| 789 | effort: "max", | 865 | ); |
| 790 | }, | ||
| 791 | }); | ||
| 792 | }); | 866 | }); |
| 793 | }); | 867 | }); |
| 794 | 868 | ||
| 795 | describe("composerDraftStore setModel", () => { | 869 | describe("composerDraftStore setModelSelection", () => { |
| 796 | const threadId = ThreadId.makeUnsafe("thread-model"); | 870 | const threadId = ThreadId.makeUnsafe("thread-model"); |
| 797 | 871 | ||
| 798 | beforeEach(() => { | 872 | beforeEach(() => { |
| 799 | resetComposerDraftStore(); | 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 | const store = useComposerDraftStore.getState(); | 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( | 881 | expect( |
| 808 | "gpt-5.3-codex", | 882 | useComposerDraftStore.getState().draftsByThreadId[threadId]?.modelSelectionByProvider.codex, |
| 809 | ); | 883 | ).toEqual(modelSelection("codex", "gpt-5.3-codex")); |
| 810 | }); | 884 | }); |
| 811 | }); | 885 | }); |
| 812 | 886 | ||
| ... | @@ -815,63 +889,74 @@ describe("composerDraftStore sticky composer settings", () => { | ... | @@ -815,63 +889,74 @@ describe("composerDraftStore sticky composer settings", () => { |
| 815 | resetComposerDraftStore(); | 889 | resetComposerDraftStore(); |
| 816 | }); | 890 | }); |
| 817 | 891 | ||
| 818 | it("stores sticky model and codex model options", () => { | 892 | it("stores a sticky model selection", () => { |
| 819 | const store = useComposerDraftStore.getState(); | 893 | const store = useComposerDraftStore.getState(); |
| 820 | 894 | ||
| 821 | store.setStickyModel("gpt-5.3-codex"); | 895 | store.setStickyModelSelection( |
| 822 | store.setStickyModelOptions({ | 896 | modelSelection("codex", "gpt-5.3-codex", { |
| 823 | codex: { | ||
| 824 | reasoningEffort: "medium", | 897 | reasoningEffort: "medium", |
| 825 | fastMode: true, | 898 | fastMode: true, |
| 826 | }, | 899 | }), |
| 827 | }); | 900 | ); |
| 828 | 901 | ||
| 829 | expect(useComposerDraftStore.getState()).toMatchObject({ | 902 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( |
| 830 | stickyModel: "gpt-5.3-codex", | 903 | modelSelection("codex", "gpt-5.3-codex", { |
| 831 | stickyModelOptions: { | 904 | reasoningEffort: "medium", |
| 832 | codex: { | 905 | fastMode: true, |
| 833 | reasoningEffort: "medium", | 906 | }), |
| 834 | fastMode: true, | 907 | ); |
| 835 | }, | 908 | expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex"); |
| 836 | }, | ||
| 837 | }); | ||
| 838 | }); | 909 | }); |
| 839 | 910 | ||
| 840 | it("normalizes empty sticky model options", () => { | 911 | it("normalizes empty sticky model options by dropping selection options", () => { |
| 841 | const store = useComposerDraftStore.getState(); | 912 | const store = useComposerDraftStore.getState(); |
| 842 | 913 | ||
| 843 | store.setStickyModelOptions({ | 914 | store.setStickyModelSelection(modelSelection("codex", "gpt-5.4")); |
| 844 | codex: { | 915 | |
| 845 | fastMode: false, | 916 | expect(useComposerDraftStore.getState().stickyModelSelectionByProvider.codex).toEqual( |
| 917 | modelSelection("codex", "gpt-5.4"), | ||
| 918 | ); | ||
| 919 | expect(useComposerDraftStore.getState().stickyActiveProvider).toBe("codex"); | ||
| 920 | }); | ||
| 921 | |||
| 922 | it("applies sticky activeProvider to new drafts", () => { | ||
| 923 | const store = useComposerDraftStore.getState(); | ||
| 924 | const threadId = ThreadId.makeUnsafe("thread-sticky-active-provider"); | ||
| 925 | |||
| 926 | store.setStickyModelSelection(modelSelection("claudeAgent", "claude-opus-4-6")); | ||
| 927 | store.applyStickyState(threadId); | ||
| 928 | |||
| 929 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toMatchObject({ | ||
| 930 | modelSelectionByProvider: { | ||
| 931 | claudeAgent: modelSelection("claudeAgent", "claude-opus-4-6"), | ||
| 846 | }, | 932 | }, |
| 933 | activeProvider: "claudeAgent", | ||
| 847 | }); | 934 | }); |
| 848 | |||
| 849 | expect(useComposerDraftStore.getState().stickyModelOptions).toEqual({}); | ||
| 850 | }); | 935 | }); |
| 851 | }); | 936 | }); |
| 852 | 937 | ||
| 853 | describe("composerDraftStore setProvider", () => { | 938 | describe("composerDraftStore provider-scoped option updates", () => { |
| 854 | const threadId = ThreadId.makeUnsafe("thread-provider"); | 939 | const threadId = ThreadId.makeUnsafe("thread-provider"); |
| 855 | 940 | ||
| 856 | beforeEach(() => { | 941 | beforeEach(() => { |
| 857 | resetComposerDraftStore(); | 942 | resetComposerDraftStore(); |
| 858 | }); | 943 | }); |
| 859 | 944 | ||
| 860 | it("persists provider-only selection even when prompt/model are empty", () => { | 945 | it("retains off-provider option memory without changing the active selection", () => { |
| 861 | const store = useComposerDraftStore.getState(); | ||
| 862 | |||
| 863 | store.setProvider(threadId, "codex"); | ||
| 864 | |||
| 865 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]?.provider).toBe("codex"); | ||
| 866 | }); | ||
| 867 | |||
| 868 | it("removes empty provider-only draft when provider is reset", () => { | ||
| 869 | const store = useComposerDraftStore.getState(); | 946 | const store = useComposerDraftStore.getState(); |
| 870 | 947 | store.setModelSelection( | |
| 871 | store.setProvider(threadId, "codex"); | 948 | threadId, |
| 872 | store.setProvider(threadId, null); | 949 | modelSelection("codex", "gpt-5.3-codex", { |
| 873 | 950 | reasoningEffort: "medium", | |
| 874 | expect(useComposerDraftStore.getState().draftsByThreadId[threadId]).toBeUndefined(); | 951 | }), |
| 952 | ); | ||
| 953 | store.setProviderModelOptions(threadId, "claudeAgent", { effort: "max" }); | ||
| 954 | const draft = useComposerDraftStore.getState().draftsByThreadId[threadId]; | ||
| 955 | expect(draft?.modelSelectionByProvider.codex).toEqual( | ||
| 956 | modelSelection("codex", "gpt-5.3-codex", { reasoningEffort: "medium" }), | ||
| 957 | ); | ||
| 958 | expect(draft?.modelSelectionByProvider.claudeAgent?.options).toEqual({ effort: "max" }); | ||
| 959 | expect(draft?.activeProvider).toBe("codex"); | ||
| 875 | }); | 960 | }); |
| 876 | }); | 961 | }); |
| 877 | 962 |
apps/web/src/composerDraftStore.ts+583-180| ... | @@ -2,7 +2,8 @@ import { | ... | @@ -2,7 +2,8 @@ import { |
| 2 | CODEX_REASONING_EFFORT_OPTIONS, | 2 | CODEX_REASONING_EFFORT_OPTIONS, |
| 3 | type ClaudeCodeEffort, | 3 | type ClaudeCodeEffort, |
| 4 | type CodexReasoningEffort, | 4 | type CodexReasoningEffort, |
| 5 | DEFAULT_REASONING_EFFORT_BY_PROVIDER, | 5 | type ModelSlug, |
| 6 | ModelSelection, | ||
| 6 | ProjectId, | 7 | ProjectId, |
| 7 | ProviderInteractionMode, | 8 | ProviderInteractionMode, |
| 8 | ProviderKind, | 9 | ProviderKind, |
| ... | @@ -13,8 +14,14 @@ import { | ... | @@ -13,8 +14,14 @@ import { |
| 13 | import * as Schema from "effect/Schema"; | 14 | import * as Schema from "effect/Schema"; |
| 14 | import * as Equal from "effect/Equal"; | 15 | import * as Equal from "effect/Equal"; |
| 15 | import { DeepMutable } from "effect/Types"; | 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 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; | 23 | import { getLocalStorageItem } from "./hooks/useLocalStorage"; |
| 24 | import { resolveAppModelSelection } from "./appSettings"; | ||
| 18 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; | 25 | import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type ChatImageAttachment } from "./types"; |
| 19 | import { | 26 | import { |
| 20 | type TerminalContextDraft, | 27 | type TerminalContextDraft, |
| ... | @@ -26,7 +33,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; | ... | @@ -26,7 +33,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; |
| 26 | import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; | 33 | import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; |
| 27 | 34 | ||
| 28 | export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; | 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 | const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); | 37 | const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); |
| 31 | export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; | 38 | export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; |
| 32 | 39 | ||
| ... | @@ -73,9 +80,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({ | ... | @@ -73,9 +80,10 @@ const PersistedComposerThreadDraftState = Schema.Struct({ |
| 73 | prompt: Schema.String, | 80 | prompt: Schema.String, |
| 74 | attachments: Schema.Array(PersistedComposerImageAttachment), | 81 | attachments: Schema.Array(PersistedComposerImageAttachment), |
| 75 | terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), | 82 | terminalContexts: Schema.optionalKey(Schema.Array(PersistedTerminalContextDraft)), |
| 76 | provider: Schema.optionalKey(ProviderKind), | 83 | modelSelectionByProvider: Schema.optionalKey( |
| 77 | model: Schema.optionalKey(Schema.String), | 84 | Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), |
| 78 | modelOptions: Schema.optionalKey(ProviderModelOptions), | 85 | ), |
| 86 | activeProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), | ||
| 79 | runtimeMode: Schema.optionalKey(RuntimeMode), | 87 | runtimeMode: Schema.optionalKey(RuntimeMode), |
| 80 | interactionMode: Schema.optionalKey(ProviderInteractionMode), | 88 | interactionMode: Schema.optionalKey(ProviderInteractionMode), |
| 81 | }); | 89 | }); |
| ... | @@ -88,7 +96,38 @@ const LegacyCodexFields = Schema.Struct({ | ... | @@ -88,7 +96,38 @@ const LegacyCodexFields = Schema.Struct({ |
| 88 | }); | 96 | }); |
| 89 | type LegacyCodexFields = typeof LegacyCodexFields.Type; | 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 | const PersistedDraftThreadState = Schema.Struct({ | 132 | const PersistedDraftThreadState = Schema.Struct({ |
| 94 | projectId: ProjectId, | 133 | projectId: ProjectId, |
| ... | @@ -105,8 +144,10 @@ const PersistedComposerDraftStoreState = Schema.Struct({ | ... | @@ -105,8 +144,10 @@ const PersistedComposerDraftStoreState = Schema.Struct({ |
| 105 | draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), | 144 | draftsByThreadId: Schema.Record(ThreadId, PersistedComposerThreadDraftState), |
| 106 | draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), | 145 | draftThreadsByThreadId: Schema.Record(ThreadId, PersistedDraftThreadState), |
| 107 | projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), | 146 | projectDraftThreadIdByProjectId: Schema.Record(ProjectId, ThreadId), |
| 108 | stickyModel: Schema.NullOr(Schema.String), | 147 | stickyModelSelectionByProvider: Schema.optionalKey( |
| 109 | stickyModelOptions: ProviderModelOptions, | 148 | Schema.Record(ProviderKind, Schema.optionalKey(ModelSelection)), |
| 149 | ), | ||
| 150 | stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderKind)), | ||
| 110 | }); | 151 | }); |
| 111 | type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; | 152 | type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; |
| 112 | 153 | ||
| ... | @@ -115,15 +156,14 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ | ... | @@ -115,15 +156,14 @@ const PersistedComposerDraftStoreStorage = Schema.Struct({ |
| 115 | state: PersistedComposerDraftStoreState, | 156 | state: PersistedComposerDraftStoreState, |
| 116 | }); | 157 | }); |
| 117 | 158 | ||
| 118 | interface ComposerThreadDraftState { | 159 | export interface ComposerThreadDraftState { |
| 119 | prompt: string; | 160 | prompt: string; |
| 120 | images: ComposerImageAttachment[]; | 161 | images: ComposerImageAttachment[]; |
| 121 | nonPersistedImageIds: string[]; | 162 | nonPersistedImageIds: string[]; |
| 122 | persistedAttachments: PersistedComposerImageAttachment[]; | 163 | persistedAttachments: PersistedComposerImageAttachment[]; |
| 123 | terminalContexts: TerminalContextDraft[]; | 164 | terminalContexts: TerminalContextDraft[]; |
| 124 | provider: ProviderKind | null; | 165 | modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; |
| 125 | model: string | null; | 166 | activeProvider: ProviderKind | null; |
| 126 | modelOptions: ProviderModelOptions | null; | ||
| 127 | runtimeMode: RuntimeMode | null; | 167 | runtimeMode: RuntimeMode | null; |
| 128 | interactionMode: ProviderInteractionMode | null; | 168 | interactionMode: ProviderInteractionMode | null; |
| 129 | } | 169 | } |
| ... | @@ -146,8 +186,8 @@ interface ComposerDraftStoreState { | ... | @@ -146,8 +186,8 @@ interface ComposerDraftStoreState { |
| 146 | draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>; | 186 | draftsByThreadId: Record<ThreadId, ComposerThreadDraftState>; |
| 147 | draftThreadsByThreadId: Record<ThreadId, DraftThreadState>; | 187 | draftThreadsByThreadId: Record<ThreadId, DraftThreadState>; |
| 148 | projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; | 188 | projectDraftThreadIdByProjectId: Record<ProjectId, ThreadId>; |
| 149 | stickyModel: string | null; | 189 | stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>>; |
| 150 | stickyModelOptions: ProviderModelOptions; | 190 | stickyActiveProvider: ProviderKind | null; |
| 151 | getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; | 191 | getDraftThreadByProjectId: (projectId: ProjectId) => ProjectDraftThread | null; |
| 152 | getDraftThread: (threadId: ThreadId) => DraftThreadState | null; | 192 | getDraftThread: (threadId: ThreadId) => DraftThreadState | null; |
| 153 | setProjectDraftThreadId: ( | 193 | setProjectDraftThreadId: ( |
| ... | @@ -177,16 +217,18 @@ interface ComposerDraftStoreState { | ... | @@ -177,16 +217,18 @@ interface ComposerDraftStoreState { |
| 177 | clearProjectDraftThreadId: (projectId: ProjectId) => void; | 217 | clearProjectDraftThreadId: (projectId: ProjectId) => void; |
| 178 | clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; | 218 | clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; |
| 179 | clearDraftThread: (threadId: ThreadId) => void; | 219 | clearDraftThread: (threadId: ThreadId) => void; |
| 180 | setStickyModel: (model: string | null | undefined) => void; | 220 | setStickyModelSelection: (modelSelection: ModelSelection | null | undefined) => void; |
| 181 | setStickyModelOptions: (modelOptions: ProviderModelOptions | null | undefined) => void; | ||
| 182 | setPrompt: (threadId: ThreadId, prompt: string) => void; | 221 | setPrompt: (threadId: ThreadId, prompt: string) => void; |
| 183 | setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; | 222 | setTerminalContexts: (threadId: ThreadId, contexts: TerminalContextDraft[]) => void; |
| 184 | setProvider: (threadId: ThreadId, provider: ProviderKind | null | undefined) => void; | 223 | setModelSelection: ( |
| 185 | setModel: (threadId: ThreadId, model: string | null | undefined) => void; | 224 | threadId: ThreadId, |
| 225 | modelSelection: ModelSelection | null | undefined, | ||
| 226 | ) => void; | ||
| 186 | setModelOptions: ( | 227 | setModelOptions: ( |
| 187 | threadId: ThreadId, | 228 | threadId: ThreadId, |
| 188 | modelOptions: ProviderModelOptions | null | undefined, | 229 | modelOptions: ProviderModelOptions | null | undefined, |
| 189 | ) => void; | 230 | ) => void; |
| 231 | applyStickyState: (threadId: ThreadId) => void; | ||
| 190 | setProviderModelOptions: ( | 232 | setProviderModelOptions: ( |
| 191 | threadId: ThreadId, | 233 | threadId: ThreadId, |
| 192 | provider: ProviderKind, | 234 | provider: ProviderKind, |
| ... | @@ -221,14 +263,42 @@ interface ComposerDraftStoreState { | ... | @@ -221,14 +263,42 @@ interface ComposerDraftStoreState { |
| 221 | clearComposerContent: (threadId: ThreadId) => void; | 263 | clearComposerContent: (threadId: ThreadId) => void; |
| 222 | } | 264 | } |
| 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 | const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ | 296 | const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze<PersistedComposerDraftStoreState>({ |
| 227 | draftsByThreadId: {}, | 297 | draftsByThreadId: {}, |
| 228 | draftThreadsByThreadId: {}, | 298 | draftThreadsByThreadId: {}, |
| 229 | projectDraftThreadIdByProjectId: {}, | 299 | projectDraftThreadIdByProjectId: {}, |
| 230 | stickyModel: null, | 300 | stickyModelSelectionByProvider: {}, |
| 231 | stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, | 301 | stickyActiveProvider: null, |
| 232 | }); | 302 | }); |
| 233 | 303 | ||
| 234 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; | 304 | const EMPTY_IMAGES: ComposerImageAttachment[] = []; |
| ... | @@ -238,15 +308,17 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; | ... | @@ -238,15 +308,17 @@ const EMPTY_TERMINAL_CONTEXTS: TerminalContextDraft[] = []; |
| 238 | Object.freeze(EMPTY_IMAGES); | 308 | Object.freeze(EMPTY_IMAGES); |
| 239 | Object.freeze(EMPTY_IDS); | 309 | Object.freeze(EMPTY_IDS); |
| 240 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); | 310 | Object.freeze(EMPTY_PERSISTED_ATTACHMENTS); |
| 311 | const EMPTY_MODEL_SELECTION_BY_PROVIDER: Partial<Record<ProviderKind, ModelSelection>> = | ||
| 312 | Object.freeze({}); | ||
| 313 | |||
| 241 | const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({ | 314 | const EMPTY_THREAD_DRAFT = Object.freeze<ComposerThreadDraftState>({ |
| 242 | prompt: "", | 315 | prompt: "", |
| 243 | images: EMPTY_IMAGES, | 316 | images: EMPTY_IMAGES, |
| 244 | nonPersistedImageIds: EMPTY_IDS, | 317 | nonPersistedImageIds: EMPTY_IDS, |
| 245 | persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, | 318 | persistedAttachments: EMPTY_PERSISTED_ATTACHMENTS, |
| 246 | terminalContexts: EMPTY_TERMINAL_CONTEXTS, | 319 | terminalContexts: EMPTY_TERMINAL_CONTEXTS, |
| 247 | provider: null, | 320 | modelSelectionByProvider: EMPTY_MODEL_SELECTION_BY_PROVIDER, |
| 248 | model: null, | 321 | activeProvider: null, |
| 249 | modelOptions: null, | ||
| 250 | runtimeMode: null, | 322 | runtimeMode: null, |
| 251 | interactionMode: null, | 323 | interactionMode: null, |
| 252 | }); | 324 | }); |
| ... | @@ -258,9 +330,8 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { | ... | @@ -258,9 +330,8 @@ function createEmptyThreadDraft(): ComposerThreadDraftState { |
| 258 | nonPersistedImageIds: [], | 330 | nonPersistedImageIds: [], |
| 259 | persistedAttachments: [], | 331 | persistedAttachments: [], |
| 260 | terminalContexts: [], | 332 | terminalContexts: [], |
| 261 | provider: null, | 333 | modelSelectionByProvider: {}, |
| 262 | model: null, | 334 | activeProvider: null, |
| 263 | modelOptions: null, | ||
| 264 | runtimeMode: null, | 335 | runtimeMode: null, |
| 265 | interactionMode: null, | 336 | interactionMode: null, |
| 266 | }; | 337 | }; |
| ... | @@ -329,9 +400,8 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { | ... | @@ -329,9 +400,8 @@ function shouldRemoveDraft(draft: ComposerThreadDraftState): boolean { |
| 329 | draft.images.length === 0 && | 400 | draft.images.length === 0 && |
| 330 | draft.persistedAttachments.length === 0 && | 401 | draft.persistedAttachments.length === 0 && |
| 331 | draft.terminalContexts.length === 0 && | 402 | draft.terminalContexts.length === 0 && |
| 332 | draft.provider === null && | 403 | Object.keys(draft.modelSelectionByProvider).length === 0 && |
| 333 | draft.model === null && | 404 | draft.activeProvider === null && |
| 334 | draft.modelOptions === null && | ||
| 335 | draft.runtimeMode === null && | 405 | draft.runtimeMode === null && |
| 336 | draft.interactionMode === null | 406 | draft.interactionMode === null |
| 337 | ); | 407 | ); |
| ... | @@ -370,20 +440,28 @@ function normalizeProviderModelOptions( | ... | @@ -370,20 +440,28 @@ function normalizeProviderModelOptions( |
| 370 | ? legacy.effort | 440 | ? legacy.effort |
| 371 | : undefined; | 441 | : undefined; |
| 372 | const codexFastMode = | 442 | const codexFastMode = |
| 373 | codexCandidate?.fastMode === true || | 443 | codexCandidate?.fastMode === true |
| 374 | (provider === "codex" && legacy?.codexFastMode === true) || | 444 | ? true |
| 375 | (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast"); | 445 | : codexCandidate?.fastMode === false |
| 446 | ? false | ||
| 447 | : (provider === "codex" && legacy?.codexFastMode === true) || | ||
| 448 | (typeof legacy?.serviceTier === "string" && legacy.serviceTier === "fast") | ||
| 449 | ? true | ||
| 450 | : undefined; | ||
| 376 | const codex = | 451 | const codex = |
| 377 | codexReasoningEffort && codexReasoningEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex | 452 | codexReasoningEffort !== undefined || codexFastMode !== undefined |
| 378 | ? { | 453 | ? { |
| 379 | reasoningEffort: codexReasoningEffort, | 454 | ...(codexReasoningEffort !== undefined ? { reasoningEffort: codexReasoningEffort } : {}), |
| 380 | ...(codexFastMode ? { fastMode: true } : {}), | 455 | ...(codexFastMode !== undefined ? { fastMode: codexFastMode } : {}), |
| 381 | } | 456 | } |
| 382 | : codexFastMode | 457 | : undefined; |
| 383 | ? { fastMode: true } | ||
| 384 | : 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 | const claudeEffort: ClaudeCodeEffort | undefined = | 465 | const claudeEffort: ClaudeCodeEffort | undefined = |
| 388 | claudeCandidate?.effort === "low" || | 466 | claudeCandidate?.effort === "low" || |
| 389 | claudeCandidate?.effort === "medium" || | 467 | claudeCandidate?.effort === "medium" || |
| ... | @@ -392,17 +470,18 @@ function normalizeProviderModelOptions( | ... | @@ -392,17 +470,18 @@ function normalizeProviderModelOptions( |
| 392 | claudeCandidate?.effort === "ultrathink" | 470 | claudeCandidate?.effort === "ultrathink" |
| 393 | ? claudeCandidate.effort | 471 | ? claudeCandidate.effort |
| 394 | : undefined; | 472 | : undefined; |
| 395 | const claudeFastMode = claudeCandidate?.fastMode === true; | 473 | const claudeFastMode = |
| 474 | claudeCandidate?.fastMode === true | ||
| 475 | ? true | ||
| 476 | : claudeCandidate?.fastMode === false | ||
| 477 | ? false | ||
| 478 | : undefined; | ||
| 396 | const claude = | 479 | const claude = |
| 397 | claudeThinking === false || | 480 | claudeThinking !== undefined || claudeEffort !== undefined || claudeFastMode !== undefined |
| 398 | (claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent) || | ||
| 399 | claudeFastMode | ||
| 400 | ? { | 481 | ? { |
| 401 | ...(claudeThinking === false ? { thinking: false } : {}), | 482 | ...(claudeThinking !== undefined ? { thinking: claudeThinking } : {}), |
| 402 | ...(claudeEffort && claudeEffort !== DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent | 483 | ...(claudeEffort !== undefined ? { effort: claudeEffort } : {}), |
| 403 | ? { effort: claudeEffort } | 484 | ...(claudeFastMode !== undefined ? { fastMode: claudeFastMode } : {}), |
| 404 | : {}), | ||
| 405 | ...(claudeFastMode ? { fastMode: true } : {}), | ||
| 406 | } | 485 | } |
| 407 | : undefined; | 486 | : undefined; |
| 408 | 487 | ||
| ... | @@ -415,7 +494,73 @@ function normalizeProviderModelOptions( | ... | @@ -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 | currentModelOptions: ProviderModelOptions | null | undefined, | 564 | currentModelOptions: ProviderModelOptions | null | undefined, |
| 420 | provider: ProviderKind, | 565 | provider: ProviderKind, |
| 421 | nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, | 566 | nextProviderOptions: ProviderModelOptions[ProviderKind] | null | undefined, |
| ... | @@ -433,6 +578,72 @@ function replaceProviderModelOptions( | ... | @@ -433,6 +578,72 @@ function replaceProviderModelOptions( |
| 433 | }); | 578 | }); |
| 434 | } | 579 | } |
| 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 | function revokeObjectPreviewUrl(previewUrl: string): void { | 647 | function revokeObjectPreviewUrl(previewUrl: string): void { |
| 437 | if (typeof URL === "undefined") { | 648 | if (typeof URL === "undefined") { |
| 438 | return; | 649 | return; |
| ... | @@ -619,10 +830,6 @@ function normalizePersistedDraftThreads( | ... | @@ -619,10 +830,6 @@ function normalizePersistedDraftThreads( |
| 619 | 830 | ||
| 620 | function normalizePersistedDraftsByThreadId( | 831 | function normalizePersistedDraftsByThreadId( |
| 621 | rawDraftMap: unknown, | 832 | rawDraftMap: unknown, |
| 622 | resolveModelOptions: ( | ||
| 623 | draftCandidate: PersistedComposerThreadDraftState | LegacyPersistedCodexThreadDraftState, | ||
| 624 | provider: ProviderKind | null, | ||
| 625 | ) => ProviderModelOptions | null, | ||
| 626 | ): PersistedComposerDraftStoreState["draftsByThreadId"] { | 833 | ): PersistedComposerDraftStoreState["draftsByThreadId"] { |
| 627 | if (!rawDraftMap || typeof rawDraftMap !== "object") { | 834 | if (!rawDraftMap || typeof rawDraftMap !== "object") { |
| 628 | return {}; | 835 | return {}; |
| ... | @@ -637,9 +844,7 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -637,9 +844,7 @@ function normalizePersistedDraftsByThreadId( |
| 637 | if (!draftValue || typeof draftValue !== "object") { | 844 | if (!draftValue || typeof draftValue !== "object") { |
| 638 | continue; | 845 | continue; |
| 639 | } | 846 | } |
| 640 | const draftCandidate = draftValue as | 847 | const draftCandidate = draftValue as PersistedComposerThreadDraftState; |
| 641 | | PersistedComposerThreadDraftState | ||
| 642 | | LegacyPersistedCodexThreadDraftState; | ||
| 643 | const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : ""; | 848 | const promptCandidate = typeof draftCandidate.prompt === "string" ? draftCandidate.prompt : ""; |
| 644 | const attachments = Array.isArray(draftCandidate.attachments) | 849 | const attachments = Array.isArray(draftCandidate.attachments) |
| 645 | ? draftCandidate.attachments.flatMap((entry) => { | 850 | ? draftCandidate.attachments.flatMap((entry) => { |
| ... | @@ -653,11 +858,6 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -653,11 +858,6 @@ function normalizePersistedDraftsByThreadId( |
| 653 | return normalized ? [normalized] : []; | 858 | return normalized ? [normalized] : []; |
| 654 | }) | 859 | }) |
| 655 | : []; | 860 | : []; |
| 656 | const provider = normalizeProviderKind(draftCandidate.provider); | ||
| 657 | const model = | ||
| 658 | typeof draftCandidate.model === "string" | ||
| 659 | ? normalizeModelSlug(draftCandidate.model, provider ?? "codex") | ||
| 660 | : null; | ||
| 661 | const runtimeMode = | 861 | const runtimeMode = |
| 662 | draftCandidate.runtimeMode === "approval-required" || | 862 | draftCandidate.runtimeMode === "approval-required" || |
| 663 | draftCandidate.runtimeMode === "full-access" | 863 | draftCandidate.runtimeMode === "full-access" |
| ... | @@ -667,18 +867,63 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -667,18 +867,63 @@ function normalizePersistedDraftsByThreadId( |
| 667 | draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default" | 867 | draftCandidate.interactionMode === "plan" || draftCandidate.interactionMode === "default" |
| 668 | ? draftCandidate.interactionMode | 868 | ? draftCandidate.interactionMode |
| 669 | : null; | 869 | : null; |
| 670 | const modelOptions = resolveModelOptions(draftCandidate, provider); | ||
| 671 | const prompt = ensureInlineTerminalContextPlaceholders( | 870 | const prompt = ensureInlineTerminalContextPlaceholders( |
| 672 | promptCandidate, | 871 | promptCandidate, |
| 673 | terminalContexts.length, | 872 | terminalContexts.length, |
| 674 | ); | 873 | ); |
| 874 | // If the draft already has the v3 shape, use it directly | ||
| 875 | const legacyDraftCandidate = draftValue as LegacyPersistedComposerThreadDraftState; | ||
| 876 | let modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = {}; | ||
| 877 | let activeProvider: ProviderKind | null = null; | ||
| 878 | |||
| 879 | if ( | ||
| 880 | draftCandidate.modelSelectionByProvider && | ||
| 881 | typeof draftCandidate.modelSelectionByProvider === "object" | ||
| 882 | ) { | ||
| 883 | // v3 format | ||
| 884 | modelSelectionByProvider = draftCandidate.modelSelectionByProvider as Partial< | ||
| 885 | Record<ProviderKind, ModelSelection> | ||
| 886 | >; | ||
| 887 | activeProvider = normalizeProviderKind(draftCandidate.activeProvider); | ||
| 888 | } else { | ||
| 889 | // v2 or legacy format: migrate | ||
| 890 | const normalizedModelOptions = | ||
| 891 | normalizeProviderModelOptions( | ||
| 892 | legacyDraftCandidate.modelOptions, | ||
| 893 | undefined, | ||
| 894 | legacyDraftCandidate, | ||
| 895 | ) ?? null; | ||
| 896 | const normalizedModelSelection = normalizeModelSelection( | ||
| 897 | legacyDraftCandidate.modelSelection, | ||
| 898 | { | ||
| 899 | provider: legacyDraftCandidate.provider, | ||
| 900 | model: legacyDraftCandidate.model, | ||
| 901 | modelOptions: normalizedModelOptions ?? legacyDraftCandidate.modelOptions, | ||
| 902 | legacyCodex: legacyDraftCandidate, | ||
| 903 | }, | ||
| 904 | ); | ||
| 905 | const mergedModelOptions = legacyMergeModelSelectionIntoProviderModelOptions( | ||
| 906 | normalizedModelSelection, | ||
| 907 | normalizedModelOptions, | ||
| 908 | ); | ||
| 909 | const modelSelection = legacySyncModelSelectionOptions( | ||
| 910 | normalizedModelSelection, | ||
| 911 | mergedModelOptions, | ||
| 912 | ); | ||
| 913 | modelSelectionByProvider = legacyToModelSelectionByProvider( | ||
| 914 | modelSelection, | ||
| 915 | mergedModelOptions, | ||
| 916 | ); | ||
| 917 | activeProvider = modelSelection?.provider ?? null; | ||
| 918 | } | ||
| 919 | |||
| 920 | const hasModelData = | ||
| 921 | Object.keys(modelSelectionByProvider).length > 0 || activeProvider !== null; | ||
| 675 | if ( | 922 | if ( |
| 676 | promptCandidate.length === 0 && | 923 | promptCandidate.length === 0 && |
| 677 | attachments.length === 0 && | 924 | attachments.length === 0 && |
| 678 | terminalContexts.length === 0 && | 925 | terminalContexts.length === 0 && |
| 679 | !provider && | 926 | !hasModelData && |
| 680 | !model && | ||
| 681 | modelOptions === null && | ||
| 682 | !runtimeMode && | 927 | !runtimeMode && |
| 683 | !interactionMode | 928 | !interactionMode |
| 684 | ) { | 929 | ) { |
| ... | @@ -688,9 +933,7 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -688,9 +933,7 @@ function normalizePersistedDraftsByThreadId( |
| 688 | prompt, | 933 | prompt, |
| 689 | attachments, | 934 | attachments, |
| 690 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), | 935 | ...(terminalContexts.length > 0 ? { terminalContexts } : {}), |
| 691 | ...(provider ? { provider } : {}), | 936 | ...(hasModelData ? { modelSelectionByProvider, activeProvider } : {}), |
| 692 | ...(model ? { model } : {}), | ||
| 693 | ...(modelOptions ? { modelOptions } : {}), | ||
| 694 | ...(runtimeMode ? { runtimeMode } : {}), | 937 | ...(runtimeMode ? { runtimeMode } : {}), |
| 695 | ...(interactionMode ? { interactionMode } : {}), | 938 | ...(interactionMode ? { interactionMode } : {}), |
| 696 | }; | 939 | }; |
| ... | @@ -701,40 +944,45 @@ function normalizePersistedDraftsByThreadId( | ... | @@ -701,40 +944,45 @@ function normalizePersistedDraftsByThreadId( |
| 701 | 944 | ||
| 702 | function migratePersistedComposerDraftStoreState( | 945 | function migratePersistedComposerDraftStoreState( |
| 703 | persistedState: unknown, | 946 | persistedState: unknown, |
| 704 | persistedVersion: number, | ||
| 705 | ): PersistedComposerDraftStoreState { | 947 | ): PersistedComposerDraftStoreState { |
| 706 | if (!persistedState || typeof persistedState !== "object") { | 948 | if (!persistedState || typeof persistedState !== "object") { |
| 707 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; | 949 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 708 | } | 950 | } |
| 709 | const candidate = persistedState as Record<string, unknown>; | 951 | const candidate = persistedState as LegacyPersistedComposerDraftStoreState; |
| 710 | const rawDraftMap = candidate.draftsByThreadId; | 952 | const rawDraftMap = candidate.draftsByThreadId; |
| 711 | const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; | 953 | const rawDraftThreadsByThreadId = candidate.draftThreadsByThreadId; |
| 712 | const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; | 954 | const rawProjectDraftThreadIdByProjectId = candidate.projectDraftThreadIdByProjectId; |
| 713 | const stickyModel = | 955 | |
| 714 | typeof candidate.stickyModel === "string" | 956 | // Migrate sticky state from v2 (dual) to v3 (consolidated) |
| 715 | ? (normalizeModelSlug(candidate.stickyModel, "codex") ?? null) | 957 | const stickyModelOptions = normalizeProviderModelOptions(candidate.stickyModelOptions) ?? {}; |
| 716 | : null; | 958 | const normalizedStickyModelSelection = normalizeModelSelection(candidate.stickyModelSelection, { |
| 717 | const stickyModelOptions = | 959 | provider: candidate.stickyProvider ?? "codex", |
| 718 | normalizeProviderModelOptions(candidate.stickyModelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; | 960 | model: candidate.stickyModel, |
| 961 | modelOptions: stickyModelOptions, | ||
| 962 | }); | ||
| 963 | const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions( | ||
| 964 | normalizedStickyModelSelection, | ||
| 965 | stickyModelOptions, | ||
| 966 | ); | ||
| 967 | const stickyModelSelection = legacySyncModelSelectionOptions( | ||
| 968 | normalizedStickyModelSelection, | ||
| 969 | nextStickyModelOptions, | ||
| 970 | ); | ||
| 971 | const stickyModelSelectionByProvider = legacyToModelSelectionByProvider( | ||
| 972 | stickyModelSelection, | ||
| 973 | nextStickyModelOptions, | ||
| 974 | ); | ||
| 975 | const stickyActiveProvider = normalizeProviderKind(candidate.stickyProvider) ?? null; | ||
| 976 | |||
| 719 | const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = | 977 | const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = |
| 720 | normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); | 978 | normalizePersistedDraftThreads(rawDraftThreadsByThreadId, rawProjectDraftThreadIdByProjectId); |
| 721 | const draftsByThreadId = normalizePersistedDraftsByThreadId( | 979 | const draftsByThreadId = normalizePersistedDraftsByThreadId(rawDraftMap); |
| 722 | rawDraftMap, | ||
| 723 | (draftCandidate, provider) => | ||
| 724 | persistedVersion >= COMPOSER_DRAFT_STORAGE_VERSION | ||
| 725 | ? normalizeProviderModelOptions(draftCandidate.modelOptions, provider) | ||
| 726 | : normalizeProviderModelOptions( | ||
| 727 | draftCandidate.modelOptions, | ||
| 728 | provider, | ||
| 729 | draftCandidate as LegacyPersistedCodexThreadDraftState, | ||
| 730 | ), | ||
| 731 | ); | ||
| 732 | return { | 980 | return { |
| 733 | draftsByThreadId, | 981 | draftsByThreadId, |
| 734 | draftThreadsByThreadId, | 982 | draftThreadsByThreadId, |
| 735 | projectDraftThreadIdByProjectId, | 983 | projectDraftThreadIdByProjectId, |
| 736 | stickyModel, | 984 | stickyModelSelectionByProvider, |
| 737 | stickyModelOptions, | 985 | stickyActiveProvider, |
| 738 | }; | 986 | }; |
| 739 | } | 987 | } |
| 740 | 988 | ||
| ... | @@ -748,13 +996,13 @@ function partializeComposerDraftStoreState( | ... | @@ -748,13 +996,13 @@ function partializeComposerDraftStoreState( |
| 748 | if (typeof threadId !== "string" || threadId.length === 0) { | 996 | if (typeof threadId !== "string" || threadId.length === 0) { |
| 749 | continue; | 997 | continue; |
| 750 | } | 998 | } |
| 999 | const hasModelData = | ||
| 1000 | Object.keys(draft.modelSelectionByProvider).length > 0 || draft.activeProvider !== null; | ||
| 751 | if ( | 1001 | if ( |
| 752 | draft.prompt.length === 0 && | 1002 | draft.prompt.length === 0 && |
| 753 | draft.persistedAttachments.length === 0 && | 1003 | draft.persistedAttachments.length === 0 && |
| 754 | draft.terminalContexts.length === 0 && | 1004 | draft.terminalContexts.length === 0 && |
| 755 | draft.provider === null && | 1005 | !hasModelData && |
| 756 | draft.model === null && | ||
| 757 | draft.modelOptions === null && | ||
| 758 | draft.runtimeMode === null && | 1006 | draft.runtimeMode === null && |
| 759 | draft.interactionMode === null | 1007 | draft.interactionMode === null |
| 760 | ) { | 1008 | ) { |
| ... | @@ -776,9 +1024,12 @@ function partializeComposerDraftStoreState( | ... | @@ -776,9 +1024,12 @@ function partializeComposerDraftStoreState( |
| 776 | })), | 1024 | })), |
| 777 | } | 1025 | } |
| 778 | : {}), | 1026 | : {}), |
| 779 | ...(draft.model ? { model: draft.model } : {}), | 1027 | ...(hasModelData |
| 780 | ...(draft.modelOptions ? { modelOptions: draft.modelOptions } : {}), | 1028 | ? { |
| 781 | ...(draft.provider ? { provider: draft.provider } : {}), | 1029 | modelSelectionByProvider: draft.modelSelectionByProvider, |
| 1030 | activeProvider: draft.activeProvider, | ||
| 1031 | } | ||
| 1032 | : {}), | ||
| 782 | ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), | 1033 | ...(draft.runtimeMode ? { runtimeMode: draft.runtimeMode } : {}), |
| 783 | ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), | 1034 | ...(draft.interactionMode ? { interactionMode: draft.interactionMode } : {}), |
| 784 | }; | 1035 | }; |
| ... | @@ -788,8 +1039,8 @@ function partializeComposerDraftStoreState( | ... | @@ -788,8 +1039,8 @@ function partializeComposerDraftStoreState( |
| 788 | draftsByThreadId: persistedDraftsByThreadId, | 1039 | draftsByThreadId: persistedDraftsByThreadId, |
| 789 | draftThreadsByThreadId: state.draftThreadsByThreadId, | 1040 | draftThreadsByThreadId: state.draftThreadsByThreadId, |
| 790 | projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, | 1041 | projectDraftThreadIdByProjectId: state.projectDraftThreadIdByProjectId, |
| 791 | stickyModel: state.stickyModel, | 1042 | stickyModelSelectionByProvider: state.stickyModelSelectionByProvider, |
| 792 | stickyModelOptions: state.stickyModelOptions, | 1043 | stickyActiveProvider: state.stickyActiveProvider, |
| 793 | }; | 1044 | }; |
| 794 | } | 1045 | } |
| 795 | 1046 | ||
| ... | @@ -799,29 +1050,58 @@ function normalizeCurrentPersistedComposerDraftStoreState( | ... | @@ -799,29 +1050,58 @@ function normalizeCurrentPersistedComposerDraftStoreState( |
| 799 | if (!persistedState || typeof persistedState !== "object") { | 1050 | if (!persistedState || typeof persistedState !== "object") { |
| 800 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; | 1051 | return EMPTY_PERSISTED_DRAFT_STORE_STATE; |
| 801 | } | 1052 | } |
| 802 | const normalizedPersistedState = persistedState as Record<string, unknown>; | 1053 | const normalizedPersistedState = persistedState as LegacyPersistedComposerDraftStoreState; |
| 803 | const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = | 1054 | const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = |
| 804 | normalizePersistedDraftThreads( | 1055 | normalizePersistedDraftThreads( |
| 805 | normalizedPersistedState.draftThreadsByThreadId, | 1056 | normalizedPersistedState.draftThreadsByThreadId, |
| 806 | normalizedPersistedState.projectDraftThreadIdByProjectId, | 1057 | normalizedPersistedState.projectDraftThreadIdByProjectId, |
| 807 | ); | 1058 | ); |
| 808 | const stickyModel = | 1059 | |
| 809 | typeof normalizedPersistedState.stickyModel === "string" | 1060 | // Handle both v3 (modelSelectionByProvider) and v2/legacy formats |
| 810 | ? (normalizeModelSlug(normalizedPersistedState.stickyModel, "codex") ?? null) | 1061 | let stickyModelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = {}; |
| 811 | : null; | 1062 | let stickyActiveProvider: ProviderKind | null = null; |
| 812 | const stickyModelOptions = | 1063 | if ( |
| 813 | normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? | 1064 | normalizedPersistedState.stickyModelSelectionByProvider && |
| 814 | EMPTY_PROVIDER_MODEL_OPTIONS; | 1065 | typeof normalizedPersistedState.stickyModelSelectionByProvider === "object" |
| 1066 | ) { | ||
| 1067 | stickyModelSelectionByProvider = | ||
| 1068 | normalizedPersistedState.stickyModelSelectionByProvider as Partial< | ||
| 1069 | Record<ProviderKind, ModelSelection> | ||
| 1070 | >; | ||
| 1071 | stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyActiveProvider); | ||
| 1072 | } else { | ||
| 1073 | // Legacy migration path | ||
| 1074 | const stickyModelOptions = | ||
| 1075 | normalizeProviderModelOptions(normalizedPersistedState.stickyModelOptions) ?? {}; | ||
| 1076 | const normalizedStickyModelSelection = normalizeModelSelection( | ||
| 1077 | normalizedPersistedState.stickyModelSelection, | ||
| 1078 | { | ||
| 1079 | provider: normalizedPersistedState.stickyProvider, | ||
| 1080 | model: normalizedPersistedState.stickyModel, | ||
| 1081 | modelOptions: stickyModelOptions, | ||
| 1082 | }, | ||
| 1083 | ); | ||
| 1084 | const nextStickyModelOptions = legacyMergeModelSelectionIntoProviderModelOptions( | ||
| 1085 | normalizedStickyModelSelection, | ||
| 1086 | stickyModelOptions, | ||
| 1087 | ); | ||
| 1088 | const stickyModelSelection = legacySyncModelSelectionOptions( | ||
| 1089 | normalizedStickyModelSelection, | ||
| 1090 | nextStickyModelOptions, | ||
| 1091 | ); | ||
| 1092 | stickyModelSelectionByProvider = legacyToModelSelectionByProvider( | ||
| 1093 | stickyModelSelection, | ||
| 1094 | nextStickyModelOptions, | ||
| 1095 | ); | ||
| 1096 | stickyActiveProvider = normalizeProviderKind(normalizedPersistedState.stickyProvider); | ||
| 1097 | } | ||
| 1098 | |||
| 815 | return { | 1099 | return { |
| 816 | draftsByThreadId: normalizePersistedDraftsByThreadId( | 1100 | draftsByThreadId: normalizePersistedDraftsByThreadId(normalizedPersistedState.draftsByThreadId), |
| 817 | normalizedPersistedState.draftsByThreadId, | ||
| 818 | (draftCandidate, provider) => | ||
| 819 | normalizeProviderModelOptions(draftCandidate.modelOptions, provider), | ||
| 820 | ), | ||
| 821 | draftThreadsByThreadId, | 1101 | draftThreadsByThreadId, |
| 822 | projectDraftThreadIdByProjectId, | 1102 | projectDraftThreadIdByProjectId, |
| 823 | stickyModel, | 1103 | stickyModelSelectionByProvider, |
| 824 | stickyModelOptions, | 1104 | stickyActiveProvider, |
| 825 | }; | 1105 | }; |
| 826 | } | 1106 | } |
| 827 | 1107 | ||
| ... | @@ -948,6 +1228,11 @@ function hydrateImagesFromPersisted( | ... | @@ -948,6 +1228,11 @@ function hydrateImagesFromPersisted( |
| 948 | function toHydratedThreadDraft( | 1228 | function toHydratedThreadDraft( |
| 949 | persistedDraft: PersistedComposerThreadDraftState, | 1229 | persistedDraft: PersistedComposerThreadDraftState, |
| 950 | ): ComposerThreadDraftState { | 1230 | ): ComposerThreadDraftState { |
| 1231 | // The persisted draft is already in v3 shape (migration handles older formats) | ||
| 1232 | const modelSelectionByProvider: Partial<Record<ProviderKind, ModelSelection>> = | ||
| 1233 | persistedDraft.modelSelectionByProvider ?? {}; | ||
| 1234 | const activeProvider = normalizeProviderKind(persistedDraft.activeProvider) ?? null; | ||
| 1235 | |||
| 951 | return { | 1236 | return { |
| 952 | prompt: persistedDraft.prompt, | 1237 | prompt: persistedDraft.prompt, |
| 953 | images: hydrateImagesFromPersisted(persistedDraft.attachments), | 1238 | images: hydrateImagesFromPersisted(persistedDraft.attachments), |
| ... | @@ -958,9 +1243,8 @@ function toHydratedThreadDraft( | ... | @@ -958,9 +1243,8 @@ function toHydratedThreadDraft( |
| 958 | ...context, | 1243 | ...context, |
| 959 | text: "", | 1244 | text: "", |
| 960 | })) ?? [], | 1245 | })) ?? [], |
| 961 | provider: persistedDraft.provider ?? null, | 1246 | modelSelectionByProvider, |
| 962 | model: persistedDraft.model ?? null, | 1247 | activeProvider, |
| 963 | modelOptions: persistedDraft.modelOptions ?? null, | ||
| 964 | runtimeMode: persistedDraft.runtimeMode ?? null, | 1248 | runtimeMode: persistedDraft.runtimeMode ?? null, |
| 965 | interactionMode: persistedDraft.interactionMode ?? null, | 1249 | interactionMode: persistedDraft.interactionMode ?? null, |
| 966 | }; | 1250 | }; |
| ... | @@ -972,8 +1256,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -972,8 +1256,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 972 | draftsByThreadId: {}, | 1256 | draftsByThreadId: {}, |
| 973 | draftThreadsByThreadId: {}, | 1257 | draftThreadsByThreadId: {}, |
| 974 | projectDraftThreadIdByProjectId: {}, | 1258 | projectDraftThreadIdByProjectId: {}, |
| 975 | stickyModel: null, | 1259 | stickyModelSelectionByProvider: {}, |
| 976 | stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, | 1260 | stickyActiveProvider: null, |
| 977 | getDraftThreadByProjectId: (projectId) => { | 1261 | getDraftThreadByProjectId: (projectId) => { |
| 978 | if (projectId.length === 0) { | 1262 | if (projectId.length === 0) { |
| 979 | return null; | 1263 | return null; |
| ... | @@ -1219,27 +1503,67 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1219,27 +1503,67 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1219 | }; | 1503 | }; |
| 1220 | }); | 1504 | }); |
| 1221 | }, | 1505 | }, |
| 1222 | setStickyModel: (model) => { | 1506 | setStickyModelSelection: (modelSelection) => { |
| 1223 | const normalizedModel = normalizeModelSlug(model, "codex") ?? null; | 1507 | const normalized = normalizeModelSelection(modelSelection); |
| 1224 | set((state) => { | 1508 | set((state) => { |
| 1225 | if (state.stickyModel === normalizedModel) { | 1509 | if (!normalized) { |
| 1226 | return state; | 1510 | return state; |
| 1227 | } | 1511 | } |
| 1512 | const nextMap: Partial<Record<ProviderKind, ModelSelection>> = { | ||
| 1513 | ...state.stickyModelSelectionByProvider, | ||
| 1514 | [normalized.provider]: normalized, | ||
| 1515 | }; | ||
| 1516 | if (Equal.equals(state.stickyModelSelectionByProvider, nextMap)) { | ||
| 1517 | return state.stickyActiveProvider === normalized.provider | ||
| 1518 | ? state | ||
| 1519 | : { stickyActiveProvider: normalized.provider }; | ||
| 1520 | } | ||
| 1228 | return { | 1521 | return { |
| 1229 | stickyModel: normalizedModel, | 1522 | stickyModelSelectionByProvider: nextMap, |
| 1523 | stickyActiveProvider: normalized.provider, | ||
| 1230 | }; | 1524 | }; |
| 1231 | }); | 1525 | }); |
| 1232 | }, | 1526 | }, |
| 1233 | setStickyModelOptions: (modelOptions) => { | 1527 | applyStickyState: (threadId) => { |
| 1234 | const normalizedModelOptions = | 1528 | if (threadId.length === 0) { |
| 1235 | normalizeProviderModelOptions(modelOptions) ?? EMPTY_PROVIDER_MODEL_OPTIONS; | 1529 | return; |
| 1530 | } | ||
| 1236 | set((state) => { | 1531 | set((state) => { |
| 1237 | if (Equal.equals(state.stickyModelOptions, normalizedModelOptions)) { | 1532 | const stickyMap = state.stickyModelSelectionByProvider; |
| 1533 | const stickyActiveProvider = state.stickyActiveProvider; | ||
| 1534 | if (Object.keys(stickyMap).length === 0 && stickyActiveProvider === null) { | ||
| 1238 | return state; | 1535 | return state; |
| 1239 | } | 1536 | } |
| 1240 | return { | 1537 | const existing = state.draftsByThreadId[threadId]; |
| 1241 | stickyModelOptions: normalizedModelOptions, | 1538 | const base = existing ?? createEmptyThreadDraft(); |
| 1539 | const nextMap = { ...base.modelSelectionByProvider }; | ||
| 1540 | for (const [provider, selection] of Object.entries(stickyMap)) { | ||
| 1541 | if (selection) { | ||
| 1542 | const current = nextMap[provider as ProviderKind]; | ||
| 1543 | nextMap[provider as ProviderKind] = { | ||
| 1544 | ...selection, | ||
| 1545 | model: current?.model ?? selection.model, | ||
| 1546 | }; | ||
| 1547 | } | ||
| 1548 | } | ||
| 1549 | if ( | ||
| 1550 | Equal.equals(base.modelSelectionByProvider, nextMap) && | ||
| 1551 | base.activeProvider === stickyActiveProvider | ||
| 1552 | ) { | ||
| 1553 | return state; | ||
| 1554 | } | ||
| 1555 | const nextDraft: ComposerThreadDraftState = { | ||
| 1556 | ...base, | ||
| 1557 | modelSelectionByProvider: nextMap, | ||
| 1558 | activeProvider: stickyActiveProvider, | ||
| 1242 | }; | 1559 | }; |
| 1560 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | ||
| 1561 | if (shouldRemoveDraft(nextDraft)) { | ||
| 1562 | delete nextDraftsByThreadId[threadId]; | ||
| 1563 | } else { | ||
| 1564 | nextDraftsByThreadId[threadId] = nextDraft; | ||
| 1565 | } | ||
| 1566 | return { draftsByThreadId: nextDraftsByThreadId }; | ||
| 1243 | }); | 1567 | }); |
| 1244 | }, | 1568 | }, |
| 1245 | setPrompt: (threadId, prompt) => { | 1569 | setPrompt: (threadId, prompt) => { |
| ... | @@ -1285,50 +1609,43 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1285,50 +1609,43 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1285 | return { draftsByThreadId: nextDraftsByThreadId }; | 1609 | return { draftsByThreadId: nextDraftsByThreadId }; |
| 1286 | }); | 1610 | }); |
| 1287 | }, | 1611 | }, |
| 1288 | setProvider: (threadId, provider) => { | 1612 | setModelSelection: (threadId, modelSelection) => { |
| 1289 | if (threadId.length === 0) { | 1613 | if (threadId.length === 0) { |
| 1290 | return; | 1614 | return; |
| 1291 | } | 1615 | } |
| 1292 | const normalizedProvider = normalizeProviderKind(provider); | 1616 | const normalized = normalizeModelSelection(modelSelection); |
| 1293 | set((state) => { | 1617 | set((state) => { |
| 1294 | const existing = state.draftsByThreadId[threadId]; | 1618 | const existing = state.draftsByThreadId[threadId]; |
| 1295 | if (!existing && normalizedProvider === null) { | 1619 | if (!existing && normalized === null) { |
| 1296 | return state; | 1620 | return state; |
| 1297 | } | 1621 | } |
| 1298 | const base = existing ?? createEmptyThreadDraft(); | 1622 | const base = existing ?? createEmptyThreadDraft(); |
| 1299 | if (base.provider === normalizedProvider) { | 1623 | const nextMap = { ...base.modelSelectionByProvider }; |
| 1300 | return state; | 1624 | if (normalized) { |
| 1301 | } | 1625 | const current = nextMap[normalized.provider]; |
| 1302 | const nextDraft: ComposerThreadDraftState = { | 1626 | if (normalized.options !== undefined) { |
| 1303 | ...base, | 1627 | // Explicit options provided → use them |
| 1304 | provider: normalizedProvider, | 1628 | nextMap[normalized.provider] = normalized; |
| 1305 | }; | 1629 | } else { |
| 1306 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 1630 | // No options in selection → preserve existing options, update provider+model |
| 1307 | if (shouldRemoveDraft(nextDraft)) { | 1631 | nextMap[normalized.provider] = { |
| 1308 | delete nextDraftsByThreadId[threadId]; | 1632 | provider: normalized.provider, |
| 1309 | } else { | 1633 | model: normalized.model, |
| 1310 | nextDraftsByThreadId[threadId] = nextDraft; | 1634 | ...(current?.options ? { options: current.options } : {}), |
| 1311 | } | 1635 | }; |
| 1312 | return { draftsByThreadId: nextDraftsByThreadId }; | 1636 | } |
| 1313 | }); | ||
| 1314 | }, | ||
| 1315 | setModel: (threadId, model) => { | ||
| 1316 | if (threadId.length === 0) { | ||
| 1317 | return; | ||
| 1318 | } | ||
| 1319 | set((state) => { | ||
| 1320 | const existing = state.draftsByThreadId[threadId]; | ||
| 1321 | const normalizedModel = normalizeModelSlug(model, existing?.provider ?? "codex") ?? null; | ||
| 1322 | if (!existing && normalizedModel === null) { | ||
| 1323 | return state; | ||
| 1324 | } | 1637 | } |
| 1325 | const base = existing ?? createEmptyThreadDraft(); | 1638 | const nextActiveProvider = normalized?.provider ?? base.activeProvider; |
| 1326 | if (base.model === normalizedModel) { | 1639 | if ( |
| 1640 | Equal.equals(base.modelSelectionByProvider, nextMap) && | ||
| 1641 | base.activeProvider === nextActiveProvider | ||
| 1642 | ) { | ||
| 1327 | return state; | 1643 | return state; |
| 1328 | } | 1644 | } |
| 1329 | const nextDraft: ComposerThreadDraftState = { | 1645 | const nextDraft: ComposerThreadDraftState = { |
| 1330 | ...base, | 1646 | ...base, |
| 1331 | model: normalizedModel, | 1647 | modelSelectionByProvider: nextMap, |
| 1648 | activeProvider: nextActiveProvider, | ||
| 1332 | }; | 1649 | }; |
| 1333 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 1650 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; |
| 1334 | if (shouldRemoveDraft(nextDraft)) { | 1651 | if (shouldRemoveDraft(nextDraft)) { |
| ... | @@ -1343,20 +1660,37 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1343,20 +1660,37 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1343 | if (threadId.length === 0) { | 1660 | if (threadId.length === 0) { |
| 1344 | return; | 1661 | return; |
| 1345 | } | 1662 | } |
| 1663 | const normalizedOpts = normalizeProviderModelOptions(modelOptions); | ||
| 1346 | set((state) => { | 1664 | set((state) => { |
| 1347 | const existing = state.draftsByThreadId[threadId]; | 1665 | const existing = state.draftsByThreadId[threadId]; |
| 1348 | const provider = existing?.provider ?? null; | 1666 | if (!existing && normalizedOpts === null) { |
| 1349 | const nextModelOptions = normalizeProviderModelOptions(modelOptions, provider); | ||
| 1350 | if (!existing && nextModelOptions === null) { | ||
| 1351 | return state; | 1667 | return state; |
| 1352 | } | 1668 | } |
| 1353 | const base = existing ?? createEmptyThreadDraft(); | 1669 | const base = existing ?? createEmptyThreadDraft(); |
| 1354 | if (Equal.equals(base.modelOptions, nextModelOptions)) { | 1670 | const nextMap = { ...base.modelSelectionByProvider }; |
| 1671 | for (const provider of ["codex", "claudeAgent"] as const) { | ||
| 1672 | // Only touch providers explicitly present in the input | ||
| 1673 | if (!normalizedOpts || !(provider in normalizedOpts)) continue; | ||
| 1674 | const opts = normalizedOpts[provider]; | ||
| 1675 | const current = nextMap[provider]; | ||
| 1676 | if (opts) { | ||
| 1677 | nextMap[provider] = { | ||
| 1678 | provider, | ||
| 1679 | model: current?.model ?? getDefaultModel(provider), | ||
| 1680 | options: opts, | ||
| 1681 | }; | ||
| 1682 | } else if (current?.options) { | ||
| 1683 | // Remove options but keep the selection | ||
| 1684 | const { options: _, ...rest } = current; | ||
| 1685 | nextMap[provider] = rest as ModelSelection; | ||
| 1686 | } | ||
| 1687 | } | ||
| 1688 | if (Equal.equals(base.modelSelectionByProvider, nextMap)) { | ||
| 1355 | return state; | 1689 | return state; |
| 1356 | } | 1690 | } |
| 1357 | const nextDraft: ComposerThreadDraftState = { | 1691 | const nextDraft: ComposerThreadDraftState = { |
| 1358 | ...base, | 1692 | ...base, |
| 1359 | modelOptions: nextModelOptions, | 1693 | modelSelectionByProvider: nextMap, |
| 1360 | }; | 1694 | }; |
| 1361 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 1695 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; |
| 1362 | if (shouldRemoveDraft(nextDraft)) { | 1696 | if (shouldRemoveDraft(nextDraft)) { |
| ... | @@ -1375,29 +1709,67 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1375,29 +1709,67 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1375 | if (normalizedProvider === null) { | 1709 | if (normalizedProvider === null) { |
| 1376 | return; | 1710 | return; |
| 1377 | } | 1711 | } |
| 1712 | // Normalize just this provider's options | ||
| 1713 | const normalizedOpts = normalizeProviderModelOptions( | ||
| 1714 | { [normalizedProvider]: nextProviderOptions }, | ||
| 1715 | normalizedProvider, | ||
| 1716 | ); | ||
| 1717 | const providerOpts = normalizedOpts?.[normalizedProvider]; | ||
| 1718 | |||
| 1378 | set((state) => { | 1719 | set((state) => { |
| 1379 | const existing = state.draftsByThreadId[threadId]; | 1720 | const existing = state.draftsByThreadId[threadId]; |
| 1380 | const base = existing ?? createEmptyThreadDraft(); | 1721 | const base = existing ?? createEmptyThreadDraft(); |
| 1381 | const nextModelOptions = replaceProviderModelOptions( | 1722 | |
| 1382 | base.modelOptions, | 1723 | // Update the map entry for this provider |
| 1383 | normalizedProvider, | 1724 | const nextMap = { ...base.modelSelectionByProvider }; |
| 1384 | nextProviderOptions, | 1725 | const currentForProvider = nextMap[normalizedProvider]; |
| 1385 | ); | 1726 | if (providerOpts) { |
| 1386 | const nextStickyModelOptions = | 1727 | nextMap[normalizedProvider] = { |
| 1387 | options?.persistSticky === true | 1728 | provider: normalizedProvider, |
| 1388 | ? (nextModelOptions ?? EMPTY_PROVIDER_MODEL_OPTIONS) | 1729 | model: currentForProvider?.model ?? getDefaultModel(normalizedProvider), |
| 1389 | : state.stickyModelOptions; | 1730 | options: providerOpts, |
| 1731 | }; | ||
| 1732 | } else if (currentForProvider?.options) { | ||
| 1733 | const { options: _, ...rest } = currentForProvider; | ||
| 1734 | nextMap[normalizedProvider] = rest as ModelSelection; | ||
| 1735 | } | ||
| 1736 | |||
| 1737 | // Handle sticky persistence | ||
| 1738 | let nextStickyMap = state.stickyModelSelectionByProvider; | ||
| 1739 | let nextStickyActiveProvider = state.stickyActiveProvider; | ||
| 1740 | if (options?.persistSticky === true) { | ||
| 1741 | nextStickyMap = { ...state.stickyModelSelectionByProvider }; | ||
| 1742 | const stickyBase = | ||
| 1743 | nextStickyMap[normalizedProvider] ?? | ||
| 1744 | base.modelSelectionByProvider[normalizedProvider] ?? | ||
| 1745 | ({ | ||
| 1746 | provider: normalizedProvider, | ||
| 1747 | model: getDefaultModel(normalizedProvider), | ||
| 1748 | } as ModelSelection); | ||
| 1749 | if (providerOpts) { | ||
| 1750 | nextStickyMap[normalizedProvider] = { | ||
| 1751 | ...stickyBase, | ||
| 1752 | provider: normalizedProvider, | ||
| 1753 | options: providerOpts, | ||
| 1754 | }; | ||
| 1755 | } else if (stickyBase.options) { | ||
| 1756 | const { options: _, ...rest } = stickyBase; | ||
| 1757 | nextStickyMap[normalizedProvider] = rest as ModelSelection; | ||
| 1758 | } | ||
| 1759 | nextStickyActiveProvider = base.activeProvider ?? normalizedProvider; | ||
| 1760 | } | ||
| 1390 | 1761 | ||
| 1391 | if ( | 1762 | if ( |
| 1392 | Equal.equals(base.modelOptions, nextModelOptions) && | 1763 | Equal.equals(base.modelSelectionByProvider, nextMap) && |
| 1393 | Equal.equals(state.stickyModelOptions, nextStickyModelOptions) | 1764 | Equal.equals(state.stickyModelSelectionByProvider, nextStickyMap) && |
| 1765 | state.stickyActiveProvider === nextStickyActiveProvider | ||
| 1394 | ) { | 1766 | ) { |
| 1395 | return state; | 1767 | return state; |
| 1396 | } | 1768 | } |
| 1397 | 1769 | ||
| 1398 | const nextDraft: ComposerThreadDraftState = { | 1770 | const nextDraft: ComposerThreadDraftState = { |
| 1399 | ...base, | 1771 | ...base, |
| 1400 | modelOptions: nextModelOptions, | 1772 | modelSelectionByProvider: nextMap, |
| 1401 | }; | 1773 | }; |
| 1402 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; | 1774 | const nextDraftsByThreadId = { ...state.draftsByThreadId }; |
| 1403 | if (shouldRemoveDraft(nextDraft)) { | 1775 | if (shouldRemoveDraft(nextDraft)) { |
| ... | @@ -1409,7 +1781,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( | ... | @@ -1409,7 +1781,10 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1409 | return { | 1781 | return { |
| 1410 | draftsByThreadId: nextDraftsByThreadId, | 1782 | draftsByThreadId: nextDraftsByThreadId, |
| 1411 | ...(options?.persistSticky === true | 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,8 +2143,8 @@ export const useComposerDraftStore = create<ComposerDraftStoreState>()( |
| 1768 | draftsByThreadId, | 2143 | draftsByThreadId, |
| 1769 | draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, | 2144 | draftThreadsByThreadId: normalizedPersisted.draftThreadsByThreadId, |
| 1770 | projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, | 2145 | projectDraftThreadIdByProjectId: normalizedPersisted.projectDraftThreadIdByProjectId, |
| 1771 | stickyModel: normalizedPersisted.stickyModel, | 2146 | stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, |
| 1772 | stickyModelOptions: normalizedPersisted.stickyModelOptions, | 2147 | stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, |
| 1773 | }; | 2148 | }; |
| 1774 | }, | 2149 | }, |
| 1775 | }, | 2150 | }, |
| ... | @@ -1780,6 +2155,34 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS | ... | @@ -1780,6 +2155,34 @@ export function useComposerThreadDraft(threadId: ThreadId): ComposerThreadDraftS |
| 1780 | return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); | 2155 | return useComposerDraftStore((state) => state.draftsByThreadId[threadId] ?? EMPTY_THREAD_DRAFT); |
| 1781 | } | 2156 | } |
| 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 | * Clear draft threads that have been promoted to server threads. | 2187 | * Clear draft threads that have been promoted to server threads. |
| 1785 | * | 2188 | * |
apps/web/src/hooks/useHandleNewThread.ts+3-14| ... | @@ -1,7 +1,6 @@ | ... | @@ -1,7 +1,6 @@ |
| 1 | import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; | 1 | import { DEFAULT_RUNTIME_MODE, type ProjectId, ThreadId } from "@t3tools/contracts"; |
| 2 | import { useNavigate, useParams } from "@tanstack/react-router"; | 2 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | import { useCallback } from "react"; | 3 | import { useCallback } from "react"; |
| 4 | import { inferProviderForModel } from "@t3tools/shared/model"; | ||
| 5 | import { | 4 | import { |
| 6 | type DraftThreadEnvMode, | 5 | type DraftThreadEnvMode, |
| 7 | type DraftThreadState, | 6 | type DraftThreadState, |
| ... | @@ -13,8 +12,6 @@ import { useStore } from "../store"; | ... | @@ -13,8 +12,6 @@ import { useStore } from "../store"; |
| 13 | export function useHandleNewThread() { | 12 | export function useHandleNewThread() { |
| 14 | const projects = useStore((store) => store.projects); | 13 | const projects = useStore((store) => store.projects); |
| 15 | const threads = useStore((store) => store.threads); | 14 | const threads = useStore((store) => store.threads); |
| 16 | const stickyModel = useComposerDraftStore((store) => store.stickyModel); | ||
| 17 | const stickyModelOptions = useComposerDraftStore((store) => store.stickyModelOptions); | ||
| 18 | const navigate = useNavigate(); | 15 | const navigate = useNavigate(); |
| 19 | const routeThreadId = useParams({ | 16 | const routeThreadId = useParams({ |
| 20 | strict: false, | 17 | strict: false, |
| ... | @@ -41,9 +38,7 @@ export function useHandleNewThread() { | ... | @@ -41,9 +38,7 @@ export function useHandleNewThread() { |
| 41 | clearProjectDraftThreadId, | 38 | clearProjectDraftThreadId, |
| 42 | getDraftThread, | 39 | getDraftThread, |
| 43 | getDraftThreadByProjectId, | 40 | getDraftThreadByProjectId, |
| 44 | setModel, | 41 | applyStickyState, |
| 45 | setModelOptions, | ||
| 46 | setProvider, | ||
| 47 | setDraftThreadContext, | 42 | setDraftThreadContext, |
| 48 | setProjectDraftThreadId, | 43 | setProjectDraftThreadId, |
| 49 | } = useComposerDraftStore.getState(); | 44 | } = useComposerDraftStore.getState(); |
| ... | @@ -102,13 +97,7 @@ export function useHandleNewThread() { | ... | @@ -102,13 +97,7 @@ export function useHandleNewThread() { |
| 102 | envMode: options?.envMode ?? "local", | 97 | envMode: options?.envMode ?? "local", |
| 103 | runtimeMode: DEFAULT_RUNTIME_MODE, | 98 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 104 | }); | 99 | }); |
| 105 | if (stickyModel) { | 100 | applyStickyState(threadId); |
| 106 | setProvider(threadId, inferProviderForModel(stickyModel)); | ||
| 107 | setModel(threadId, stickyModel); | ||
| 108 | } | ||
| 109 | if (Object.keys(stickyModelOptions).length > 0) { | ||
| 110 | setModelOptions(threadId, stickyModelOptions); | ||
| 111 | } | ||
| 112 | 101 | ||
| 113 | await navigate({ | 102 | await navigate({ |
| 114 | to: "/$threadId", | 103 | to: "/$threadId", |
| ... | @@ -116,7 +105,7 @@ export function useHandleNewThread() { | ... | @@ -116,7 +105,7 @@ export function useHandleNewThread() { |
| 116 | }); | 105 | }); |
| 117 | })(); | 106 | })(); |
| 118 | }, | 107 | }, |
| 119 | [navigate, routeThreadId, stickyModel, stickyModelOptions], | 108 | [navigate, routeThreadId], |
| 120 | ); | 109 | ); |
| 121 | 110 | ||
| 122 | return { | 111 | return { |
apps/web/src/store.test.ts+50-14| ... | @@ -16,7 +16,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -16,7 +16,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 16 | codexThreadId: null, | 16 | codexThreadId: null, |
| 17 | projectId: ProjectId.makeUnsafe("project-1"), | 17 | projectId: ProjectId.makeUnsafe("project-1"), |
| 18 | title: "Thread", | 18 | title: "Thread", |
| 19 | model: "gpt-5-codex", | 19 | modelSelection: { |
| 20 | provider: "codex", | ||
| 21 | model: "gpt-5-codex", | ||
| 22 | }, | ||
| 20 | runtimeMode: DEFAULT_RUNTIME_MODE, | 23 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 21 | interactionMode: DEFAULT_INTERACTION_MODE, | 24 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 22 | session: null, | 25 | session: null, |
| ... | @@ -40,7 +43,10 @@ function makeState(thread: Thread): AppState { | ... | @@ -40,7 +43,10 @@ function makeState(thread: Thread): AppState { |
| 40 | id: ProjectId.makeUnsafe("project-1"), | 43 | id: ProjectId.makeUnsafe("project-1"), |
| 41 | name: "Project", | 44 | name: "Project", |
| 42 | cwd: "/tmp/project", | 45 | cwd: "/tmp/project", |
| 43 | model: "gpt-5-codex", | 46 | defaultModelSelection: { |
| 47 | provider: "codex", | ||
| 48 | model: "gpt-5-codex", | ||
| 49 | }, | ||
| 44 | expanded: true, | 50 | expanded: true, |
| 45 | scripts: [], | 51 | scripts: [], |
| 46 | }, | 52 | }, |
| ... | @@ -55,7 +61,10 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads" | ... | @@ -55,7 +61,10 @@ function makeReadModelThread(overrides: Partial<OrchestrationReadModel["threads" |
| 55 | id: ThreadId.makeUnsafe("thread-1"), | 61 | id: ThreadId.makeUnsafe("thread-1"), |
| 56 | projectId: ProjectId.makeUnsafe("project-1"), | 62 | projectId: ProjectId.makeUnsafe("project-1"), |
| 57 | title: "Thread", | 63 | title: "Thread", |
| 58 | model: "gpt-5.3-codex", | 64 | modelSelection: { |
| 65 | provider: "codex", | ||
| 66 | model: "gpt-5.3-codex", | ||
| 67 | }, | ||
| 59 | runtimeMode: DEFAULT_RUNTIME_MODE, | 68 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 60 | interactionMode: DEFAULT_INTERACTION_MODE, | 69 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 61 | branch: null, | 70 | branch: null, |
| ... | @@ -82,7 +91,10 @@ function makeReadModel(thread: OrchestrationReadModel["threads"][number]): Orche | ... | @@ -82,7 +91,10 @@ function makeReadModel(thread: OrchestrationReadModel["threads"][number]): Orche |
| 82 | id: ProjectId.makeUnsafe("project-1"), | 91 | id: ProjectId.makeUnsafe("project-1"), |
| 83 | title: "Project", | 92 | title: "Project", |
| 84 | workspaceRoot: "/tmp/project", | 93 | workspaceRoot: "/tmp/project", |
| 85 | defaultModel: "gpt-5.3-codex", | 94 | defaultModelSelection: { |
| 95 | provider: "codex", | ||
| 96 | model: "gpt-5.3-codex", | ||
| 97 | }, | ||
| 86 | createdAt: "2026-02-27T00:00:00.000Z", | 98 | createdAt: "2026-02-27T00:00:00.000Z", |
| 87 | updatedAt: "2026-02-27T00:00:00.000Z", | 99 | updatedAt: "2026-02-27T00:00:00.000Z", |
| 88 | deletedAt: null, | 100 | deletedAt: null, |
| ... | @@ -100,7 +112,10 @@ function makeReadModelProject( | ... | @@ -100,7 +112,10 @@ function makeReadModelProject( |
| 100 | id: ProjectId.makeUnsafe("project-1"), | 112 | id: ProjectId.makeUnsafe("project-1"), |
| 101 | title: "Project", | 113 | title: "Project", |
| 102 | workspaceRoot: "/tmp/project", | 114 | workspaceRoot: "/tmp/project", |
| 103 | defaultModel: "gpt-5.3-codex", | 115 | defaultModelSelection: { |
| 116 | provider: "codex", | ||
| 117 | model: "gpt-5.3-codex", | ||
| 118 | }, | ||
| 104 | createdAt: "2026-02-27T00:00:00.000Z", | 119 | createdAt: "2026-02-27T00:00:00.000Z", |
| 105 | updatedAt: "2026-02-27T00:00:00.000Z", | 120 | updatedAt: "2026-02-27T00:00:00.000Z", |
| 106 | deletedAt: null, | 121 | deletedAt: null, |
| ... | @@ -159,7 +174,10 @@ describe("store pure functions", () => { | ... | @@ -159,7 +174,10 @@ describe("store pure functions", () => { |
| 159 | id: project1, | 174 | id: project1, |
| 160 | name: "Project 1", | 175 | name: "Project 1", |
| 161 | cwd: "/tmp/project-1", | 176 | cwd: "/tmp/project-1", |
| 162 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 177 | defaultModelSelection: { |
| 178 | provider: "codex", | ||
| 179 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | ||
| 180 | }, | ||
| 163 | expanded: true, | 181 | expanded: true, |
| 164 | scripts: [], | 182 | scripts: [], |
| 165 | }, | 183 | }, |
| ... | @@ -167,7 +185,10 @@ describe("store pure functions", () => { | ... | @@ -167,7 +185,10 @@ describe("store pure functions", () => { |
| 167 | id: project2, | 185 | id: project2, |
| 168 | name: "Project 2", | 186 | name: "Project 2", |
| 169 | cwd: "/tmp/project-2", | 187 | cwd: "/tmp/project-2", |
| 170 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 188 | defaultModelSelection: { |
| 189 | provider: "codex", | ||
| 190 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | ||
| 191 | }, | ||
| 171 | expanded: true, | 192 | expanded: true, |
| 172 | scripts: [], | 193 | scripts: [], |
| 173 | }, | 194 | }, |
| ... | @@ -175,7 +196,10 @@ describe("store pure functions", () => { | ... | @@ -175,7 +196,10 @@ describe("store pure functions", () => { |
| 175 | id: project3, | 196 | id: project3, |
| 176 | name: "Project 3", | 197 | name: "Project 3", |
| 177 | cwd: "/tmp/project-3", | 198 | cwd: "/tmp/project-3", |
| 178 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 199 | defaultModelSelection: { |
| 200 | provider: "codex", | ||
| 201 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | ||
| 202 | }, | ||
| 179 | expanded: true, | 203 | expanded: true, |
| 180 | scripts: [], | 204 | scripts: [], |
| 181 | }, | 205 | }, |
| ... | @@ -195,20 +219,26 @@ describe("store read model sync", () => { | ... | @@ -195,20 +219,26 @@ describe("store read model sync", () => { |
| 195 | const initialState = makeState(makeThread()); | 219 | const initialState = makeState(makeThread()); |
| 196 | const readModel = makeReadModel( | 220 | const readModel = makeReadModel( |
| 197 | makeReadModelThread({ | 221 | makeReadModelThread({ |
| 198 | model: "claude-opus-4-6", | 222 | modelSelection: { |
| 223 | provider: "claudeAgent", | ||
| 224 | model: "claude-opus-4-6", | ||
| 225 | }, | ||
| 199 | }), | 226 | }), |
| 200 | ); | 227 | ); |
| 201 | 228 | ||
| 202 | const next = syncServerReadModel(initialState, readModel); | 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 | it("resolves claude aliases when session provider is claudeAgent", () => { | 234 | it("resolves claude aliases when session provider is claudeAgent", () => { |
| 208 | const initialState = makeState(makeThread()); | 235 | const initialState = makeState(makeThread()); |
| 209 | const readModel = makeReadModel( | 236 | const readModel = makeReadModel( |
| 210 | makeReadModelThread({ | 237 | makeReadModelThread({ |
| 211 | model: "sonnet", | 238 | modelSelection: { |
| 239 | provider: "claudeAgent", | ||
| 240 | model: "sonnet", | ||
| 241 | }, | ||
| 212 | session: { | 242 | session: { |
| 213 | threadId: ThreadId.makeUnsafe("thread-1"), | 243 | threadId: ThreadId.makeUnsafe("thread-1"), |
| 214 | status: "ready", | 244 | status: "ready", |
| ... | @@ -223,7 +253,7 @@ describe("store read model sync", () => { | ... | @@ -223,7 +253,7 @@ describe("store read model sync", () => { |
| 223 | 253 | ||
| 224 | const next = syncServerReadModel(initialState, readModel); | 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 | it("preserves project and thread updatedAt timestamps from the read model", () => { | 259 | it("preserves project and thread updatedAt timestamps from the read model", () => { |
| ... | @@ -250,7 +280,10 @@ describe("store read model sync", () => { | ... | @@ -250,7 +280,10 @@ describe("store read model sync", () => { |
| 250 | id: project2, | 280 | id: project2, |
| 251 | name: "Project 2", | 281 | name: "Project 2", |
| 252 | cwd: "/tmp/project-2", | 282 | cwd: "/tmp/project-2", |
| 253 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 283 | defaultModelSelection: { |
| 284 | provider: "codex", | ||
| 285 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | ||
| 286 | }, | ||
| 254 | expanded: true, | 287 | expanded: true, |
| 255 | scripts: [], | 288 | scripts: [], |
| 256 | }, | 289 | }, |
| ... | @@ -258,7 +291,10 @@ describe("store read model sync", () => { | ... | @@ -258,7 +291,10 @@ describe("store read model sync", () => { |
| 258 | id: project1, | 291 | id: project1, |
| 259 | name: "Project 1", | 292 | name: "Project 1", |
| 260 | cwd: "/tmp/project-1", | 293 | cwd: "/tmp/project-1", |
| 261 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | 294 | defaultModelSelection: { |
| 295 | provider: "codex", | ||
| 296 | model: DEFAULT_MODEL_BY_PROVIDER.codex, | ||
| 297 | }, | ||
| 262 | expanded: true, | 298 | expanded: true, |
| 263 | scripts: [], | 299 | scripts: [], |
| 264 | }, | 300 | }, |
apps/web/src/store.ts+19-26| ... | @@ -1,16 +1,11 @@ | ... | @@ -1,16 +1,11 @@ |
| 1 | import { Fragment, type ReactNode, createElement, useEffect } from "react"; | 1 | import { Fragment, type ReactNode, createElement, useEffect } from "react"; |
| 2 | import { | 2 | import { |
| 3 | DEFAULT_MODEL_BY_PROVIDER, | ||
| 4 | type ProviderKind, | 3 | type ProviderKind, |
| 5 | ThreadId, | 4 | ThreadId, |
| 6 | type OrchestrationReadModel, | 5 | type OrchestrationReadModel, |
| 7 | type OrchestrationSessionStatus, | 6 | type OrchestrationSessionStatus, |
| 8 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 9 | import { | 8 | import { resolveModelSlugForProvider } from "@t3tools/shared/model"; |
| 10 | inferProviderForModel, | ||
| 11 | resolveModelSlug, | ||
| 12 | resolveModelSlugForProvider, | ||
| 13 | } from "@t3tools/shared/model"; | ||
| 14 | import { create } from "zustand"; | 9 | import { create } from "zustand"; |
| 15 | import { type ChatMessage, type Project, type Thread } from "./types"; | 10 | import { type ChatMessage, type Project, type Thread } from "./types"; |
| 16 | import { Debouncer } from "@tanstack/react-pacer"; | 11 | import { Debouncer } from "@tanstack/react-pacer"; |
| ... | @@ -137,9 +132,17 @@ function mapProjectsFromReadModel( | ... | @@ -137,9 +132,17 @@ function mapProjectsFromReadModel( |
| 137 | id: project.id, | 132 | id: project.id, |
| 138 | name: project.title, | 133 | name: project.title, |
| 139 | cwd: project.workspaceRoot, | 134 | cwd: project.workspaceRoot, |
| 140 | model: | 135 | defaultModelSelection: |
| 141 | existing?.model ?? | 136 | existing?.defaultModelSelection ?? |
| 142 | resolveModelSlug(project.defaultModel ?? DEFAULT_MODEL_BY_PROVIDER.codex), | 137 | (project.defaultModelSelection |
| 138 | ? { | ||
| 139 | ...project.defaultModelSelection, | ||
| 140 | model: resolveModelSlugForProvider( | ||
| 141 | project.defaultModelSelection.provider, | ||
| 142 | project.defaultModelSelection.model, | ||
| 143 | ), | ||
| 144 | } | ||
| 145 | : null), | ||
| 143 | expanded: | 146 | expanded: |
| 144 | existing?.expanded ?? | 147 | existing?.expanded ?? |
| 145 | (persistedExpandedProjectCwds.size > 0 | 148 | (persistedExpandedProjectCwds.size > 0 |
| ... | @@ -196,16 +199,6 @@ function toLegacyProvider(providerName: string | null): ProviderKind { | ... | @@ -196,16 +199,6 @@ function toLegacyProvider(providerName: string | null): ProviderKind { |
| 196 | return "codex"; | 199 | return "codex"; |
| 197 | } | 200 | } |
| 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 | function resolveWsHttpOrigin(): string { | 202 | function resolveWsHttpOrigin(): string { |
| 210 | if (typeof window === "undefined") return ""; | 203 | if (typeof window === "undefined") return ""; |
| 211 | const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); | 204 | const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); |
| ... | @@ -255,13 +248,13 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea | ... | @@ -255,13 +248,13 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea |
| 255 | codexThreadId: null, | 248 | codexThreadId: null, |
| 256 | projectId: thread.projectId, | 249 | projectId: thread.projectId, |
| 257 | title: thread.title, | 250 | title: thread.title, |
| 258 | model: resolveModelSlugForProvider( | 251 | modelSelection: { |
| 259 | inferProviderForThreadModel({ | 252 | ...thread.modelSelection, |
| 260 | model: thread.model, | 253 | model: resolveModelSlugForProvider( |
| 261 | sessionProviderName: thread.session?.providerName ?? null, | 254 | thread.modelSelection.provider, |
| 262 | }), | 255 | thread.modelSelection.model, |
| 263 | thread.model, | 256 | ), |
| 264 | ), | 257 | }, |
| 265 | runtimeMode: thread.runtimeMode, | 258 | runtimeMode: thread.runtimeMode, |
| 266 | interactionMode: thread.interactionMode, | 259 | interactionMode: thread.interactionMode, |
| 267 | session: thread.session | 260 | session: thread.session |
apps/web/src/types.ts+4-3| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import type { | 1 | import type { |
| 2 | ModelSelection, | ||
| 2 | OrchestrationLatestTurn, | 3 | OrchestrationLatestTurn, |
| 3 | OrchestrationProposedPlanId, | 4 | OrchestrationProposedPlanId, |
| 4 | OrchestrationSessionStatus, | 5 | OrchestrationSessionStatus, |
| ... | @@ -8,8 +9,8 @@ import type { | ... | @@ -8,8 +9,8 @@ import type { |
| 8 | ProjectId, | 9 | ProjectId, |
| 9 | TurnId, | 10 | TurnId, |
| 10 | MessageId, | 11 | MessageId, |
| 11 | CheckpointRef, | ||
| 12 | ProviderKind, | 12 | ProviderKind, |
| 13 | CheckpointRef, | ||
| 13 | ProviderInteractionMode, | 14 | ProviderInteractionMode, |
| 14 | RuntimeMode, | 15 | RuntimeMode, |
| 15 | } from "@t3tools/contracts"; | 16 | } from "@t3tools/contracts"; |
| ... | @@ -80,7 +81,7 @@ export interface Project { | ... | @@ -80,7 +81,7 @@ export interface Project { |
| 80 | id: ProjectId; | 81 | id: ProjectId; |
| 81 | name: string; | 82 | name: string; |
| 82 | cwd: string; | 83 | cwd: string; |
| 83 | model: string; | 84 | defaultModelSelection: ModelSelection | null; |
| 84 | expanded: boolean; | 85 | expanded: boolean; |
| 85 | createdAt?: string | undefined; | 86 | createdAt?: string | undefined; |
| 86 | updatedAt?: string | undefined; | 87 | updatedAt?: string | undefined; |
| ... | @@ -92,7 +93,7 @@ export interface Thread { | ... | @@ -92,7 +93,7 @@ export interface Thread { |
| 92 | codexThreadId: string | null; | 93 | codexThreadId: string | null; |
| 93 | projectId: ProjectId; | 94 | projectId: ProjectId; |
| 94 | title: string; | 95 | title: string; |
| 95 | model: string; | 96 | modelSelection: ModelSelection; |
| 96 | runtimeMode: RuntimeMode; | 97 | runtimeMode: RuntimeMode; |
| 97 | interactionMode: ProviderInteractionMode; | 98 | interactionMode: ProviderInteractionMode; |
| 98 | session: ThreadSession | null; | 99 | session: ThreadSession | null; |
apps/web/src/worktreeCleanup.test.ts+4-1| ... | @@ -10,7 +10,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { | ... | @@ -10,7 +10,10 @@ function makeThread(overrides: Partial<Thread> = {}): Thread { |
| 10 | codexThreadId: null, | 10 | codexThreadId: null, |
| 11 | projectId: ProjectId.makeUnsafe("project-1"), | 11 | projectId: ProjectId.makeUnsafe("project-1"), |
| 12 | title: "Thread", | 12 | title: "Thread", |
| 13 | model: "gpt-5.3-codex", | 13 | modelSelection: { |
| 14 | provider: "codex", | ||
| 15 | model: "gpt-5.3-codex", | ||
| 16 | }, | ||
| 14 | runtimeMode: DEFAULT_RUNTIME_MODE, | 17 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 15 | interactionMode: DEFAULT_INTERACTION_MODE, | 18 | interactionMode: DEFAULT_INTERACTION_MODE, |
| 16 | session: null, | 19 | session: null, |
apps/web/src/wsNativeApi.test.ts+5-2| ... | @@ -269,7 +269,7 @@ describe("wsNativeApi", () => { | ... | @@ -269,7 +269,7 @@ describe("wsNativeApi", () => { |
| 269 | projectId: ProjectId.makeUnsafe("project-1"), | 269 | projectId: ProjectId.makeUnsafe("project-1"), |
| 270 | title: "Project", | 270 | title: "Project", |
| 271 | workspaceRoot: "/tmp/workspace", | 271 | workspaceRoot: "/tmp/workspace", |
| 272 | defaultModel: null, | 272 | defaultModelSelection: null, |
| 273 | scripts: [], | 273 | scripts: [], |
| 274 | createdAt: "2026-02-24T00:00:00.000Z", | 274 | createdAt: "2026-02-24T00:00:00.000Z", |
| 275 | updatedAt: "2026-02-24T00:00:00.000Z", | 275 | updatedAt: "2026-02-24T00:00:00.000Z", |
| ... | @@ -311,7 +311,10 @@ describe("wsNativeApi", () => { | ... | @@ -311,7 +311,10 @@ describe("wsNativeApi", () => { |
| 311 | projectId: ProjectId.makeUnsafe("project-1"), | 311 | projectId: ProjectId.makeUnsafe("project-1"), |
| 312 | title: "Project", | 312 | title: "Project", |
| 313 | workspaceRoot: "/tmp/project", | 313 | workspaceRoot: "/tmp/project", |
| 314 | defaultModel: "gpt-5-codex", | 314 | defaultModelSelection: { |
| 315 | provider: "codex", | ||
| 316 | model: "gpt-5-codex", | ||
| 317 | }, | ||
| 315 | createdAt: "2026-02-24T00:00:00.000Z", | 318 | createdAt: "2026-02-24T00:00:00.000Z", |
| 316 | } as const; | 319 | } as const; |
| 317 | await api.orchestration.dispatchCommand(command); | 320 | await api.orchestration.dispatchCommand(command); |
packages/contracts/src/model.ts+165-19| ... | @@ -26,26 +26,166 @@ export const ProviderModelOptions = Schema.Struct({ | ... | @@ -26,26 +26,166 @@ export const ProviderModelOptions = Schema.Struct({ |
| 26 | }); | 26 | }); |
| 27 | export type ProviderModelOptions = typeof ProviderModelOptions.Type; | 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 | readonly slug: string; | 43 | readonly slug: string; |
| 31 | readonly name: string; | 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 | export const MODEL_OPTIONS_BY_PROVIDER = { | 52 | export const MODEL_OPTIONS_BY_PROVIDER = { |
| 35 | codex: [ | 53 | codex: [ |
| 36 | { slug: "gpt-5.4", name: "GPT-5.4" }, | 54 | { |
| 37 | { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini" }, | 55 | slug: "gpt-5.4", |
| 38 | { slug: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, | 56 | name: "GPT-5.4", |
| 39 | { slug: "gpt-5.3-codex-spark", name: "GPT-5.3 Codex Spark" }, | 57 | capabilities: { |
| 40 | { slug: "gpt-5.2-codex", name: "GPT-5.2 Codex" }, | 58 | reasoningEffortLevels: [ |
| 41 | { slug: "gpt-5.2", name: "GPT-5.2" }, | 59 | { value: "xhigh", label: "Extra High" }, |
| 60 | { value: "high", label: "High", isDefault: true }, | ||
| 61 | { value: "medium", label: "Medium" }, | ||
| 62 | { value: "low", label: "Low" }, | ||
| 63 | ], | ||
| 64 | supportsFastMode: true, | ||
| 65 | supportsThinkingToggle: false, | ||
| 66 | promptInjectedEffortLevels: [], | ||
| 67 | }, | ||
| 68 | }, | ||
| 69 | { | ||
| 70 | slug: "gpt-5.4-mini", | ||
| 71 | name: "GPT-5.4 Mini", | ||
| 72 | capabilities: { | ||
| 73 | reasoningEffortLevels: [ | ||
| 74 | { value: "xhigh", label: "Extra High" }, | ||
| 75 | { value: "high", label: "High", isDefault: true }, | ||
| 76 | { value: "medium", label: "Medium" }, | ||
| 77 | { value: "low", label: "Low" }, | ||
| 78 | ], | ||
| 79 | supportsFastMode: true, | ||
| 80 | supportsThinkingToggle: false, | ||
| 81 | promptInjectedEffortLevels: [], | ||
| 82 | }, | ||
| 83 | }, | ||
| 84 | { | ||
| 85 | slug: "gpt-5.3-codex", | ||
| 86 | name: "GPT-5.3 Codex", | ||
| 87 | capabilities: { | ||
| 88 | reasoningEffortLevels: [ | ||
| 89 | { value: "xhigh", label: "Extra High" }, | ||
| 90 | { value: "high", label: "High", isDefault: true }, | ||
| 91 | { value: "medium", label: "Medium" }, | ||
| 92 | { value: "low", label: "Low" }, | ||
| 93 | ], | ||
| 94 | supportsFastMode: true, | ||
| 95 | supportsThinkingToggle: false, | ||
| 96 | promptInjectedEffortLevels: [], | ||
| 97 | }, | ||
| 98 | }, | ||
| 99 | { | ||
| 100 | slug: "gpt-5.3-codex-spark", | ||
| 101 | name: "GPT-5.3 Codex Spark", | ||
| 102 | capabilities: { | ||
| 103 | reasoningEffortLevels: [ | ||
| 104 | { value: "xhigh", label: "Extra High" }, | ||
| 105 | { value: "high", label: "High", isDefault: true }, | ||
| 106 | { value: "medium", label: "Medium" }, | ||
| 107 | { value: "low", label: "Low" }, | ||
| 108 | ], | ||
| 109 | supportsFastMode: true, | ||
| 110 | supportsThinkingToggle: false, | ||
| 111 | promptInjectedEffortLevels: [], | ||
| 112 | }, | ||
| 113 | }, | ||
| 114 | { | ||
| 115 | slug: "gpt-5.2-codex", | ||
| 116 | name: "GPT-5.2 Codex", | ||
| 117 | capabilities: { | ||
| 118 | reasoningEffortLevels: [ | ||
| 119 | { value: "xhigh", label: "Extra High" }, | ||
| 120 | { value: "high", label: "High", isDefault: true }, | ||
| 121 | { value: "medium", label: "Medium" }, | ||
| 122 | { value: "low", label: "Low" }, | ||
| 123 | ], | ||
| 124 | supportsFastMode: true, | ||
| 125 | supportsThinkingToggle: false, | ||
| 126 | promptInjectedEffortLevels: [], | ||
| 127 | }, | ||
| 128 | }, | ||
| 129 | { | ||
| 130 | slug: "gpt-5.2", | ||
| 131 | name: "GPT-5.2", | ||
| 132 | capabilities: { | ||
| 133 | reasoningEffortLevels: [ | ||
| 134 | { value: "xhigh", label: "Extra High" }, | ||
| 135 | { value: "high", label: "High", isDefault: true }, | ||
| 136 | { value: "medium", label: "Medium" }, | ||
| 137 | { value: "low", label: "Low" }, | ||
| 138 | ], | ||
| 139 | supportsFastMode: true, | ||
| 140 | supportsThinkingToggle: false, | ||
| 141 | promptInjectedEffortLevels: [], | ||
| 142 | }, | ||
| 143 | }, | ||
| 42 | ], | 144 | ], |
| 43 | claudeAgent: [ | 145 | claudeAgent: [ |
| 44 | { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, | 146 | { |
| 45 | { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, | 147 | slug: "claude-opus-4-6", |
| 46 | { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, | 148 | name: "Claude Opus 4.6", |
| 149 | capabilities: { | ||
| 150 | reasoningEffortLevels: [ | ||
| 151 | { value: "low", label: "Low" }, | ||
| 152 | { value: "medium", label: "Medium" }, | ||
| 153 | { value: "high", label: "High", isDefault: true }, | ||
| 154 | { value: "max", label: "Max" }, | ||
| 155 | { value: "ultrathink", label: "Ultrathink" }, | ||
| 156 | ], | ||
| 157 | supportsFastMode: true, | ||
| 158 | supportsThinkingToggle: false, | ||
| 159 | promptInjectedEffortLevels: ["ultrathink"], | ||
| 160 | }, | ||
| 161 | }, | ||
| 162 | { | ||
| 163 | slug: "claude-sonnet-4-6", | ||
| 164 | name: "Claude Sonnet 4.6", | ||
| 165 | capabilities: { | ||
| 166 | reasoningEffortLevels: [ | ||
| 167 | { value: "low", label: "Low" }, | ||
| 168 | { value: "medium", label: "Medium" }, | ||
| 169 | { value: "high", label: "High", isDefault: true }, | ||
| 170 | { value: "ultrathink", label: "Ultrathink" }, | ||
| 171 | ], | ||
| 172 | supportsFastMode: false, | ||
| 173 | supportsThinkingToggle: false, | ||
| 174 | promptInjectedEffortLevels: ["ultrathink"], | ||
| 175 | }, | ||
| 176 | }, | ||
| 177 | { | ||
| 178 | slug: "claude-haiku-4-5", | ||
| 179 | name: "Claude Haiku 4.5", | ||
| 180 | capabilities: { | ||
| 181 | reasoningEffortLevels: [], | ||
| 182 | supportsFastMode: false, | ||
| 183 | supportsThinkingToggle: true, | ||
| 184 | promptInjectedEffortLevels: [], | ||
| 185 | }, | ||
| 186 | }, | ||
| 47 | ], | 187 | ], |
| 48 | } as const satisfies Record<ProviderKind, readonly ModelOption[]>; | 188 | } as const satisfies Record<ProviderKind, readonly ModelDefinition[]>; |
| 49 | export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; | 189 | export type ModelOptionsByProvider = typeof MODEL_OPTIONS_BY_PROVIDER; |
| 50 | 190 | ||
| 51 | type BuiltInModelSlug = (typeof MODEL_OPTIONS_BY_PROVIDER)[ProviderKind][number]["slug"]; | 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,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 = { | 228 | // ── Model capabilities index ────────────────────────────────────────── |
| 89 | codex: CODEX_REASONING_EFFORT_OPTIONS, | ||
| 90 | claudeAgent: CLAUDE_CODE_EFFORT_OPTIONS, | ||
| 91 | } as const satisfies Record<ProviderKind, readonly ProviderReasoningEffort[]>; | ||
| 92 | 229 | ||
| 93 | export const DEFAULT_REASONING_EFFORT_BY_PROVIDER = { | 230 | export const MODEL_CAPABILITIES_INDEX = Object.fromEntries( |
| 94 | codex: "high", | 231 | Object.entries(MODEL_OPTIONS_BY_PROVIDER).map(([provider, models]) => [ |
| 95 | claudeAgent: "high", | 232 | provider, |
| 96 | } as const satisfies Record<ProviderKind, ProviderReasoningEffort>; | 233 | Object.fromEntries(models.map((m) => [m.slug, m.capabilities])), |
| 234 | ]), | ||
| 235 | ) as unknown as Record<ProviderKind, Record<string, ModelCapabilities>>; | ||
| 236 | |||
| 237 | // ── Provider display names ──────────────────────────────────────────── | ||
| 238 | |||
| 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,9 +7,12 @@ import { |
| 7 | DEFAULT_RUNTIME_MODE, | 7 | DEFAULT_RUNTIME_MODE, |
| 8 | OrchestrationGetTurnDiffInput, | 8 | OrchestrationGetTurnDiffInput, |
| 9 | OrchestrationLatestTurn, | 9 | OrchestrationLatestTurn, |
| 10 | ProjectCreatedPayload, | ||
| 11 | ProjectMetaUpdatedPayload, | ||
| 10 | OrchestrationProposedPlan, | 12 | OrchestrationProposedPlan, |
| 11 | OrchestrationSession, | 13 | OrchestrationSession, |
| 12 | ProjectCreateCommand, | 14 | ProjectCreateCommand, |
| 15 | ThreadMetaUpdatedPayload, | ||
| 13 | ThreadTurnStartCommand, | 16 | ThreadTurnStartCommand, |
| 14 | ThreadCreatedPayload, | 17 | ThreadCreatedPayload, |
| 15 | ThreadTurnDiff, | 18 | ThreadTurnDiff, |
| ... | @@ -19,6 +22,8 @@ import { | ... | @@ -19,6 +22,8 @@ import { |
| 19 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); | 22 | const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); |
| 20 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); | 23 | const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); |
| 21 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); | 24 | const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); |
| 25 | const decodeProjectCreatedPayload = Schema.decodeUnknownEffect(ProjectCreatedPayload); | ||
| 26 | const decodeProjectMetaUpdatedPayload = Schema.decodeUnknownEffect(ProjectMetaUpdatedPayload); | ||
| 22 | const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); | 27 | const decodeThreadTurnStartCommand = Schema.decodeUnknownEffect(ThreadTurnStartCommand); |
| 23 | const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( | 28 | const decodeThreadTurnStartRequestedPayload = Schema.decodeUnknownEffect( |
| 24 | ThreadTurnStartRequestedPayload, | 29 | ThreadTurnStartRequestedPayload, |
| ... | @@ -27,6 +32,7 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa | ... | @@ -27,6 +32,7 @@ const decodeOrchestrationLatestTurn = Schema.decodeUnknownEffect(OrchestrationLa |
| 27 | const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); | 32 | const decodeOrchestrationProposedPlan = Schema.decodeUnknownEffect(OrchestrationProposedPlan); |
| 28 | const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); | 33 | const decodeOrchestrationSession = Schema.decodeUnknownEffect(OrchestrationSession); |
| 29 | const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); | 34 | const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); |
| 35 | const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); | ||
| 30 | 36 | ||
| 31 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => | 37 | it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => |
| 32 | Effect.gen(function* () { | 38 | Effect.gen(function* () { |
| ... | @@ -75,14 +81,52 @@ it.effect("trims branded ids and command string fields at decode boundaries", () | ... | @@ -75,14 +81,52 @@ it.effect("trims branded ids and command string fields at decode boundaries", () |
| 75 | projectId: " project-1 ", | 81 | projectId: " project-1 ", |
| 76 | title: " Project Title ", | 82 | title: " Project Title ", |
| 77 | workspaceRoot: " /tmp/workspace ", | 83 | workspaceRoot: " /tmp/workspace ", |
| 78 | defaultModel: " gpt-5.2 ", | 84 | defaultModelSelection: { |
| 85 | provider: "codex", | ||
| 86 | model: " gpt-5.2 ", | ||
| 87 | }, | ||
| 79 | createdAt: "2026-01-01T00:00:00.000Z", | 88 | createdAt: "2026-01-01T00:00:00.000Z", |
| 80 | }); | 89 | }); |
| 81 | assert.strictEqual(parsed.commandId, "cmd-1"); | 90 | assert.strictEqual(parsed.commandId, "cmd-1"); |
| 82 | assert.strictEqual(parsed.projectId, "project-1"); | 91 | assert.strictEqual(parsed.projectId, "project-1"); |
| 83 | assert.strictEqual(parsed.title, "Project Title"); | 92 | assert.strictEqual(parsed.title, "Project Title"); |
| 84 | assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); | 93 | assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); |
| 85 | assert.strictEqual(parsed.defaultModel, "gpt-5.2"); | 94 | assert.deepStrictEqual(parsed.defaultModelSelection, { |
| 95 | provider: "codex", | ||
| 96 | model: "gpt-5.2", | ||
| 97 | }); | ||
| 98 | }), | ||
| 99 | ); | ||
| 100 | |||
| 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,7 +160,7 @@ it.effect("decodes thread.turn.start defaults for provider and runtime mode", () |
| 116 | }, | 160 | }, |
| 117 | createdAt: "2026-01-01T00:00:00.000Z", | 161 | createdAt: "2026-01-01T00:00:00.000Z", |
| 118 | }); | 162 | }); |
| 119 | assert.strictEqual(parsed.provider, undefined); | 163 | assert.strictEqual(parsed.modelSelection, undefined); |
| 120 | assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); | 164 | assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); |
| 121 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); | 165 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); |
| 122 | }), | 166 | }), |
| ... | @@ -134,11 +178,14 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( | ... | @@ -134,11 +178,14 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( |
| 134 | text: "hello", | 178 | text: "hello", |
| 135 | attachments: [], | 179 | attachments: [], |
| 136 | }, | 180 | }, |
| 137 | provider: "codex", | 181 | modelSelection: { |
| 182 | provider: "codex", | ||
| 183 | model: "gpt-5.4", | ||
| 184 | }, | ||
| 138 | runtimeMode: "full-access", | 185 | runtimeMode: "full-access", |
| 139 | createdAt: "2026-01-01T00:00:00.000Z", | 186 | createdAt: "2026-01-01T00:00:00.000Z", |
| 140 | }); | 187 | }); |
| 141 | assert.strictEqual(parsed.provider, "codex"); | 188 | assert.strictEqual(parsed.modelSelection?.provider, "codex"); |
| 142 | assert.strictEqual(parsed.runtimeMode, "full-access"); | 189 | assert.strictEqual(parsed.runtimeMode, "full-access"); |
| 143 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); | 190 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); |
| 144 | }), | 191 | }), |
| ... | @@ -150,7 +197,10 @@ it.effect("decodes thread.created runtime mode for historical events", () => | ... | @@ -150,7 +197,10 @@ it.effect("decodes thread.created runtime mode for historical events", () => |
| 150 | threadId: "thread-1", | 197 | threadId: "thread-1", |
| 151 | projectId: "project-1", | 198 | projectId: "project-1", |
| 152 | title: "Thread title", | 199 | title: "Thread title", |
| 153 | model: "gpt-5.4", | 200 | modelSelection: { |
| 201 | provider: "codex", | ||
| 202 | model: "gpt-5.4", | ||
| 203 | }, | ||
| 154 | interactionMode: "default", | 204 | interactionMode: "default", |
| 155 | branch: null, | 205 | branch: null, |
| 156 | worktreePath: null, | 206 | worktreePath: null, |
| ... | @@ -159,6 +209,21 @@ it.effect("decodes thread.created runtime mode for historical events", () => | ... | @@ -159,6 +209,21 @@ it.effect("decodes thread.created runtime mode for historical events", () => |
| 159 | }); | 209 | }); |
| 160 | 210 | ||
| 161 | assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); | 211 | assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); |
| 212 | assert.strictEqual(parsed.modelSelection.provider, "codex"); | ||
| 213 | }), | ||
| 214 | ); | ||
| 215 | |||
| 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,19 +239,19 @@ it.effect("accepts provider-scoped model options in thread.turn.start", () => |
| 174 | text: "hello", | 239 | text: "hello", |
| 175 | attachments: [], | 240 | attachments: [], |
| 176 | }, | 241 | }, |
| 177 | provider: "codex", | 242 | modelSelection: { |
| 178 | model: "gpt-5.3-codex", | 243 | provider: "codex", |
| 179 | modelOptions: { | 244 | model: "gpt-5.3-codex", |
| 180 | codex: { | 245 | options: { |
| 181 | reasoningEffort: "high", | 246 | reasoningEffort: "high", |
| 182 | fastMode: true, | 247 | fastMode: true, |
| 183 | }, | 248 | }, |
| 184 | }, | 249 | }, |
| 185 | createdAt: "2026-01-01T00:00:00.000Z", | 250 | createdAt: "2026-01-01T00:00:00.000Z", |
| 186 | }); | 251 | }); |
| 187 | assert.strictEqual(parsed.provider, "codex"); | 252 | assert.strictEqual(parsed.modelSelection?.provider, "codex"); |
| 188 | assert.strictEqual(parsed.modelOptions?.codex?.reasoningEffort, "high"); | 253 | assert.strictEqual(parsed.modelSelection?.options?.reasoningEffort, "high"); |
| 189 | assert.strictEqual(parsed.modelOptions?.codex?.fastMode, true); | 254 | assert.strictEqual(parsed.modelSelection?.options?.fastMode, true); |
| 190 | }), | 255 | }), |
| 191 | ); | 256 | ); |
| 192 | 257 | ||
| ... | @@ -224,7 +289,7 @@ it.effect( | ... | @@ -224,7 +289,7 @@ it.effect( |
| 224 | messageId: "msg-1", | 289 | messageId: "msg-1", |
| 225 | createdAt: "2026-01-01T00:00:00.000Z", | 290 | createdAt: "2026-01-01T00:00:00.000Z", |
| 226 | }); | 291 | }); |
| 227 | assert.strictEqual(parsed.provider, undefined); | 292 | assert.strictEqual(parsed.modelSelection, undefined); |
| 228 | assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); | 293 | assert.strictEqual(parsed.runtimeMode, DEFAULT_RUNTIME_MODE); |
| 229 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); | 294 | assert.strictEqual(parsed.interactionMode, DEFAULT_PROVIDER_INTERACTION_MODE); |
| 230 | assert.strictEqual(parsed.sourceProposedPlan, undefined); | 295 | assert.strictEqual(parsed.sourceProposedPlan, undefined); |
packages/contracts/src/orchestration.ts+31-20| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { Option, Schema, SchemaIssue, Struct } from "effect"; | 1 | import { Option, Schema, SchemaIssue, Struct } from "effect"; |
| 2 | import { ProviderModelOptions } from "./model"; | 2 | import { ClaudeModelOptions, CodexModelOptions } from "./model"; |
| 3 | import { | 3 | import { |
| 4 | ApprovalRequestId, | 4 | ApprovalRequestId, |
| 5 | CheckpointRef, | 5 | CheckpointRef, |
| ... | @@ -44,6 +44,23 @@ export const ProviderSandboxMode = Schema.Literals([ | ... | @@ -44,6 +44,23 @@ export const ProviderSandboxMode = Schema.Literals([ |
| 44 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; | 44 | export type ProviderSandboxMode = typeof ProviderSandboxMode.Type; |
| 45 | export const DEFAULT_PROVIDER_KIND: ProviderKind = "codex"; | 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 | export const CodexProviderStartOptions = Schema.Struct({ | 64 | export const CodexProviderStartOptions = Schema.Struct({ |
| 48 | binaryPath: Schema.optional(TrimmedNonEmptyString), | 65 | binaryPath: Schema.optional(TrimmedNonEmptyString), |
| 49 | homePath: Schema.optional(TrimmedNonEmptyString), | 66 | homePath: Schema.optional(TrimmedNonEmptyString), |
| ... | @@ -144,7 +161,7 @@ export const OrchestrationProject = Schema.Struct({ | ... | @@ -144,7 +161,7 @@ export const OrchestrationProject = Schema.Struct({ |
| 144 | id: ProjectId, | 161 | id: ProjectId, |
| 145 | title: TrimmedNonEmptyString, | 162 | title: TrimmedNonEmptyString, |
| 146 | workspaceRoot: TrimmedNonEmptyString, | 163 | workspaceRoot: TrimmedNonEmptyString, |
| 147 | defaultModel: Schema.NullOr(TrimmedNonEmptyString), | 164 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 148 | scripts: Schema.Array(ProjectScript), | 165 | scripts: Schema.Array(ProjectScript), |
| 149 | createdAt: IsoDateTime, | 166 | createdAt: IsoDateTime, |
| 150 | updatedAt: IsoDateTime, | 167 | updatedAt: IsoDateTime, |
| ... | @@ -273,7 +290,7 @@ export const OrchestrationThread = Schema.Struct({ | ... | @@ -273,7 +290,7 @@ export const OrchestrationThread = Schema.Struct({ |
| 273 | id: ThreadId, | 290 | id: ThreadId, |
| 274 | projectId: ProjectId, | 291 | projectId: ProjectId, |
| 275 | title: TrimmedNonEmptyString, | 292 | title: TrimmedNonEmptyString, |
| 276 | model: TrimmedNonEmptyString, | 293 | modelSelection: ModelSelection, |
| 277 | runtimeMode: RuntimeMode, | 294 | runtimeMode: RuntimeMode, |
| 278 | interactionMode: ProviderInteractionMode.pipe( | 295 | interactionMode: ProviderInteractionMode.pipe( |
| 279 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), | 296 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), |
| ... | @@ -306,7 +323,7 @@ export const ProjectCreateCommand = Schema.Struct({ | ... | @@ -306,7 +323,7 @@ export const ProjectCreateCommand = Schema.Struct({ |
| 306 | projectId: ProjectId, | 323 | projectId: ProjectId, |
| 307 | title: TrimmedNonEmptyString, | 324 | title: TrimmedNonEmptyString, |
| 308 | workspaceRoot: TrimmedNonEmptyString, | 325 | workspaceRoot: TrimmedNonEmptyString, |
| 309 | defaultModel: Schema.optional(TrimmedNonEmptyString), | 326 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 310 | createdAt: IsoDateTime, | 327 | createdAt: IsoDateTime, |
| 311 | }); | 328 | }); |
| 312 | 329 | ||
| ... | @@ -316,7 +333,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ | ... | @@ -316,7 +333,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ |
| 316 | projectId: ProjectId, | 333 | projectId: ProjectId, |
| 317 | title: Schema.optional(TrimmedNonEmptyString), | 334 | title: Schema.optional(TrimmedNonEmptyString), |
| 318 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), | 335 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), |
| 319 | defaultModel: Schema.optional(TrimmedNonEmptyString), | 336 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 320 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 337 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 321 | }); | 338 | }); |
| 322 | 339 | ||
| ... | @@ -332,7 +349,7 @@ const ThreadCreateCommand = Schema.Struct({ | ... | @@ -332,7 +349,7 @@ const ThreadCreateCommand = Schema.Struct({ |
| 332 | threadId: ThreadId, | 349 | threadId: ThreadId, |
| 333 | projectId: ProjectId, | 350 | projectId: ProjectId, |
| 334 | title: TrimmedNonEmptyString, | 351 | title: TrimmedNonEmptyString, |
| 335 | model: TrimmedNonEmptyString, | 352 | modelSelection: ModelSelection, |
| 336 | runtimeMode: RuntimeMode, | 353 | runtimeMode: RuntimeMode, |
| 337 | interactionMode: ProviderInteractionMode.pipe( | 354 | interactionMode: ProviderInteractionMode.pipe( |
| 338 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), | 355 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), |
| ... | @@ -353,7 +370,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ | ... | @@ -353,7 +370,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ |
| 353 | commandId: CommandId, | 370 | commandId: CommandId, |
| 354 | threadId: ThreadId, | 371 | threadId: ThreadId, |
| 355 | title: Schema.optional(TrimmedNonEmptyString), | 372 | title: Schema.optional(TrimmedNonEmptyString), |
| 356 | model: Schema.optional(TrimmedNonEmptyString), | 373 | modelSelection: Schema.optional(ModelSelection), |
| 357 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 374 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 358 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 375 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 359 | }); | 376 | }); |
| ... | @@ -384,9 +401,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ | ... | @@ -384,9 +401,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ |
| 384 | text: Schema.String, | 401 | text: Schema.String, |
| 385 | attachments: Schema.Array(ChatAttachment), | 402 | attachments: Schema.Array(ChatAttachment), |
| 386 | }), | 403 | }), |
| 387 | provider: Schema.optional(ProviderKind), | 404 | modelSelection: Schema.optional(ModelSelection), |
| 388 | model: Schema.optional(TrimmedNonEmptyString), | ||
| 389 | modelOptions: Schema.optional(ProviderModelOptions), | ||
| 390 | providerOptions: Schema.optional(ProviderStartOptions), | 405 | providerOptions: Schema.optional(ProviderStartOptions), |
| 391 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | 406 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 392 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), | 407 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), |
| ... | @@ -407,9 +422,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ | ... | @@ -407,9 +422,7 @@ const ClientThreadTurnStartCommand = Schema.Struct({ |
| 407 | text: Schema.String, | 422 | text: Schema.String, |
| 408 | attachments: Schema.Array(UploadChatAttachment), | 423 | attachments: Schema.Array(UploadChatAttachment), |
| 409 | }), | 424 | }), |
| 410 | provider: Schema.optional(ProviderKind), | 425 | modelSelection: Schema.optional(ModelSelection), |
| 411 | model: Schema.optional(TrimmedNonEmptyString), | ||
| 412 | modelOptions: Schema.optional(ProviderModelOptions), | ||
| 413 | providerOptions: Schema.optional(ProviderStartOptions), | 426 | providerOptions: Schema.optional(ProviderStartOptions), |
| 414 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | 427 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 415 | runtimeMode: RuntimeMode, | 428 | runtimeMode: RuntimeMode, |
| ... | @@ -610,7 +623,7 @@ export const ProjectCreatedPayload = Schema.Struct({ | ... | @@ -610,7 +623,7 @@ export const ProjectCreatedPayload = Schema.Struct({ |
| 610 | projectId: ProjectId, | 623 | projectId: ProjectId, |
| 611 | title: TrimmedNonEmptyString, | 624 | title: TrimmedNonEmptyString, |
| 612 | workspaceRoot: TrimmedNonEmptyString, | 625 | workspaceRoot: TrimmedNonEmptyString, |
| 613 | defaultModel: Schema.NullOr(TrimmedNonEmptyString), | 626 | defaultModelSelection: Schema.NullOr(ModelSelection), |
| 614 | scripts: Schema.Array(ProjectScript), | 627 | scripts: Schema.Array(ProjectScript), |
| 615 | createdAt: IsoDateTime, | 628 | createdAt: IsoDateTime, |
| 616 | updatedAt: IsoDateTime, | 629 | updatedAt: IsoDateTime, |
| ... | @@ -620,7 +633,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ | ... | @@ -620,7 +633,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ |
| 620 | projectId: ProjectId, | 633 | projectId: ProjectId, |
| 621 | title: Schema.optional(TrimmedNonEmptyString), | 634 | title: Schema.optional(TrimmedNonEmptyString), |
| 622 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), | 635 | workspaceRoot: Schema.optional(TrimmedNonEmptyString), |
| 623 | defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 636 | defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), |
| 624 | scripts: Schema.optional(Schema.Array(ProjectScript)), | 637 | scripts: Schema.optional(Schema.Array(ProjectScript)), |
| 625 | updatedAt: IsoDateTime, | 638 | updatedAt: IsoDateTime, |
| 626 | }); | 639 | }); |
| ... | @@ -634,7 +647,7 @@ export const ThreadCreatedPayload = Schema.Struct({ | ... | @@ -634,7 +647,7 @@ export const ThreadCreatedPayload = Schema.Struct({ |
| 634 | threadId: ThreadId, | 647 | threadId: ThreadId, |
| 635 | projectId: ProjectId, | 648 | projectId: ProjectId, |
| 636 | title: TrimmedNonEmptyString, | 649 | title: TrimmedNonEmptyString, |
| 637 | model: TrimmedNonEmptyString, | 650 | modelSelection: ModelSelection, |
| 638 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), | 651 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), |
| 639 | interactionMode: ProviderInteractionMode.pipe( | 652 | interactionMode: ProviderInteractionMode.pipe( |
| 640 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), | 653 | Schema.withDecodingDefault(() => DEFAULT_PROVIDER_INTERACTION_MODE), |
| ... | @@ -653,7 +666,7 @@ export const ThreadDeletedPayload = Schema.Struct({ | ... | @@ -653,7 +666,7 @@ export const ThreadDeletedPayload = Schema.Struct({ |
| 653 | export const ThreadMetaUpdatedPayload = Schema.Struct({ | 666 | export const ThreadMetaUpdatedPayload = Schema.Struct({ |
| 654 | threadId: ThreadId, | 667 | threadId: ThreadId, |
| 655 | title: Schema.optional(TrimmedNonEmptyString), | 668 | title: Schema.optional(TrimmedNonEmptyString), |
| 656 | model: Schema.optional(TrimmedNonEmptyString), | 669 | modelSelection: Schema.optional(ModelSelection), |
| 657 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 670 | branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 658 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 671 | worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 659 | updatedAt: IsoDateTime, | 672 | updatedAt: IsoDateTime, |
| ... | @@ -688,9 +701,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ | ... | @@ -688,9 +701,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ |
| 688 | export const ThreadTurnStartRequestedPayload = Schema.Struct({ | 701 | export const ThreadTurnStartRequestedPayload = Schema.Struct({ |
| 689 | threadId: ThreadId, | 702 | threadId: ThreadId, |
| 690 | messageId: MessageId, | 703 | messageId: MessageId, |
| 691 | provider: Schema.optional(ProviderKind), | 704 | modelSelection: Schema.optional(ModelSelection), |
| 692 | model: Schema.optional(TrimmedNonEmptyString), | ||
| 693 | modelOptions: Schema.optional(ProviderModelOptions), | ||
| 694 | providerOptions: Schema.optional(ProviderStartOptions), | 705 | providerOptions: Schema.optional(ProviderStartOptions), |
| 695 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | 706 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), |
| 696 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), | 707 | runtimeMode: RuntimeMode.pipe(Schema.withDecodingDefault(() => DEFAULT_RUNTIME_MODE)), |
packages/contracts/src/provider.test.ts+46-24| ... | @@ -12,9 +12,10 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -12,9 +12,10 @@ describe("ProviderSessionStartInput", () => { |
| 12 | threadId: "thread-1", | 12 | threadId: "thread-1", |
| 13 | provider: "codex", | 13 | provider: "codex", |
| 14 | cwd: "/tmp/workspace", | 14 | cwd: "/tmp/workspace", |
| 15 | model: "gpt-5.3-codex", | 15 | modelSelection: { |
| 16 | modelOptions: { | 16 | provider: "codex", |
| 17 | codex: { | 17 | model: "gpt-5.3-codex", |
| 18 | options: { | ||
| 18 | reasoningEffort: "high", | 19 | reasoningEffort: "high", |
| 19 | fastMode: true, | 20 | fastMode: true, |
| 20 | }, | 21 | }, |
| ... | @@ -28,8 +29,13 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -28,8 +29,13 @@ describe("ProviderSessionStartInput", () => { |
| 28 | }, | 29 | }, |
| 29 | }); | 30 | }); |
| 30 | expect(parsed.runtimeMode).toBe("full-access"); | 31 | expect(parsed.runtimeMode).toBe("full-access"); |
| 31 | expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("high"); | 32 | expect(parsed.modelSelection?.provider).toBe("codex"); |
| 32 | expect(parsed.modelOptions?.codex?.fastMode).toBe(true); | 33 | expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); |
| 34 | if (parsed.modelSelection?.provider !== "codex") { | ||
| 35 | throw new Error("Expected codex modelSelection"); | ||
| 36 | } | ||
| 37 | expect(parsed.modelSelection.options?.reasoningEffort).toBe("high"); | ||
| 38 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | ||
| 33 | expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex"); | 39 | expect(parsed.providerOptions?.codex?.binaryPath).toBe("/usr/local/bin/codex"); |
| 34 | expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); | 40 | expect(parsed.providerOptions?.codex?.homePath).toBe("/tmp/.codex"); |
| 35 | }); | 41 | }); |
| ... | @@ -48,9 +54,10 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -48,9 +54,10 @@ describe("ProviderSessionStartInput", () => { |
| 48 | threadId: "thread-1", | 54 | threadId: "thread-1", |
| 49 | provider: "claudeAgent", | 55 | provider: "claudeAgent", |
| 50 | cwd: "/tmp/workspace", | 56 | cwd: "/tmp/workspace", |
| 51 | model: "claude-sonnet-4-6", | 57 | modelSelection: { |
| 52 | modelOptions: { | 58 | provider: "claudeAgent", |
| 53 | claudeAgent: { | 59 | model: "claude-sonnet-4-6", |
| 60 | options: { | ||
| 54 | thinking: true, | 61 | thinking: true, |
| 55 | effort: "max", | 62 | effort: "max", |
| 56 | fastMode: true, | 63 | fastMode: true, |
| ... | @@ -66,9 +73,14 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -66,9 +73,14 @@ describe("ProviderSessionStartInput", () => { |
| 66 | runtimeMode: "full-access", | 73 | runtimeMode: "full-access", |
| 67 | }); | 74 | }); |
| 68 | expect(parsed.provider).toBe("claudeAgent"); | 75 | expect(parsed.provider).toBe("claudeAgent"); |
| 69 | expect(parsed.modelOptions?.claudeAgent?.thinking).toBe(true); | 76 | expect(parsed.modelSelection?.provider).toBe("claudeAgent"); |
| 70 | expect(parsed.modelOptions?.claudeAgent?.effort).toBe("max"); | 77 | expect(parsed.modelSelection?.model).toBe("claude-sonnet-4-6"); |
| 71 | expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true); | 78 | if (parsed.modelSelection?.provider !== "claudeAgent") { |
| 79 | throw new Error("Expected claude modelSelection"); | ||
| 80 | } | ||
| 81 | expect(parsed.modelSelection.options?.thinking).toBe(true); | ||
| 82 | expect(parsed.modelSelection.options?.effort).toBe("max"); | ||
| 83 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | ||
| 72 | expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude"); | 84 | expect(parsed.providerOptions?.claudeAgent?.binaryPath).toBe("/usr/local/bin/claude"); |
| 73 | expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan"); | 85 | expect(parsed.providerOptions?.claudeAgent?.permissionMode).toBe("plan"); |
| 74 | expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000); | 86 | expect(parsed.providerOptions?.claudeAgent?.maxThinkingTokens).toBe(12_000); |
| ... | @@ -77,36 +89,46 @@ describe("ProviderSessionStartInput", () => { | ... | @@ -77,36 +89,46 @@ describe("ProviderSessionStartInput", () => { |
| 77 | }); | 89 | }); |
| 78 | 90 | ||
| 79 | describe("ProviderSendTurnInput", () => { | 91 | describe("ProviderSendTurnInput", () => { |
| 80 | it("accepts provider-scoped model options", () => { | 92 | it("accepts codex modelSelection", () => { |
| 81 | const parsed = decodeProviderSendTurnInput({ | 93 | const parsed = decodeProviderSendTurnInput({ |
| 82 | threadId: "thread-1", | 94 | threadId: "thread-1", |
| 83 | model: "gpt-5.3-codex", | 95 | modelSelection: { |
| 84 | modelOptions: { | 96 | provider: "codex", |
| 85 | codex: { | 97 | model: "gpt-5.3-codex", |
| 98 | options: { | ||
| 86 | reasoningEffort: "xhigh", | 99 | reasoningEffort: "xhigh", |
| 87 | fastMode: true, | 100 | fastMode: true, |
| 88 | }, | 101 | }, |
| 89 | }, | 102 | }, |
| 90 | }); | 103 | }); |
| 91 | 104 | ||
| 92 | expect(parsed.model).toBe("gpt-5.3-codex"); | 105 | expect(parsed.modelSelection?.provider).toBe("codex"); |
| 93 | expect(parsed.modelOptions?.codex?.reasoningEffort).toBe("xhigh"); | 106 | expect(parsed.modelSelection?.model).toBe("gpt-5.3-codex"); |
| 94 | expect(parsed.modelOptions?.codex?.fastMode).toBe(true); | 107 | if (parsed.modelSelection?.provider !== "codex") { |
| 108 | throw new Error("Expected codex modelSelection"); | ||
| 109 | } | ||
| 110 | expect(parsed.modelSelection.options?.reasoningEffort).toBe("xhigh"); | ||
| 111 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | ||
| 95 | }); | 112 | }); |
| 96 | 113 | ||
| 97 | it("accepts claude provider effort options including ultrathink", () => { | 114 | it("accepts claude modelSelection including ultrathink", () => { |
| 98 | const parsed = decodeProviderSendTurnInput({ | 115 | const parsed = decodeProviderSendTurnInput({ |
| 99 | threadId: "thread-1", | 116 | threadId: "thread-1", |
| 100 | model: "claude-sonnet-4-6", | 117 | modelSelection: { |
| 101 | modelOptions: { | 118 | provider: "claudeAgent", |
| 102 | claudeAgent: { | 119 | model: "claude-sonnet-4-6", |
| 120 | options: { | ||
| 103 | effort: "ultrathink", | 121 | effort: "ultrathink", |
| 104 | fastMode: true, | 122 | fastMode: true, |
| 105 | }, | 123 | }, |
| 106 | }, | 124 | }, |
| 107 | }); | 125 | }); |
| 108 | 126 | ||
| 109 | expect(parsed.modelOptions?.claudeAgent?.effort).toBe("ultrathink"); | 127 | expect(parsed.modelSelection?.provider).toBe("claudeAgent"); |
| 110 | expect(parsed.modelOptions?.claudeAgent?.fastMode).toBe(true); | 128 | if (parsed.modelSelection?.provider !== "claudeAgent") { |
| 129 | throw new Error("Expected claude modelSelection"); | ||
| 130 | } | ||
| 131 | expect(parsed.modelSelection.options?.effort).toBe("ultrathink"); | ||
| 132 | expect(parsed.modelSelection.options?.fastMode).toBe(true); | ||
| 111 | }); | 133 | }); |
| 112 | }); | 134 | }); |
packages/contracts/src/provider.ts+3-5| ... | @@ -1,6 +1,5 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { Schema } from "effect"; | 1 | import { Schema } from "effect"; |
| 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; | 2 | import { TrimmedNonEmptyString } from "./baseSchemas"; |
| 3 | import { ProviderModelOptions } from "./model"; | ||
| 4 | import { | 3 | import { |
| 5 | ApprovalRequestId, | 4 | ApprovalRequestId, |
| 6 | EventId, | 5 | EventId, |
| ... | @@ -11,6 +10,7 @@ import { | ... | @@ -11,6 +10,7 @@ import { |
| 11 | } from "./baseSchemas"; | 10 | } from "./baseSchemas"; |
| 12 | import { | 11 | import { |
| 13 | ChatAttachment, | 12 | ChatAttachment, |
| 13 | ModelSelection, | ||
| 14 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, | 14 | PROVIDER_SEND_TURN_MAX_ATTACHMENTS, |
| 15 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, | 15 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 16 | ProviderApprovalDecision, | 16 | ProviderApprovalDecision, |
| ... | @@ -51,8 +51,7 @@ export const ProviderSessionStartInput = Schema.Struct({ | ... | @@ -51,8 +51,7 @@ export const ProviderSessionStartInput = Schema.Struct({ |
| 51 | threadId: ThreadId, | 51 | threadId: ThreadId, |
| 52 | provider: Schema.optional(ProviderKind), | 52 | provider: Schema.optional(ProviderKind), |
| 53 | cwd: Schema.optional(TrimmedNonEmptyString), | 53 | cwd: Schema.optional(TrimmedNonEmptyString), |
| 54 | model: Schema.optional(TrimmedNonEmptyString), | 54 | modelSelection: Schema.optional(ModelSelection), |
| 55 | modelOptions: Schema.optional(ProviderModelOptions), | ||
| 56 | resumeCursor: Schema.optional(Schema.Unknown), | 55 | resumeCursor: Schema.optional(Schema.Unknown), |
| 57 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), | 56 | approvalPolicy: Schema.optional(ProviderApprovalPolicy), |
| 58 | sandboxMode: Schema.optional(ProviderSandboxMode), | 57 | sandboxMode: Schema.optional(ProviderSandboxMode), |
| ... | @@ -69,8 +68,7 @@ export const ProviderSendTurnInput = Schema.Struct({ | ... | @@ -69,8 +68,7 @@ export const ProviderSendTurnInput = Schema.Struct({ |
| 69 | attachments: Schema.optional( | 68 | attachments: Schema.optional( |
| 70 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), | 69 | Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), |
| 71 | ), | 70 | ), |
| 72 | model: Schema.optional(TrimmedNonEmptyString), | 71 | modelSelection: Schema.optional(ModelSelection), |
| 73 | modelOptions: Schema.optional(ProviderModelOptions), | ||
| 74 | interactionMode: Schema.optional(ProviderInteractionMode), | 72 | interactionMode: Schema.optional(ProviderInteractionMode), |
| 75 | }); | 73 | }); |
| 76 | export type ProviderSendTurnInput = typeof ProviderSendTurnInput.Type; | 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,33 +2,25 @@ import { describe, expect, it } from "vitest"; |
| 2 | import { | 2 | import { |
| 3 | DEFAULT_MODEL, | 3 | DEFAULT_MODEL, |
| 4 | DEFAULT_MODEL_BY_PROVIDER, | 4 | DEFAULT_MODEL_BY_PROVIDER, |
| 5 | DEFAULT_REASONING_EFFORT_BY_PROVIDER, | ||
| 6 | MODEL_OPTIONS, | 5 | MODEL_OPTIONS, |
| 7 | MODEL_OPTIONS_BY_PROVIDER, | 6 | MODEL_OPTIONS_BY_PROVIDER, |
| 8 | REASONING_EFFORT_OPTIONS_BY_PROVIDER, | 7 | CODEX_REASONING_EFFORT_OPTIONS, |
| 9 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 10 | 9 | ||
| 11 | import { | 10 | import { |
| 12 | applyClaudePromptEffortPrefix, | 11 | applyClaudePromptEffortPrefix, |
| 13 | getEffectiveClaudeCodeEffort, | ||
| 14 | getDefaultModel, | 12 | getDefaultModel, |
| 15 | getDefaultReasoningEffort, | 13 | getModelCapabilities, |
| 16 | getModelOptions, | 14 | getModelOptions, |
| 17 | getReasoningEffortOptions, | ||
| 18 | inferProviderForModel, | ||
| 19 | isClaudeUltrathinkPrompt, | 15 | isClaudeUltrathinkPrompt, |
| 20 | normalizeClaudeModelOptions, | 16 | normalizeClaudeModelOptions, |
| 21 | normalizeCodexModelOptions, | 17 | normalizeCodexModelOptions, |
| 22 | normalizeModelSlug, | 18 | normalizeModelSlug, |
| 23 | resolveReasoningEffortForProvider, | ||
| 24 | resolveSelectableModel, | 19 | resolveSelectableModel, |
| 25 | resolveModelSlug, | 20 | resolveModelSlug, |
| 26 | resolveModelSlugForProvider, | 21 | resolveModelSlugForProvider, |
| 27 | supportsClaudeAdaptiveReasoning, | 22 | getDefaultEffort, |
| 28 | supportsClaudeFastMode, | 23 | hasEffortLevel, |
| 29 | supportsClaudeMaxEffort, | ||
| 30 | supportsClaudeThinkingToggle, | ||
| 31 | supportsClaudeUltrathinkKeyword, | ||
| 32 | } from "./model"; | 24 | } from "./model"; |
| 33 | 25 | ||
| 34 | describe("normalizeModelSlug", () => { | 26 | describe("normalizeModelSlug", () => { |
| ... | @@ -159,13 +151,16 @@ describe("resolveSelectableModel", () => { | ... | @@ -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 | it("returns codex reasoning options for codex", () => { | 158 | it("returns codex reasoning options for codex", () => { |
| 164 | expect(getReasoningEffortOptions("codex")).toEqual(REASONING_EFFORT_OPTIONS_BY_PROVIDER.codex); | 159 | expect(values("codex", "gpt-5.4")).toEqual([...CODEX_REASONING_EFFORT_OPTIONS]); |
| 165 | }); | 160 | }); |
| 166 | 161 | ||
| 167 | it("returns claude effort options for Opus 4.6", () => { | 162 | it("returns claude effort options for Opus 4.6", () => { |
| 168 | expect(getReasoningEffortOptions("claudeAgent", "claude-opus-4-6")).toEqual([ | 163 | expect(values("claudeAgent", "claude-opus-4-6")).toEqual([ |
| 169 | "low", | 164 | "low", |
| 170 | "medium", | 165 | "medium", |
| 171 | "high", | 166 | "high", |
| ... | @@ -175,7 +170,7 @@ describe("getReasoningEffortOptions", () => { | ... | @@ -175,7 +170,7 @@ describe("getReasoningEffortOptions", () => { |
| 175 | }); | 170 | }); |
| 176 | 171 | ||
| 177 | it("returns claude effort options for Sonnet 4.6", () => { | 172 | it("returns claude effort options for Sonnet 4.6", () => { |
| 178 | expect(getReasoningEffortOptions("claudeAgent", "claude-sonnet-4-6")).toEqual([ | 173 | expect(values("claudeAgent", "claude-sonnet-4-6")).toEqual([ |
| 179 | "low", | 174 | "low", |
| 180 | "medium", | 175 | "medium", |
| 181 | "high", | 176 | "high", |
| ... | @@ -184,45 +179,37 @@ describe("getReasoningEffortOptions", () => { | ... | @@ -184,45 +179,37 @@ describe("getReasoningEffortOptions", () => { |
| 184 | }); | 179 | }); |
| 185 | 180 | ||
| 186 | it("returns no claude effort options for Haiku 4.5", () => { | 181 | it("returns no claude effort options for Haiku 4.5", () => { |
| 187 | expect(getReasoningEffortOptions("claudeAgent", "claude-haiku-4-5")).toEqual([]); | 182 | expect(values("claudeAgent", "claude-haiku-4-5")).toEqual([]); |
| 188 | }); | ||
| 189 | }); | ||
| 190 | |||
| 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"); | ||
| 201 | }); | 183 | }); |
| 202 | 184 | ||
| 203 | it("treats claude-prefixed custom slugs as claude", () => { | 185 | it("co-locates labels with effort values", () => { |
| 204 | expect(inferProviderForModel("claude-custom-internal")).toBe("claudeAgent"); | 186 | const levels = getModelCapabilities("claudeAgent", "claude-opus-4-6").reasoningEffortLevels; |
| 187 | const high = levels.find((l) => l.value === "high"); | ||
| 188 | expect(high).toEqual({ value: "high", label: "High", isDefault: true }); | ||
| 189 | const xhigh = getModelCapabilities("codex", "gpt-5.4").reasoningEffortLevels.find( | ||
| 190 | (l) => l.value === "xhigh", | ||
| 191 | ); | ||
| 192 | expect(xhigh).toEqual({ value: "xhigh", label: "Extra High" }); | ||
| 205 | }); | 193 | }); |
| 206 | }); | 194 | }); |
| 207 | 195 | ||
| 208 | describe("getDefaultReasoningEffort", () => { | 196 | describe("getDefaultEffort", () => { |
| 209 | it("returns provider-scoped defaults", () => { | 197 | it("returns the default effort from capabilities", () => { |
| 210 | expect(getDefaultReasoningEffort("codex")).toBe(DEFAULT_REASONING_EFFORT_BY_PROVIDER.codex); | 198 | expect(getDefaultEffort(getModelCapabilities("codex", "gpt-5.4"))).toBe("high"); |
| 211 | expect(getDefaultReasoningEffort("claudeAgent")).toBe( | 199 | expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-opus-4-6"))).toBe("high"); |
| 212 | DEFAULT_REASONING_EFFORT_BY_PROVIDER.claudeAgent, | 200 | expect(getDefaultEffort(getModelCapabilities("claudeAgent", "claude-haiku-4-5"))).toBeNull(); |
| 213 | ); | ||
| 214 | }); | 201 | }); |
| 215 | }); | 202 | }); |
| 216 | 203 | ||
| 217 | describe("resolveReasoningEffortForProvider", () => { | 204 | describe("hasEffortLevel", () => { |
| 218 | it("accepts provider-scoped effort values", () => { | 205 | it("validates effort against model capabilities", () => { |
| 219 | expect(resolveReasoningEffortForProvider("codex", "xhigh")).toBe("xhigh"); | 206 | const opusCaps = getModelCapabilities("claudeAgent", "claude-opus-4-6"); |
| 220 | expect(resolveReasoningEffortForProvider("claudeAgent", "ultrathink")).toBe("ultrathink"); | 207 | expect(hasEffortLevel(opusCaps, "max")).toBe(true); |
| 221 | }); | 208 | expect(hasEffortLevel(opusCaps, "xhigh")).toBe(false); |
| 222 | 209 | ||
| 223 | it("rejects effort values from the wrong provider", () => { | 210 | const codexCaps = getModelCapabilities("codex", "gpt-5.4"); |
| 224 | expect(resolveReasoningEffortForProvider("codex", "max")).toBeNull(); | 211 | expect(hasEffortLevel(codexCaps, "xhigh")).toBe(true); |
| 225 | expect(resolveReasoningEffortForProvider("claudeAgent", "xhigh")).toBeNull(); | 212 | expect(hasEffortLevel(codexCaps, "max")).toBe(false); |
| 226 | }); | 213 | }); |
| 227 | }); | 214 | }); |
| 228 | 215 | ||
| ... | @@ -241,27 +228,17 @@ describe("applyClaudePromptEffortPrefix", () => { | ... | @@ -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 | describe("normalizeCodexModelOptions", () => { | 231 | describe("normalizeCodexModelOptions", () => { |
| 257 | it("drops default-only codex options", () => { | 232 | it("drops default-only codex options", () => { |
| 258 | expect( | 233 | expect( |
| 259 | normalizeCodexModelOptions({ reasoningEffort: "high", fastMode: false }), | 234 | normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "high", fastMode: false }), |
| 260 | ).toBeUndefined(); | 235 | ).toBeUndefined(); |
| 261 | }); | 236 | }); |
| 262 | 237 | ||
| 263 | it("preserves non-default codex options", () => { | 238 | it("preserves non-default codex options", () => { |
| 264 | expect(normalizeCodexModelOptions({ reasoningEffort: "xhigh", fastMode: true })).toEqual({ | 239 | expect( |
| 240 | normalizeCodexModelOptions("gpt-5.4", { reasoningEffort: "xhigh", fastMode: true }), | ||
| 241 | ).toEqual({ | ||
| 265 | reasoningEffort: "xhigh", | 242 | reasoningEffort: "xhigh", |
| 266 | fastMode: true, | 243 | fastMode: true, |
| 267 | }); | 244 | }); |
| ... | @@ -290,49 +267,50 @@ describe("normalizeClaudeModelOptions", () => { | ... | @@ -290,49 +267,50 @@ describe("normalizeClaudeModelOptions", () => { |
| 290 | }); | 267 | }); |
| 291 | }); | 268 | }); |
| 292 | 269 | ||
| 293 | describe("supportsClaudeAdaptiveReasoning", () => { | 270 | describe("getModelCapabilities Claude capability flags", () => { |
| 294 | it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { | 271 | it("only enables adaptive reasoning for Opus 4.6 and Sonnet 4.6", () => { |
| 295 | expect(supportsClaudeAdaptiveReasoning("claude-opus-4-6")).toBe(true); | 272 | const has = (m: string | undefined) => |
| 296 | expect(supportsClaudeAdaptiveReasoning("claude-sonnet-4-6")).toBe(true); | 273 | getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; |
| 297 | expect(supportsClaudeAdaptiveReasoning("claude-haiku-4-5")).toBe(false); | 274 | expect(has("claude-opus-4-6")).toBe(true); |
| 298 | expect(supportsClaudeAdaptiveReasoning(undefined)).toBe(false); | 275 | expect(has("claude-sonnet-4-6")).toBe(true); |
| 276 | expect(has("claude-haiku-4-5")).toBe(false); | ||
| 277 | expect(has(undefined)).toBe(false); | ||
| 299 | }); | 278 | }); |
| 300 | }); | ||
| 301 | 279 | ||
| 302 | describe("supportsClaudeMaxEffort", () => { | ||
| 303 | it("only enables max effort for Opus 4.6", () => { | 280 | it("only enables max effort for Opus 4.6", () => { |
| 304 | expect(supportsClaudeMaxEffort("claude-opus-4-6")).toBe(true); | 281 | const has = (m: string | undefined) => |
| 305 | expect(supportsClaudeMaxEffort("claude-sonnet-4-6")).toBe(false); | 282 | getModelCapabilities("claudeAgent", m).reasoningEffortLevels.some((l) => l.value === "max"); |
| 306 | expect(supportsClaudeMaxEffort("claude-haiku-4-5")).toBe(false); | 283 | expect(has("claude-opus-4-6")).toBe(true); |
| 307 | expect(supportsClaudeMaxEffort(undefined)).toBe(false); | 284 | expect(has("claude-sonnet-4-6")).toBe(false); |
| 285 | expect(has("claude-haiku-4-5")).toBe(false); | ||
| 286 | expect(has(undefined)).toBe(false); | ||
| 308 | }); | 287 | }); |
| 309 | }); | ||
| 310 | 288 | ||
| 311 | describe("supportsClaudeFastMode", () => { | ||
| 312 | it("only enables Claude fast mode for Opus 4.6", () => { | 289 | it("only enables Claude fast mode for Opus 4.6", () => { |
| 313 | expect(supportsClaudeFastMode("claude-opus-4-6")).toBe(true); | 290 | const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).supportsFastMode; |
| 314 | expect(supportsClaudeFastMode("opus")).toBe(true); | 291 | expect(has("claude-opus-4-6")).toBe(true); |
| 315 | expect(supportsClaudeFastMode("claude-sonnet-4-6")).toBe(false); | 292 | expect(has("opus")).toBe(true); |
| 316 | expect(supportsClaudeFastMode("claude-haiku-4-5")).toBe(false); | 293 | expect(has("claude-sonnet-4-6")).toBe(false); |
| 317 | expect(supportsClaudeFastMode(undefined)).toBe(false); | 294 | expect(has("claude-haiku-4-5")).toBe(false); |
| 295 | expect(has(undefined)).toBe(false); | ||
| 318 | }); | 296 | }); |
| 319 | }); | ||
| 320 | 297 | ||
| 321 | describe("supportsClaudeUltrathinkKeyword", () => { | ||
| 322 | it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { | 298 | it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { |
| 323 | expect(supportsClaudeUltrathinkKeyword("claude-opus-4-6")).toBe(true); | 299 | const has = (m: string | undefined) => |
| 324 | expect(supportsClaudeUltrathinkKeyword("claude-sonnet-4-6")).toBe(true); | 300 | getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; |
| 325 | expect(supportsClaudeUltrathinkKeyword("claude-haiku-4-5")).toBe(false); | 301 | expect(has("claude-opus-4-6")).toBe(true); |
| 302 | expect(has("claude-sonnet-4-6")).toBe(true); | ||
| 303 | expect(has("claude-haiku-4-5")).toBe(false); | ||
| 326 | }); | 304 | }); |
| 327 | }); | ||
| 328 | 305 | ||
| 329 | describe("supportsClaudeThinkingToggle", () => { | ||
| 330 | it("only enables the Claude thinking toggle for Haiku 4.5", () => { | 306 | it("only enables the Claude thinking toggle for Haiku 4.5", () => { |
| 331 | expect(supportsClaudeThinkingToggle("claude-opus-4-6")).toBe(false); | 307 | const has = (m: string | undefined) => |
| 332 | expect(supportsClaudeThinkingToggle("claude-sonnet-4-6")).toBe(false); | 308 | getModelCapabilities("claudeAgent", m).supportsThinkingToggle; |
| 333 | expect(supportsClaudeThinkingToggle("claude-haiku-4-5")).toBe(true); | 309 | expect(has("claude-opus-4-6")).toBe(false); |
| 334 | expect(supportsClaudeThinkingToggle("haiku")).toBe(true); | 310 | expect(has("claude-sonnet-4-6")).toBe(false); |
| 335 | expect(supportsClaudeThinkingToggle(undefined)).toBe(false); | 311 | expect(has("claude-haiku-4-5")).toBe(true); |
| 312 | expect(has("haiku")).toBe(true); | ||
| 313 | expect(has(undefined)).toBe(false); | ||
| 336 | }); | 314 | }); |
| 337 | }); | 315 | }); |
| 338 | 316 |
packages/shared/src/model.ts+42-124| ... | @@ -1,18 +1,15 @@ | ... | @@ -1,18 +1,15 @@ |
| 1 | import { | 1 | import { |
| 2 | CLAUDE_CODE_EFFORT_OPTIONS, | ||
| 3 | CODEX_REASONING_EFFORT_OPTIONS, | ||
| 4 | DEFAULT_MODEL_BY_PROVIDER, | 2 | DEFAULT_MODEL_BY_PROVIDER, |
| 5 | DEFAULT_REASONING_EFFORT_BY_PROVIDER, | 3 | MODEL_CAPABILITIES_INDEX, |
| 6 | MODEL_OPTIONS_BY_PROVIDER, | 4 | MODEL_OPTIONS_BY_PROVIDER, |
| 7 | MODEL_SLUG_ALIASES_BY_PROVIDER, | 5 | MODEL_SLUG_ALIASES_BY_PROVIDER, |
| 8 | REASONING_EFFORT_OPTIONS_BY_PROVIDER, | ||
| 9 | type ClaudeModelOptions, | 6 | type ClaudeModelOptions, |
| 10 | type ClaudeCodeEffort, | 7 | type ClaudeCodeEffort, |
| 11 | type CodexModelOptions, | 8 | type CodexModelOptions, |
| 12 | type CodexReasoningEffort, | 9 | type ModelCapabilities, |
| 13 | type ModelSlug, | 10 | type ModelSlug, |
| 14 | type ProviderReasoningEffort, | ||
| 15 | type ProviderKind, | 11 | type ProviderKind, |
| 12 | CodexReasoningEffort, | ||
| 16 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 17 | 14 | ||
| 18 | const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = { | 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,10 +17,6 @@ const MODEL_SLUG_SET_BY_PROVIDER: Record<ProviderKind, ReadonlySet<ModelSlug>> = |
| 20 | codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), | 17 | codex: new Set(MODEL_OPTIONS_BY_PROVIDER.codex.map((option) => option.slug)), |
| 21 | }; | 18 | }; |
| 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 | export interface SelectableModelOption { | 20 | export interface SelectableModelOption { |
| 28 | slug: string; | 21 | slug: string; |
| 29 | name: string; | 22 | name: string; |
| ... | @@ -37,25 +30,34 @@ export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { | ... | @@ -37,25 +30,34 @@ export function getDefaultModel(provider: ProviderKind = "codex"): ModelSlug { |
| 37 | return DEFAULT_MODEL_BY_PROVIDER[provider]; | 30 | return DEFAULT_MODEL_BY_PROVIDER[provider]; |
| 38 | } | 31 | } |
| 39 | 32 | ||
| 40 | export function supportsClaudeFastMode(model: string | null | undefined): boolean { | 33 | // ── Effort helpers ──────────────────────────────────────────────────── |
| 41 | return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; | ||
| 42 | } | ||
| 43 | 34 | ||
| 44 | export function supportsClaudeAdaptiveReasoning(model: string | null | undefined): boolean { | 35 | /** Check whether a capabilities object includes a given effort value. */ |
| 45 | const normalized = normalizeModelSlug(model, "claudeAgent"); | 36 | export function hasEffortLevel(caps: ModelCapabilities, value: string): boolean { |
| 46 | return normalized === CLAUDE_OPUS_4_6_MODEL || normalized === CLAUDE_SONNET_4_6_MODEL; | 37 | return caps.reasoningEffortLevels.some((l) => l.value === value); |
| 47 | } | 38 | } |
| 48 | 39 | ||
| 49 | export function supportsClaudeMaxEffort(model: string | null | undefined): boolean { | 40 | /** Return the default effort value for a capabilities object, or null if none. */ |
| 50 | return normalizeModelSlug(model, "claudeAgent") === CLAUDE_OPUS_4_6_MODEL; | 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 { | 45 | // ── Data-driven capability resolver ─────────────────────────────────── |
| 54 | return supportsClaudeAdaptiveReasoning(model); | ||
| 55 | } | ||
| 56 | 46 | ||
| 57 | export function supportsClaudeThinkingToggle(model: string | null | undefined): boolean { | 47 | export function getModelCapabilities( |
| 58 | return normalizeModelSlug(model, "claudeAgent") === CLAUDE_HAIKU_4_5_MODEL; | 48 | provider: ProviderKind, |
| 49 | model: string | null | undefined, | ||
| 50 | ): ModelCapabilities { | ||
| 51 | const slug = normalizeModelSlug(model, provider); | ||
| 52 | if (slug && MODEL_CAPABILITIES_INDEX[provider]?.[slug]) { | ||
| 53 | return MODEL_CAPABILITIES_INDEX[provider][slug]; | ||
| 54 | } | ||
| 55 | return { | ||
| 56 | reasoningEffortLevels: [], | ||
| 57 | supportsFastMode: false, | ||
| 58 | supportsThinkingToggle: false, | ||
| 59 | promptInjectedEffortLevels: [], | ||
| 60 | }; | ||
| 59 | } | 61 | } |
| 60 | 62 | ||
| 61 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { | 63 | export function isClaudeUltrathinkPrompt(text: string | null | undefined): boolean { |
| ... | @@ -136,102 +138,20 @@ export function resolveModelSlugForProvider( | ... | @@ -136,102 +138,20 @@ export function resolveModelSlugForProvider( |
| 136 | return resolveModelSlug(model, provider); | 138 | return resolveModelSlug(model, provider); |
| 137 | } | 139 | } |
| 138 | 140 | ||
| 139 | export function inferProviderForModel( | 141 | /** Trim a string, returning null for empty/missing values. */ |
| 140 | model: string | null | undefined, | 142 | export function trimOrNull<T extends string>(value: T | null | undefined): T | null { |
| 141 | fallback: ProviderKind = "codex", | 143 | if (typeof value !== "string") return null; |
| 142 | ): ProviderKind { | 144 | const trimmed = value.trim() as T; |
| 143 | const normalizedClaude = normalizeModelSlug(model, "claudeAgent"); | 145 | return trimmed || null; |
| 144 | if (normalizedClaude && MODEL_SLUG_SET_BY_PROVIDER.claudeAgent.has(normalizedClaude)) { | ||
| 145 | return "claudeAgent"; | ||
| 146 | } | ||
| 147 | |||
| 148 | const normalizedCodex = normalizeModelSlug(model, "codex"); | ||
| 149 | if (normalizedCodex && MODEL_SLUG_SET_BY_PROVIDER.codex.has(normalizedCodex)) { | ||
| 150 | return "codex"; | ||
| 151 | } | ||
| 152 | |||
| 153 | return typeof model === "string" && model.trim().startsWith("claude-") ? "claudeAgent" : fallback; | ||
| 154 | } | ||
| 155 | |||
| 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; | ||
| 226 | } | 146 | } |
| 227 | 147 | ||
| 228 | export function normalizeCodexModelOptions( | 148 | export function normalizeCodexModelOptions( |
| 149 | model: string | null | undefined, | ||
| 229 | modelOptions: CodexModelOptions | null | undefined, | 150 | modelOptions: CodexModelOptions | null | undefined, |
| 230 | ): CodexModelOptions | undefined { | 151 | ): CodexModelOptions | undefined { |
| 231 | const defaultReasoningEffort = getDefaultReasoningEffort("codex"); | 152 | const caps = getModelCapabilities("codex", model); |
| 232 | const reasoningEffort = | 153 | const defaultReasoningEffort = getDefaultEffort(caps) as CodexReasoningEffort; |
| 233 | resolveReasoningEffortForProvider("codex", modelOptions?.reasoningEffort) ?? | 154 | const reasoningEffort = trimOrNull(modelOptions?.reasoningEffort) ?? defaultReasoningEffort; |
| 234 | defaultReasoningEffort; | ||
| 235 | const fastModeEnabled = modelOptions?.fastMode === true; | 155 | const fastModeEnabled = modelOptions?.fastMode === true; |
| 236 | const nextOptions: CodexModelOptions = { | 156 | const nextOptions: CodexModelOptions = { |
| 237 | ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), | 157 | ...(reasoningEffort !== defaultReasoningEffort ? { reasoningEffort } : {}), |
| ... | @@ -244,20 +164,20 @@ export function normalizeClaudeModelOptions( | ... | @@ -244,20 +164,20 @@ export function normalizeClaudeModelOptions( |
| 244 | model: string | null | undefined, | 164 | model: string | null | undefined, |
| 245 | modelOptions: ClaudeModelOptions | null | undefined, | 165 | modelOptions: ClaudeModelOptions | null | undefined, |
| 246 | ): ClaudeModelOptions | undefined { | 166 | ): ClaudeModelOptions | undefined { |
| 247 | const reasoningOptions = getReasoningEffortOptions("claudeAgent", model); | 167 | const caps = getModelCapabilities("claudeAgent", model); |
| 248 | const defaultReasoningEffort = getDefaultReasoningEffort("claudeAgent"); | 168 | const defaultReasoningEffort = getDefaultEffort(caps); |
| 249 | const resolvedEffort = resolveReasoningEffortForProvider("claudeAgent", modelOptions?.effort); | 169 | const resolvedEffort = trimOrNull(modelOptions?.effort); |
| 170 | const isPromptInjected = caps.promptInjectedEffortLevels.includes(resolvedEffort ?? ""); | ||
| 250 | const effort = | 171 | const effort = |
| 251 | resolvedEffort && | 172 | resolvedEffort && |
| 252 | resolvedEffort !== "ultrathink" && | 173 | !isPromptInjected && |
| 253 | reasoningOptions.includes(resolvedEffort) && | 174 | hasEffortLevel(caps, resolvedEffort) && |
| 254 | resolvedEffort !== defaultReasoningEffort | 175 | resolvedEffort !== defaultReasoningEffort |
| 255 | ? resolvedEffort | 176 | ? resolvedEffort |
| 256 | : undefined; | 177 | : undefined; |
| 257 | const thinking = | 178 | const thinking = |
| 258 | supportsClaudeThinkingToggle(model) && modelOptions?.thinking === false ? false : undefined; | 179 | caps.supportsThinkingToggle && modelOptions?.thinking === false ? false : undefined; |
| 259 | const fastMode = | 180 | const fastMode = caps.supportsFastMode && modelOptions?.fastMode === true ? true : undefined; |
| 260 | supportsClaudeFastMode(model) && modelOptions?.fastMode === true ? true : undefined; | ||
| 261 | const nextOptions: ClaudeModelOptions = { | 181 | const nextOptions: ClaudeModelOptions = { |
| 262 | ...(thinking === false ? { thinking: false } : {}), | 182 | ...(thinking === false ? { thinking: false } : {}), |
| 263 | ...(effort ? { effort } : {}), | 183 | ...(effort ? { effort } : {}), |
| ... | @@ -282,5 +202,3 @@ export function applyClaudePromptEffortPrefix( | ... | @@ -282,5 +202,3 @@ export function applyClaudePromptEffortPrefix( |
| 282 | } | 202 | } |
| 283 | return `Ultrathink:\n${trimmed}`; | 203 | return `Ultrathink:\n${trimmed}`; |
| 284 | } | 204 | } |
| 285 | |||
| 286 | export { CLAUDE_CODE_EFFORT_OPTIONS, CODEX_REASONING_EFFORT_OPTIONS }; |