1import {
2 ANTIGRAVITY_DEFAULT_MODEL,
3 ProviderDriverKind,
4 ProviderInstanceId,
5 type ServerProvider,
6} from "@t3tools/contracts";
7import { DEFAULT_UNIFIED_SETTINGS, type UnifiedSettings } from "@t3tools/contracts/settings";
8import { describe, expect, it } from "vite-plus/test";
9import { createModelSelection } from "@t3tools/shared/model";
10import { deriveEffectiveComposerModelState } from "./composerDraftStore";
11import { getComposerProviderState } from "./components/chat/composerProviderState";
12import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances";
13import {
14 getCustomModelOptionsByInstance,
15 getAppModelOptionsForInstance,
16 resolveAppModelSelectionForInstance,
17 resolveAppModelSelectionState,
18} from "./modelSelection";
19
20function 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
54function 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
70describe("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
863describe("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});