| 1 | import { |
| 2 | ANTIGRAVITY_DEFAULT_MODEL, |
| 3 | ProviderDriverKind, |
| 4 | ProviderInstanceId, |
| 5 | type ServerProvider, |
| 6 | } from "@t3tools/contracts"; |
| 7 | import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings"; |
| 8 | import { describe, expect, it } from "vite-plus/test"; |
| 9 | import { createModelSelection } from "@t3tools/shared/model"; |
| 10 | import { deriveEffectiveComposerModelState } from "./composerDraftStore"; |
| 11 | import { getComposerProviderState } from "./components/chat/composerProviderState"; |
| 12 | import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances"; |
| 13 | import { |
| 14 | getCustomModelOptionsByInstance, |
| 15 | getAppModelOptionsForInstance, |
| 16 | resolveAppModelSelectionForInstance, |
| 17 | resolveAppModelSelectionState, |
| 18 | } from "./modelSelection"; |
| 19 | |
| 20 | function provider(input: { |
| 21 | provider?: ProviderDriverKind; |
| 22 | instanceId: string; |
| 23 | models?: ReadonlyArray<string>; |
| 24 | supportsTextGeneration?: boolean; |
| 25 | }): ServerProvider { |
| 26 | const driver = |
| 27 | input.provider ?? |
| 28 | (input.instanceId.startsWith("claude_") |
| 29 | ? ProviderDriverKind.make("claudeAgent") |
| 30 | : ProviderDriverKind.make("codex")); |
| 31 | return { |
| 32 | instanceId: ProviderInstanceId.make(input.instanceId), |
| 33 | driver, |
| 34 | ...(input.supportsTextGeneration === undefined |
| 35 | ? {} |
| 36 | : { supportsTextGeneration: input.supportsTextGeneration }), |
| 37 | enabled: true, |
| 38 | installed: true, |
| 39 | version: null, |
| 40 | status: "ready", |
| 41 | auth: { status: "authenticated" }, |
| 42 | checkedAt: "2026-01-01T00:00:00.000Z", |
| 43 | models: (input.models ?? []).map((slug) => ({ |
| 44 | slug, |
| 45 | name: slug, |
| 46 | isCustom: false, |
| 47 | capabilities: {}, |
| 48 | })), |
| 49 | slashCommands: [], |
| 50 | skills: [], |
| 51 | }; |
| 52 | } |
| 53 | |
| 54 | function settingsWithProviderInstances(): UnifiedSettings { |
| 55 | return { |
| 56 | ...DEFAULT_UNIFIED_SETTINGS, |
| 57 | providerInstances: { |
| 58 | [ProviderInstanceId.make("claudeAgent")]: { |
| 59 | driver: ProviderDriverKind.make("claudeAgent"), |
| 60 | config: { customModels: [] }, |
| 61 | }, |
| 62 | [ProviderInstanceId.make("claude_openrouter")]: { |
| 63 | driver: ProviderDriverKind.make("claudeAgent"), |
| 64 | config: { customModels: ["openai/gpt-5.5"] }, |
| 65 | }, |
| 66 | }, |
| 67 | }; |
| 68 | } |
| 69 | |
| 70 | describe("instance-scoped model selection", () => { |
| 71 | it("preserves server-provided legacy model metadata", () => { |
| 72 | const baseProvider = provider({ |
| 73 | instanceId: "claudeAgent", |
| 74 | models: ["claude-opus-4-8"], |
| 75 | }); |
| 76 | const providers = [ |
| 77 | { |
| 78 | ...baseProvider, |
| 79 | models: [{ ...baseProvider.models[0]!, isLegacy: true }], |
| 80 | }, |
| 81 | ]; |
| 82 | const stock = deriveProviderInstanceEntries(providers)[0]!; |
| 83 | |
| 84 | expect(getAppModelOptionsForInstance(settingsWithProviderInstances(), stock)[0]?.isLegacy).toBe( |
| 85 | true, |
| 86 | ); |
| 87 | }); |
| 88 | |
| 89 | it("keeps custom models on the provider instance that declared them", () => { |
| 90 | const providers = [ |
| 91 | provider({ |
| 92 | instanceId: "claudeAgent", |
| 93 | models: ["claude-sonnet-4-6"], |
| 94 | }), |
| 95 | provider({ |
| 96 | instanceId: "claude_openrouter", |
| 97 | models: ["claude-sonnet-4-6"], |
| 98 | }), |
| 99 | ]; |
| 100 | const entries = deriveProviderInstanceEntries(providers); |
| 101 | const stock = entries.find((entry) => entry.instanceId === "claudeAgent")!; |
| 102 | const openrouter = entries.find((entry) => entry.instanceId === "claude_openrouter")!; |
| 103 | |
| 104 | expect( |
| 105 | getAppModelOptionsForInstance(settingsWithProviderInstances(), stock).map( |
| 106 | (option) => option.slug, |
| 107 | ), |
| 108 | ).not.toContain("openai/gpt-5.5"); |
| 109 | expect( |
| 110 | getAppModelOptionsForInstance(settingsWithProviderInstances(), openrouter).map( |
| 111 | (option) => option.slug, |
| 112 | ), |
| 113 | ).toContain("openai/gpt-5.5"); |
| 114 | }); |
| 115 | |
| 116 | it("resolves a custom slug against the selected custom instance", () => { |
| 117 | const providers = [ |
| 118 | provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent" }), |
| 119 | provider({ |
| 120 | provider: ProviderDriverKind.make("claudeAgent"), |
| 121 | instanceId: "claude_openrouter", |
| 122 | }), |
| 123 | ]; |
| 124 | |
| 125 | expect( |
| 126 | resolveAppModelSelectionForInstance( |
| 127 | ProviderInstanceId.make("claude_openrouter"), |
| 128 | settingsWithProviderInstances(), |
| 129 | providers, |
| 130 | "openai/gpt-5.5", |
| 131 | ), |
| 132 | ).toBe("openai/gpt-5.5"); |
| 133 | }); |
| 134 | |
| 135 | it("preserves a custom slug that collides with a provider alias", () => { |
| 136 | const providers = [ |
| 137 | provider({ |
| 138 | provider: ProviderDriverKind.make("claudeAgent"), |
| 139 | instanceId: "claude_openrouter", |
| 140 | models: ["claude-opus-4-8"], |
| 141 | }), |
| 142 | ]; |
| 143 | const settings: UnifiedSettings = { |
| 144 | ...settingsWithProviderInstances(), |
| 145 | providerInstances: { |
| 146 | ...settingsWithProviderInstances().providerInstances, |
| 147 | [ProviderInstanceId.make("claude_openrouter")]: { |
| 148 | driver: ProviderDriverKind.make("claudeAgent"), |
| 149 | config: { customModels: ["opus"] }, |
| 150 | }, |
| 151 | }, |
| 152 | }; |
| 153 | const openrouter = deriveProviderInstanceEntries(providers)[0]!; |
| 154 | |
| 155 | expect( |
| 156 | getAppModelOptionsForInstance(settings, openrouter).map((option) => option.slug), |
| 157 | ).toEqual(["claude-opus-4-8", "opus"]); |
| 158 | expect( |
| 159 | resolveAppModelSelectionForInstance( |
| 160 | ProviderInstanceId.make("claude_openrouter"), |
| 161 | settings, |
| 162 | providers, |
| 163 | "opus", |
| 164 | ), |
| 165 | ).toBe("opus"); |
| 166 | }); |
| 167 | |
| 168 | it("includes Grok custom models from the selected provider instance", () => { |
| 169 | const providers = [provider({ provider: ProviderDriverKind.make("grok"), instanceId: "grok" })]; |
| 170 | const settings: UnifiedSettings = { |
| 171 | ...settingsWithProviderInstances(), |
| 172 | providerInstances: { |
| 173 | ...settingsWithProviderInstances().providerInstances, |
| 174 | [ProviderInstanceId.make("grok")]: { |
| 175 | driver: ProviderDriverKind.make("grok"), |
| 176 | config: { customModels: ["grok-test-custom-model"] }, |
| 177 | }, |
| 178 | }, |
| 179 | }; |
| 180 | const grok = deriveProviderInstanceEntries(providers).find( |
| 181 | (entry) => entry.instanceId === "grok", |
| 182 | )!; |
| 183 | |
| 184 | expect(getAppModelOptionsForInstance(settings, grok).map((option) => option.slug)).toContain( |
| 185 | "grok-test-custom-model", |
| 186 | ); |
| 187 | }); |
| 188 | |
| 189 | it("does not inject an unknown selected slug into the stock instance list", () => { |
| 190 | const providers = [ |
| 191 | provider({ |
| 192 | instanceId: "claudeAgent", |
| 193 | models: ["claude-sonnet-4-6"], |
| 194 | }), |
| 195 | provider({ |
| 196 | instanceId: "claude_openrouter", |
| 197 | models: ["claude-sonnet-4-6"], |
| 198 | }), |
| 199 | ]; |
| 200 | const stock = deriveProviderInstanceEntries(providers).find( |
| 201 | (entry) => entry.instanceId === "claudeAgent", |
| 202 | )!; |
| 203 | |
| 204 | expect( |
| 205 | getAppModelOptionsForInstance(settingsWithProviderInstances(), stock).map( |
| 206 | (option) => option.slug, |
| 207 | ), |
| 208 | ).not.toContain("openai/gpt-5.5"); |
| 209 | }); |
| 210 | |
| 211 | it("hides server models from the instance option list", () => { |
| 212 | const providers = [ |
| 213 | provider({ |
| 214 | instanceId: "claudeAgent", |
| 215 | models: ["claude-opus-4-6", "claude-sonnet-4-6"], |
| 216 | }), |
| 217 | ]; |
| 218 | const settings: UnifiedSettings = { |
| 219 | ...settingsWithProviderInstances(), |
| 220 | providerModelPreferences: { |
| 221 | [ProviderInstanceId.make("claudeAgent")]: { |
| 222 | hiddenModels: ["claude-opus-4-6"], |
| 223 | modelOrder: [], |
| 224 | }, |
| 225 | }, |
| 226 | }; |
| 227 | const stock = deriveProviderInstanceEntries(providers).find( |
| 228 | (entry) => entry.instanceId === "claudeAgent", |
| 229 | )!; |
| 230 | |
| 231 | expect(getAppModelOptionsForInstance(settings, stock).map((option) => option.slug)).toEqual([ |
| 232 | "claude-sonnet-4-6", |
| 233 | ]); |
| 234 | }); |
| 235 | |
| 236 | it("drops server-reported custom rows that are no longer in settings", () => { |
| 237 | const baseProvider = provider({ |
| 238 | instanceId: "claude_openrouter", |
| 239 | models: ["claude-sonnet-4-6"], |
| 240 | }); |
| 241 | const providers = [ |
| 242 | { |
| 243 | ...baseProvider, |
| 244 | models: [ |
| 245 | ...baseProvider.models, |
| 246 | { slug: "removed/custom", name: "removed/custom", isCustom: true, capabilities: {} }, |
| 247 | ], |
| 248 | }, |
| 249 | ]; |
| 250 | const openrouter = deriveProviderInstanceEntries(providers)[0]!; |
| 251 | |
| 252 | expect( |
| 253 | getAppModelOptionsForInstance(settingsWithProviderInstances(), openrouter).map( |
| 254 | (option) => option.slug, |
| 255 | ), |
| 256 | ).toEqual(["claude-sonnet-4-6", "openai/gpt-5.5"]); |
| 257 | }); |
| 258 | |
| 259 | it("applies persisted per-instance model ordering", () => { |
| 260 | const providers = [ |
| 261 | provider({ |
| 262 | instanceId: "claudeAgent", |
| 263 | models: ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"], |
| 264 | }), |
| 265 | ]; |
| 266 | const settings: UnifiedSettings = { |
| 267 | ...settingsWithProviderInstances(), |
| 268 | providerModelPreferences: { |
| 269 | [ProviderInstanceId.make("claudeAgent")]: { |
| 270 | hiddenModels: [], |
| 271 | modelOrder: ["claude-haiku-4-5", "claude-opus-4-6"], |
| 272 | }, |
| 273 | }, |
| 274 | }; |
| 275 | const stock = deriveProviderInstanceEntries(providers).find( |
| 276 | (entry) => entry.instanceId === "claudeAgent", |
| 277 | )!; |
| 278 | |
| 279 | expect(getAppModelOptionsForInstance(settings, stock).map((option) => option.slug)).toEqual([ |
| 280 | "claude-haiku-4-5", |
| 281 | "claude-opus-4-6", |
| 282 | "claude-sonnet-4-6", |
| 283 | ]); |
| 284 | }); |
| 285 | |
| 286 | it("falls back when the selected model is hidden", () => { |
| 287 | const providers = [ |
| 288 | provider({ |
| 289 | instanceId: "claudeAgent", |
| 290 | models: ["claude-opus-4-6", "claude-sonnet-4-6"], |
| 291 | }), |
| 292 | ]; |
| 293 | const settings: UnifiedSettings = { |
| 294 | ...settingsWithProviderInstances(), |
| 295 | providerModelPreferences: { |
| 296 | [ProviderInstanceId.make("claudeAgent")]: { |
| 297 | hiddenModels: ["claude-opus-4-6"], |
| 298 | modelOrder: [], |
| 299 | }, |
| 300 | }, |
| 301 | }; |
| 302 | |
| 303 | expect( |
| 304 | resolveAppModelSelectionForInstance( |
| 305 | ProviderInstanceId.make("claudeAgent"), |
| 306 | settings, |
| 307 | providers, |
| 308 | "claude-opus-4-6", |
| 309 | ), |
| 310 | ).toBe("claude-sonnet-4-6"); |
| 311 | expect( |
| 312 | resolveAppModelSelectionForInstance( |
| 313 | ProviderInstanceId.make("claudeAgent"), |
| 314 | settings, |
| 315 | providers, |
| 316 | "claude-opus-4-6", |
| 317 | { preserveUnavailableSelection: true }, |
| 318 | ), |
| 319 | ).toBe("claude-sonnet-4-6"); |
| 320 | }); |
| 321 | |
| 322 | it("falls back instead of resolving a custom slug against the wrong instance", () => { |
| 323 | const providers = [ |
| 324 | provider({ |
| 325 | instanceId: "claudeAgent", |
| 326 | models: ["claude-sonnet-4-6"], |
| 327 | }), |
| 328 | provider({ |
| 329 | instanceId: "claude_openrouter", |
| 330 | models: ["claude-sonnet-4-6"], |
| 331 | }), |
| 332 | ]; |
| 333 | |
| 334 | expect( |
| 335 | resolveAppModelSelectionForInstance( |
| 336 | ProviderInstanceId.make("claudeAgent"), |
| 337 | settingsWithProviderInstances(), |
| 338 | providers, |
| 339 | "openai/gpt-5.5", |
| 340 | ), |
| 341 | ).toBe("claude-sonnet-4-6"); |
| 342 | }); |
| 343 | |
| 344 | describe.each([ |
| 345 | { |
| 346 | driverName: "opencode", |
| 347 | availableModel: "opencode/big-pickle", |
| 348 | missingModel: "opencode/kimi-k3", |
| 349 | }, |
| 350 | { |
| 351 | driverName: "antigravity", |
| 352 | availableModel: "gemini-3.1-pro", |
| 353 | missingModel: "gemini-3.1-pro-high", |
| 354 | }, |
| 355 | ])("$driverName catalog gaps", ({ driverName, availableModel, missingModel }) => { |
| 356 | it("preserves a selected model when a catalog refresh no longer contains it", () => { |
| 357 | const providers = [ |
| 358 | provider({ |
| 359 | provider: ProviderDriverKind.make(driverName), |
| 360 | instanceId: driverName, |
| 361 | models: [availableModel], |
| 362 | }), |
| 363 | ]; |
| 364 | |
| 365 | expect( |
| 366 | resolveAppModelSelectionForInstance( |
| 367 | ProviderInstanceId.make(driverName), |
| 368 | settingsWithProviderInstances(), |
| 369 | providers, |
| 370 | missingModel, |
| 371 | { preserveUnavailableSelection: true }, |
| 372 | ), |
| 373 | ).toBe(missingModel); |
| 374 | expect( |
| 375 | resolveAppModelSelectionForInstance( |
| 376 | ProviderInstanceId.make(driverName), |
| 377 | settingsWithProviderInstances(), |
| 378 | providers, |
| 379 | missingModel, |
| 380 | ), |
| 381 | ).toBe(availableModel); |
| 382 | }); |
| 383 | |
| 384 | it("adds the selected missing model as an unavailable option", () => { |
| 385 | const providers = [ |
| 386 | provider({ |
| 387 | provider: ProviderDriverKind.make(driverName), |
| 388 | instanceId: driverName, |
| 389 | models: [availableModel], |
| 390 | }), |
| 391 | ]; |
| 392 | const entry = deriveProviderInstanceEntries(providers)[0]!; |
| 393 | |
| 394 | expect( |
| 395 | getAppModelOptionsForInstance(settingsWithProviderInstances(), entry, missingModel), |
| 396 | ).toEqual([ |
| 397 | expect.objectContaining({ slug: availableModel }), |
| 398 | expect.objectContaining({ |
| 399 | slug: missingModel, |
| 400 | name: missingModel, |
| 401 | isUnavailable: true, |
| 402 | }), |
| 403 | ]); |
| 404 | }); |
| 405 | |
| 406 | it("keeps a missing option scoped to the selected instance", () => { |
| 407 | const selectedInstanceId = ProviderInstanceId.make(`${driverName}_work`); |
| 408 | const otherInstanceId = ProviderInstanceId.make(`${driverName}_personal`); |
| 409 | const driver = ProviderDriverKind.make(driverName); |
| 410 | const providers = [ |
| 411 | provider({ provider: driver, instanceId: selectedInstanceId, models: [] }), |
| 412 | provider({ provider: driver, instanceId: otherInstanceId, models: [] }), |
| 413 | ]; |
| 414 | const options = getCustomModelOptionsByInstance( |
| 415 | settingsWithProviderInstances(), |
| 416 | providers, |
| 417 | selectedInstanceId, |
| 418 | missingModel, |
| 419 | ); |
| 420 | |
| 421 | expect(options.get(selectedInstanceId)).toEqual([ |
| 422 | expect.objectContaining({ slug: missingModel, isUnavailable: true }), |
| 423 | ]); |
| 424 | expect(options.get(otherInstanceId)).toEqual([]); |
| 425 | }); |
| 426 | |
| 427 | it("replaces the unavailable marker with catalog metadata after recovery", () => { |
| 428 | const instanceId = ProviderInstanceId.make(driverName); |
| 429 | const driver = ProviderDriverKind.make(driverName); |
| 430 | const selectedModel = missingModel; |
| 431 | const pendingProviders = [ |
| 432 | provider({ provider: driver, instanceId, models: [availableModel] }), |
| 433 | ]; |
| 434 | const recoveredProviders = [ |
| 435 | provider({ provider: driver, instanceId, models: [availableModel, selectedModel] }), |
| 436 | ]; |
| 437 | |
| 438 | expect( |
| 439 | getAppModelOptionsForInstance( |
| 440 | settingsWithProviderInstances(), |
| 441 | deriveProviderInstanceEntries(pendingProviders)[0]!, |
| 442 | selectedModel, |
| 443 | ).find((option) => option.slug === selectedModel)?.isUnavailable, |
| 444 | ).toBe(true); |
| 445 | expect( |
| 446 | getAppModelOptionsForInstance( |
| 447 | settingsWithProviderInstances(), |
| 448 | deriveProviderInstanceEntries(recoveredProviders)[0]!, |
| 449 | selectedModel, |
| 450 | ).find((option) => option.slug === selectedModel)?.isUnavailable, |
| 451 | ).toBeUndefined(); |
| 452 | expect( |
| 453 | resolveAppModelSelectionForInstance( |
| 454 | instanceId, |
| 455 | settingsWithProviderInstances(), |
| 456 | recoveredProviders, |
| 457 | selectedModel, |
| 458 | { preserveUnavailableSelection: true }, |
| 459 | ), |
| 460 | ).toBe(selectedModel); |
| 461 | }); |
| 462 | |
| 463 | it("does not resurrect a hidden model when the raw catalog omits it", () => { |
| 464 | const instanceId = ProviderInstanceId.make(driverName); |
| 465 | const driver = ProviderDriverKind.make(driverName); |
| 466 | const settings: UnifiedSettings = { |
| 467 | ...settingsWithProviderInstances(), |
| 468 | providerModelPreferences: { |
| 469 | [instanceId]: { |
| 470 | hiddenModels: [missingModel], |
| 471 | modelOrder: [], |
| 472 | }, |
| 473 | }, |
| 474 | }; |
| 475 | const providers = [provider({ provider: driver, instanceId, models: [] })]; |
| 476 | const entry = deriveProviderInstanceEntries(providers)[0]!; |
| 477 | |
| 478 | expect(getAppModelOptionsForInstance(settings, entry, missingModel)).toEqual([]); |
| 479 | expect( |
| 480 | resolveAppModelSelectionForInstance(instanceId, settings, providers, missingModel, { |
| 481 | preserveUnavailableSelection: true, |
| 482 | }), |
| 483 | ).toBeNull(); |
| 484 | }); |
| 485 | }); |
| 486 | |
| 487 | it("does not add unavailable options for other providers", () => { |
| 488 | const providers = [ |
| 489 | provider({ |
| 490 | provider: ProviderDriverKind.make("codex"), |
| 491 | instanceId: "codex", |
| 492 | models: ["gpt-5.6-sol"], |
| 493 | }), |
| 494 | ]; |
| 495 | const entry = deriveProviderInstanceEntries(providers)[0]!; |
| 496 | |
| 497 | expect( |
| 498 | getAppModelOptionsForInstance(settingsWithProviderInstances(), entry, "gpt-missing").map( |
| 499 | (option) => option.slug, |
| 500 | ), |
| 501 | ).toEqual(["gpt-5.6-sol"]); |
| 502 | expect( |
| 503 | resolveAppModelSelectionForInstance( |
| 504 | ProviderInstanceId.make("codex"), |
| 505 | settingsWithProviderInstances(), |
| 506 | providers, |
| 507 | "gpt-missing", |
| 508 | { preserveUnavailableSelection: true }, |
| 509 | ), |
| 510 | ).toBe("gpt-5.6-sol"); |
| 511 | }); |
| 512 | |
| 513 | it("falls back from an explicit non-OpenCode draft with a missing model", () => { |
| 514 | const instanceId = ProviderInstanceId.make("codex"); |
| 515 | const driver = ProviderDriverKind.make("codex"); |
| 516 | const providers = [provider({ provider: driver, instanceId, models: ["gpt-5.6-sol"] })]; |
| 517 | const state = deriveEffectiveComposerModelState({ |
| 518 | draft: { |
| 519 | activeProvider: instanceId, |
| 520 | modelSelectionByProvider: { |
| 521 | [instanceId]: createModelSelection(instanceId, "gpt-missing", [ |
| 522 | { id: "effort", value: "max" }, |
| 523 | ]), |
| 524 | }, |
| 525 | }, |
| 526 | providers, |
| 527 | selectedProvider: driver, |
| 528 | selectedInstanceId: instanceId, |
| 529 | threadModelSelection: null, |
| 530 | projectModelSelection: null, |
| 531 | settings: settingsWithProviderInstances(), |
| 532 | }); |
| 533 | const dispatch = getComposerProviderState({ |
| 534 | provider: driver, |
| 535 | model: state.selectedModel, |
| 536 | models: providers[0]!.models, |
| 537 | modelOptions: state.modelOptions?.[instanceId], |
| 538 | planModeEnabled: false, |
| 539 | }); |
| 540 | |
| 541 | expect(state.selectedModel).toBe("gpt-5.6-sol"); |
| 542 | expect(dispatch.modelOptionsForDispatch).toBeUndefined(); |
| 543 | }); |
| 544 | |
| 545 | it("preserves an explicit draft OpenCode selection while the catalog is empty", () => { |
| 546 | const instanceId = ProviderInstanceId.make("opencode_work"); |
| 547 | const driver = ProviderDriverKind.make("opencode"); |
| 548 | const draftSelection = createModelSelection(instanceId, "openrouter/kimi-k3", [ |
| 549 | { id: "variant", value: "max" }, |
| 550 | { id: "agent", value: "build" }, |
| 551 | ]); |
| 552 | const providers = [provider({ provider: driver, instanceId, models: [] })]; |
| 553 | const state = deriveEffectiveComposerModelState({ |
| 554 | draft: { |
| 555 | activeProvider: instanceId, |
| 556 | modelSelectionByProvider: { [instanceId]: draftSelection }, |
| 557 | }, |
| 558 | providers, |
| 559 | selectedProvider: driver, |
| 560 | selectedInstanceId: instanceId, |
| 561 | threadModelSelection: null, |
| 562 | projectModelSelection: null, |
| 563 | settings: settingsWithProviderInstances(), |
| 564 | }); |
| 565 | |
| 566 | expect(state.selectedModel).toBe("openrouter/kimi-k3"); |
| 567 | expect(state.modelOptions?.[instanceId]).toEqual(draftSelection.options); |
| 568 | }); |
| 569 | |
| 570 | it("preserves the Antigravity model in drafts and existing threads after sign-out", () => { |
| 571 | const instanceId = ProviderInstanceId.make("antigravity_work"); |
| 572 | const driver = ProviderDriverKind.make("antigravity"); |
| 573 | const saved = createModelSelection(instanceId, "gemini-3.1-pro-high"); |
| 574 | const providers = [ |
| 575 | { |
| 576 | ...provider({ provider: driver, instanceId, models: [] }), |
| 577 | status: "error" as const, |
| 578 | auth: { status: "unauthenticated" as const }, |
| 579 | }, |
| 580 | ]; |
| 581 | for (const draft of [ |
| 582 | null, |
| 583 | { activeProvider: instanceId, modelSelectionByProvider: { [instanceId]: saved } }, |
| 584 | ]) { |
| 585 | const state = deriveEffectiveComposerModelState({ |
| 586 | draft, |
| 587 | providers, |
| 588 | selectedProvider: driver, |
| 589 | selectedInstanceId: instanceId, |
| 590 | threadModelSelection: saved, |
| 591 | projectModelSelection: null, |
| 592 | settings: settingsWithProviderInstances(), |
| 593 | }); |
| 594 | expect(state.selectedModel).toBe(saved.model); |
| 595 | } |
| 596 | }); |
| 597 | |
| 598 | it("does not borrow a default model while a new Antigravity account has no catalog", () => { |
| 599 | const driver = ProviderDriverKind.make("antigravity"); |
| 600 | const instanceId = ProviderInstanceId.make("antigravity_work"); |
| 601 | const providers = [ |
| 602 | provider({ instanceId: "codex", models: ["gpt-5.6-sol"] }), |
| 603 | provider({ provider: driver, instanceId: "antigravity", models: ["gemini-other-account"] }), |
| 604 | provider({ provider: driver, instanceId, models: [] }), |
| 605 | ]; |
| 606 | |
| 607 | const otherAccountId = ProviderInstanceId.make("antigravity"); |
| 608 | for (const draft of [ |
| 609 | null, |
| 610 | { |
| 611 | activeProvider: instanceId, |
| 612 | modelSelectionByProvider: { |
| 613 | [otherAccountId]: createModelSelection(otherAccountId, "gemini-other-account"), |
| 614 | }, |
| 615 | }, |
| 616 | ]) { |
| 617 | const state = deriveEffectiveComposerModelState({ |
| 618 | draft, |
| 619 | providers, |
| 620 | selectedProvider: driver, |
| 621 | selectedInstanceId: instanceId, |
| 622 | threadModelSelection: null, |
| 623 | projectModelSelection: createModelSelection( |
| 624 | ProviderInstanceId.make("codex"), |
| 625 | "gpt-5.6-sol", |
| 626 | ), |
| 627 | settings: settingsWithProviderInstances(), |
| 628 | }); |
| 629 | expect(state.selectedModel).toBe(""); |
| 630 | } |
| 631 | }); |
| 632 | |
| 633 | it("offers only account catalog models for Antigravity despite custom model settings", () => { |
| 634 | const driver = ProviderDriverKind.make("antigravity"); |
| 635 | const customId = ProviderInstanceId.make("antigravity_work"); |
| 636 | const nativeModel = "gemini-3.1-pro"; |
| 637 | const settings: UnifiedSettings = { |
| 638 | ...DEFAULT_UNIFIED_SETTINGS, |
| 639 | providerInstances: { |
| 640 | [ProviderInstanceId.make("antigravity")]: { |
| 641 | driver, |
| 642 | config: { customModels: ["api-only-model"] }, |
| 643 | }, |
| 644 | [customId]: { driver, config: { customModels: ["unknown-model"] } }, |
| 645 | }, |
| 646 | }; |
| 647 | const entries = deriveProviderInstanceEntries([ |
| 648 | provider({ provider: driver, instanceId: "antigravity", models: [nativeModel] }), |
| 649 | provider({ provider: driver, instanceId: customId, models: [nativeModel] }), |
| 650 | ]); |
| 651 | |
| 652 | for (const entry of entries) { |
| 653 | expect(getAppModelOptionsForInstance(settings, entry).map((model) => model.slug)).toEqual([ |
| 654 | nativeModel, |
| 655 | ]); |
| 656 | } |
| 657 | }); |
| 658 | |
| 659 | it("resolves the Antigravity default marker without creating an unavailable model", () => { |
| 660 | const instanceId = ProviderInstanceId.make("antigravity_work"); |
| 661 | const nativeModel = "gemini-3.1-pro"; |
| 662 | const base = provider({ |
| 663 | provider: ProviderDriverKind.make("antigravity"), |
| 664 | instanceId, |
| 665 | models: [nativeModel], |
| 666 | }); |
| 667 | const liveProvider = { |
| 668 | ...base, |
| 669 | models: base.models.map((model) => ({ |
| 670 | ...model, |
| 671 | isDefault: true, |
| 672 | aliases: [ANTIGRAVITY_DEFAULT_MODEL], |
| 673 | })), |
| 674 | }; |
| 675 | const settings = settingsWithProviderInstances(); |
| 676 | |
| 677 | expect( |
| 678 | getAppModelOptionsForInstance( |
| 679 | settings, |
| 680 | deriveProviderInstanceEntries([liveProvider])[0]!, |
| 681 | ANTIGRAVITY_DEFAULT_MODEL, |
| 682 | ).map((model) => model.slug), |
| 683 | ).toEqual([nativeModel]); |
| 684 | expect( |
| 685 | resolveAppModelSelectionForInstance( |
| 686 | instanceId, |
| 687 | settings, |
| 688 | [liveProvider], |
| 689 | ANTIGRAVITY_DEFAULT_MODEL, |
| 690 | { |
| 691 | preserveUnavailableSelection: true, |
| 692 | }, |
| 693 | ), |
| 694 | ).toBe(nativeModel); |
| 695 | |
| 696 | const hiddenSettings: UnifiedSettings = { |
| 697 | ...settings, |
| 698 | providerModelPreferences: { |
| 699 | [instanceId]: { hiddenModels: [nativeModel], modelOrder: [] }, |
| 700 | }, |
| 701 | }; |
| 702 | expect( |
| 703 | resolveAppModelSelectionForInstance( |
| 704 | instanceId, |
| 705 | hiddenSettings, |
| 706 | [liveProvider], |
| 707 | ANTIGRAVITY_DEFAULT_MODEL, |
| 708 | { preserveUnavailableSelection: true }, |
| 709 | ), |
| 710 | ).toBeNull(); |
| 711 | expect( |
| 712 | getAppModelOptionsForInstance( |
| 713 | settings, |
| 714 | deriveProviderInstanceEntries([{ ...base, models: [] }])[0]!, |
| 715 | ANTIGRAVITY_DEFAULT_MODEL, |
| 716 | ), |
| 717 | ).toEqual([]); |
| 718 | }); |
| 719 | |
| 720 | it("preserves saved options through dispatch when the model is absent from the catalog", () => { |
| 721 | const instanceId = ProviderInstanceId.make("opencode"); |
| 722 | const driver = ProviderDriverKind.make("opencode"); |
| 723 | const providers = [provider({ provider: driver, instanceId, models: ["opencode/big-pickle"] })]; |
| 724 | const saved = createModelSelection(instanceId, "opencode/kimi-k3", [ |
| 725 | { id: "variant", value: "max" }, |
| 726 | { id: "agent", value: "build" }, |
| 727 | ]); |
| 728 | const state = deriveEffectiveComposerModelState({ |
| 729 | draft: null, |
| 730 | providers, |
| 731 | selectedProvider: driver, |
| 732 | selectedInstanceId: instanceId, |
| 733 | threadModelSelection: saved, |
| 734 | projectModelSelection: null, |
| 735 | settings: settingsWithProviderInstances(), |
| 736 | }); |
| 737 | const dispatch = getComposerProviderState({ |
| 738 | provider: driver, |
| 739 | model: state.selectedModel, |
| 740 | models: providers[0]!.models, |
| 741 | modelOptions: state.modelOptions?.[instanceId], |
| 742 | planModeEnabled: false, |
| 743 | }); |
| 744 | |
| 745 | expect( |
| 746 | createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch), |
| 747 | ).toEqual(saved); |
| 748 | }); |
| 749 | |
| 750 | it("keeps a custom-instance draft model while dropping unsupported options", () => { |
| 751 | const instanceId = ProviderInstanceId.make("claude_openrouter"); |
| 752 | const driver = ProviderDriverKind.make("claudeAgent"); |
| 753 | const providers = [ |
| 754 | provider({ provider: driver, instanceId: "claudeAgent", models: ["claude-opus-5"] }), |
| 755 | provider({ provider: driver, instanceId, models: ["claude-opus-5"] }), |
| 756 | ]; |
| 757 | const threadSelection = createModelSelection(instanceId, "claude-opus-5", [ |
| 758 | { id: "effort", value: "high" }, |
| 759 | ]); |
| 760 | const draftSelection = createModelSelection(instanceId, "openai/gpt-5.5", [ |
| 761 | { id: "effort", value: "max" }, |
| 762 | ]); |
| 763 | const state = deriveEffectiveComposerModelState({ |
| 764 | draft: { |
| 765 | activeProvider: instanceId, |
| 766 | modelSelectionByProvider: { [instanceId]: draftSelection }, |
| 767 | }, |
| 768 | providers, |
| 769 | selectedProvider: driver, |
| 770 | selectedInstanceId: instanceId, |
| 771 | threadModelSelection: threadSelection, |
| 772 | projectModelSelection: null, |
| 773 | settings: settingsWithProviderInstances(), |
| 774 | }); |
| 775 | const dispatch = getComposerProviderState({ |
| 776 | provider: driver, |
| 777 | model: state.selectedModel, |
| 778 | models: providers[1]!.models, |
| 779 | modelOptions: state.modelOptions?.[instanceId], |
| 780 | planModeEnabled: false, |
| 781 | }); |
| 782 | |
| 783 | expect( |
| 784 | createModelSelection(instanceId, state.selectedModel, dispatch.modelOptionsForDispatch), |
| 785 | ).toEqual(createModelSelection(instanceId, "openai/gpt-5.5")); |
| 786 | }); |
| 787 | |
| 788 | it("preserves custom provider instances in settings model selection", () => { |
| 789 | const providers = [ |
| 790 | provider({ |
| 791 | instanceId: "claudeAgent", |
| 792 | models: ["claude-sonnet-4-6"], |
| 793 | }), |
| 794 | provider({ |
| 795 | instanceId: "claude_openrouter", |
| 796 | models: ["claude-sonnet-4-6"], |
| 797 | }), |
| 798 | ]; |
| 799 | const settings: UnifiedSettings = { |
| 800 | ...settingsWithProviderInstances(), |
| 801 | textGenerationModelSelection: { |
| 802 | instanceId: ProviderInstanceId.make("claude_openrouter"), |
| 803 | model: "openai/gpt-5.5", |
| 804 | }, |
| 805 | }; |
| 806 | |
| 807 | expect(resolveAppModelSelectionState(settings, providers)).toEqual({ |
| 808 | instanceId: ProviderInstanceId.make("claude_openrouter"), |
| 809 | model: "openai/gpt-5.5", |
| 810 | }); |
| 811 | }); |
| 812 | |
| 813 | it("self-heals a persisted selection pointing at a text-generation-incapable instance", () => { |
| 814 | const providers = [ |
| 815 | provider({ |
| 816 | provider: ProviderDriverKind.make("acpRegistry"), |
| 817 | instanceId: "acp_gemini", |
| 818 | models: ["default"], |
| 819 | supportsTextGeneration: false, |
| 820 | }), |
| 821 | provider({ |
| 822 | instanceId: "codex", |
| 823 | models: ["gpt-5.6-luna"], |
| 824 | }), |
| 825 | ]; |
| 826 | const settings: UnifiedSettings = { |
| 827 | ...settingsWithProviderInstances(), |
| 828 | textGenerationModelSelection: { |
| 829 | instanceId: ProviderInstanceId.make("acp_gemini"), |
| 830 | model: "default", |
| 831 | }, |
| 832 | }; |
| 833 | |
| 834 | expect(resolveAppModelSelectionState(settings, providers).instanceId).toBe( |
| 835 | ProviderInstanceId.make("codex"), |
| 836 | ); |
| 837 | }); |
| 838 | it("does not select a provider that cannot generate system text", () => { |
| 839 | const instanceId = ProviderInstanceId.make("antigravity"); |
| 840 | const unsupported = { |
| 841 | ...provider({ |
| 842 | provider: ProviderDriverKind.make("antigravity"), |
| 843 | instanceId, |
| 844 | models: ["gemini-3.1-pro"], |
| 845 | }), |
| 846 | supportsTextGeneration: false, |
| 847 | }; |
| 848 | const supported = provider({ instanceId: "codex", models: ["gpt-5.6-sol"] }); |
| 849 | const settings = { |
| 850 | ...settingsWithProviderInstances(), |
| 851 | textGenerationModelSelection: createModelSelection(instanceId, "gemini-3.1-pro"), |
| 852 | }; |
| 853 | |
| 854 | expect(resolveAppModelSelectionState(settings, [unsupported, supported])).toEqual( |
| 855 | createModelSelection(supported.instanceId, "gpt-5.6-sol"), |
| 856 | ); |
| 857 | expect(resolveAppModelSelectionState(settings, [unsupported])).toEqual( |
| 858 | NO_PROVIDER_MODEL_SELECTION, |
| 859 | ); |
| 860 | }); |
| 861 | }); |
| 862 | |
| 863 | describe("resolveAppModelSelectionState with the opencode plan agent", () => { |
| 864 | const instanceId = ProviderInstanceId.make("opencode"); |
| 865 | const model = "opencode/gpt-5.4"; |
| 866 | const opencode: ServerProvider = { |
| 867 | ...provider({ provider: ProviderDriverKind.make("opencode"), instanceId: "opencode" }), |
| 868 | models: [ |
| 869 | { |
| 870 | slug: model, |
| 871 | name: model, |
| 872 | isCustom: false, |
| 873 | capabilities: { |
| 874 | optionDescriptors: [ |
| 875 | { |
| 876 | id: "agent", |
| 877 | label: "Agent", |
| 878 | type: "select", |
| 879 | options: [ |
| 880 | { id: "build", label: "Build", isDefault: true }, |
| 881 | { id: "plan", label: "Plan" }, |
| 882 | ], |
| 883 | currentValue: "build", |
| 884 | }, |
| 885 | ], |
| 886 | }, |
| 887 | }, |
| 888 | ], |
| 889 | }; |
| 890 | const settings: UnifiedSettings = { |
| 891 | ...DEFAULT_UNIFIED_SETTINGS, |
| 892 | planModeEnabled: false, |
| 893 | textGenerationModelSelection: createModelSelection(instanceId, model, [ |
| 894 | { id: "agent", value: "plan" }, |
| 895 | ]), |
| 896 | }; |
| 897 | |
| 898 | it("keeps a stored plan agent this device did not pick", () => { |
| 899 | expect(resolveAppModelSelectionState(settings, [opencode])).toEqual( |
| 900 | createModelSelection(instanceId, model, [{ id: "agent", value: "plan" }]), |
| 901 | ); |
| 902 | }); |
| 903 | }); |