|
|
@@ -1,6 +1,5 @@
|
|
|
import { describe, expect, test } from 'bun:test';
|
|
|
import type { ModelEntry } from '../config/schema';
|
|
|
-import { normalizeFallbackChainsForPreset } from './fallback-chains';
|
|
|
|
|
|
/**
|
|
|
* Test the model array resolution logic that runs in the config hook.
|
|
|
@@ -66,198 +65,3 @@ describe('model array resolution', () => {
|
|
|
expect(result).toBeNull();
|
|
|
});
|
|
|
});
|
|
|
-
|
|
|
-/**
|
|
|
- * Tests for the fallback.chains merging logic that runs in the config hook.
|
|
|
- * Mirrors the effectiveArrays construction in src/index.ts.
|
|
|
- */
|
|
|
-describe('fallback.chains merging for foreground agents', () => {
|
|
|
- /**
|
|
|
- * Simulates the effectiveArrays construction + resolution from src/index.ts.
|
|
|
- * Returns the resolved model string or null.
|
|
|
- */
|
|
|
- function resolveWithChains(opts: {
|
|
|
- modelArray?: Array<{ id: string; variant?: string }>;
|
|
|
- currentModel?: string;
|
|
|
- chainModels?: string[];
|
|
|
- preset?: string;
|
|
|
- fallbackEnabled?: boolean;
|
|
|
- }): string | null {
|
|
|
- const {
|
|
|
- modelArray,
|
|
|
- currentModel,
|
|
|
- chainModels,
|
|
|
- preset,
|
|
|
- fallbackEnabled = true,
|
|
|
- } = opts;
|
|
|
-
|
|
|
- // Build effectiveArrays (mirrors index.ts logic)
|
|
|
- const effectiveArray: Array<{ id: string; variant?: string }> = modelArray
|
|
|
- ? [...modelArray]
|
|
|
- : [];
|
|
|
-
|
|
|
- if (fallbackEnabled && chainModels && chainModels.length > 0) {
|
|
|
- const normalized = normalizeFallbackChainsForPreset(
|
|
|
- {
|
|
|
- [currentModel ?? 'orchestrator']: chainModels,
|
|
|
- },
|
|
|
- preset,
|
|
|
- );
|
|
|
- const normalizedModels = Object.values(normalized)[0] ?? [];
|
|
|
- if (effectiveArray.length === 0 && currentModel) {
|
|
|
- effectiveArray.push({ id: currentModel });
|
|
|
- }
|
|
|
- const seen = new Set(effectiveArray.map((m) => m.id));
|
|
|
- for (const chainModel of normalizedModels) {
|
|
|
- if (!seen.has(chainModel)) {
|
|
|
- seen.add(chainModel);
|
|
|
- effectiveArray.push({ id: chainModel });
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if (effectiveArray.length === 0) return null;
|
|
|
-
|
|
|
- // Resolution: always use first model in effective array
|
|
|
- return effectiveArray[0].id;
|
|
|
- }
|
|
|
-
|
|
|
- test('primary model wins regardless of provider config', () => {
|
|
|
- const result = resolveWithChains({
|
|
|
- currentModel: 'anthropic/claude-opus-4-5',
|
|
|
- chainModels: ['openai/gpt-4o'],
|
|
|
- });
|
|
|
- expect(result).toBe('anthropic/claude-opus-4-5');
|
|
|
- });
|
|
|
-
|
|
|
- test('chain is ignored when fallback disabled', () => {
|
|
|
- const result = resolveWithChains({
|
|
|
- currentModel: 'anthropic/claude-opus-4-5',
|
|
|
- chainModels: ['openai/gpt-4o'],
|
|
|
- fallbackEnabled: false,
|
|
|
- });
|
|
|
- // chain not applied; no effectiveArray entry → falls through to null (no _modelArray either)
|
|
|
- expect(result).toBeNull();
|
|
|
- });
|
|
|
-
|
|
|
- test('_modelArray entries take precedence and chain appends after', () => {
|
|
|
- const result = resolveWithChains({
|
|
|
- modelArray: [
|
|
|
- { id: 'anthropic/claude-opus-4-5' },
|
|
|
- { id: 'anthropic/claude-sonnet-4-5' },
|
|
|
- ],
|
|
|
- chainModels: ['openai/gpt-4o'],
|
|
|
- });
|
|
|
- // First entry in _modelArray wins; chain only used for runtime failover
|
|
|
- expect(result).toBe('anthropic/claude-opus-4-5');
|
|
|
- });
|
|
|
-
|
|
|
- test('duplicate model ids across array and chain are deduplicated', () => {
|
|
|
- const result = resolveWithChains({
|
|
|
- modelArray: [
|
|
|
- { id: 'anthropic/claude-opus-4-5' },
|
|
|
- { id: 'openai/gpt-4o' },
|
|
|
- ],
|
|
|
- chainModels: ['openai/gpt-4o', 'google/gemini-pro'],
|
|
|
- });
|
|
|
- expect(result).toBe('anthropic/claude-opus-4-5');
|
|
|
- });
|
|
|
-
|
|
|
- test('no currentModel and no _modelArray with chain still resolves', () => {
|
|
|
- const result = resolveWithChains({
|
|
|
- chainModels: ['openai/gpt-4o', 'anthropic/claude-sonnet-4-5'],
|
|
|
- });
|
|
|
- expect(result).toBe('openai/gpt-4o');
|
|
|
- });
|
|
|
-
|
|
|
- test('built-in provider not skipped when other providers are configured', () => {
|
|
|
- // Regression test: github-copilot is auto-loaded by opencode and doesn't
|
|
|
- // need an entry in opencodeConfig.provider. The resolver must not skip
|
|
|
- // it in favor of a configured provider later in the chain.
|
|
|
- const result = resolveWithChains({
|
|
|
- currentModel: 'github-copilot/claude-opus-4.6',
|
|
|
- chainModels: [
|
|
|
- 'github-copilot/gemini-3.1-pro-preview',
|
|
|
- 'zai-coding-plan/glm-5',
|
|
|
- ],
|
|
|
- });
|
|
|
- expect(result).toBe('github-copilot/claude-opus-4.6');
|
|
|
- });
|
|
|
-
|
|
|
- test('normalizes scoped fallback chains for the active preset', () => {
|
|
|
- const result = normalizeFallbackChainsForPreset(
|
|
|
- {
|
|
|
- 'gpt-plus-max:orchestrator': [
|
|
|
- 'openai/o3',
|
|
|
- 'anthropic/claude-sonnet-4-6',
|
|
|
- ],
|
|
|
- 'gpt-plus-max:oracle': ['anthropic/claude-opus-4-5'],
|
|
|
- },
|
|
|
- 'gpt-plus-max',
|
|
|
- );
|
|
|
-
|
|
|
- expect(result).toEqual({
|
|
|
- orchestrator: ['openai/o3', 'anthropic/claude-sonnet-4-6'],
|
|
|
- oracle: ['anthropic/claude-opus-4-5'],
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- test('ignores scoped fallback chains for other presets', () => {
|
|
|
- const result = normalizeFallbackChainsForPreset(
|
|
|
- {
|
|
|
- 'gpt-plus-max:orchestrator': ['openai/o3'],
|
|
|
- orchestrator: ['anthropic/claude-sonnet-4-6'],
|
|
|
- },
|
|
|
- 'other-preset',
|
|
|
- );
|
|
|
-
|
|
|
- expect(result).toEqual({
|
|
|
- orchestrator: ['anthropic/claude-sonnet-4-6'],
|
|
|
- });
|
|
|
- });
|
|
|
-
|
|
|
- test('does not emit scoped keys in normalized output', () => {
|
|
|
- const result = normalizeFallbackChainsForPreset(
|
|
|
- {
|
|
|
- 'gpt-plus-max:orchestrator': ['openai/o3'],
|
|
|
- },
|
|
|
- 'gpt-plus-max',
|
|
|
- );
|
|
|
-
|
|
|
- expect(Object.keys(result).some((key) => key.includes(':'))).toBe(false);
|
|
|
- });
|
|
|
-
|
|
|
- test('ignores empty fallback chain agent names', () => {
|
|
|
- const result = normalizeFallbackChainsForPreset(
|
|
|
- {
|
|
|
- '': ['openai/o3'],
|
|
|
- 'gpt-plus-max:': ['anthropic/claude-sonnet-4-6'],
|
|
|
- orchestrator: ['ustc-deepseek/deepseek-v4-pro'],
|
|
|
- },
|
|
|
- 'gpt-plus-max',
|
|
|
- );
|
|
|
-
|
|
|
- expect(result).toEqual({
|
|
|
- orchestrator: ['ustc-deepseek/deepseek-v4-pro'],
|
|
|
- });
|
|
|
- expect(result).not.toHaveProperty('');
|
|
|
- });
|
|
|
-
|
|
|
- test('runtime preset takes precedence over config preset for scoped fallback chains', () => {
|
|
|
- const configPreset = 'cheap';
|
|
|
- const runtimePreset = 'powerful';
|
|
|
- const activePreset = runtimePreset ?? configPreset ?? null;
|
|
|
-
|
|
|
- const result = normalizeFallbackChainsForPreset(
|
|
|
- {
|
|
|
- 'cheap:orchestrator': ['cheap/model'],
|
|
|
- 'powerful:orchestrator': ['powerful/model'],
|
|
|
- },
|
|
|
- activePreset,
|
|
|
- );
|
|
|
-
|
|
|
- expect(result).toEqual({
|
|
|
- orchestrator: ['powerful/model'],
|
|
|
- });
|
|
|
- });
|
|
|
-});
|